Imported from RimantasZ/contextspy (
AGENTS.md). Install upstream withnpx skills add RimantasZ/contextspy. Copyright stays with the author.
Agent Instructions
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
What this is
ContextSpy is a local proxy that sits between a coding agent and an LLM API, records every
request, and classifies the input tokens of each request into 8 categories to show how the
context window is being used. Backend is Python (FastAPI + mitmproxy + SQLAlchemy/SQLite);
frontend is a React/Vite SPA. All data stays local in ~/.contextspy/.
Commands
make build # uv pip install -e . + build the UI into contextspy/_web/
make install # uv pip install -e . only
make ui # cd ui && npm install && npm run build (outputs to contextspy/_web/)
make dev-backend # uvicorn ...api.main:create_app --factory --reload --port 5173
make dev-ui # cd ui && npm run dev (Vite on :5174, proxies /api + /ws → :5173)
uv pip install -e ".[dev]" # install with pytest
pytest # run the test suite
pytest tests/test_providers.py::test_name # run a single test
contextspy start # production entrypoint: starts proxy + web server together
There is no linter/formatter configured. Backend tests under tests/ cover adapters/classification,
normalization, WebSocket protocols, migrations, and request filtering. Frontend component tests
use Vitest and React Testing Library (cd ui && npm test). When you touch backend analysis or
capture code, run pytest; when you touch ui/src/, run the frontend tests and build.
Build/packaging gotcha
The React UI is built into contextspy/_web/, which is gitignored but shipped as package
data ([tool.setuptools.package-data] in pyproject.toml). After changing anything under
ui/src/, you must run make ui (or cd ui && npm run build) for the change to appear in the
running app — contextspy start serves the pre-built _web/, not the Vite dev server. During
active UI work, use make dev-ui + make dev-backend instead.
Policy: analysis logic lives in Python
All logic related to request analysis, breaking requests down into blocks, and identifying the
composition of the token window (categorization, aggregation, per-tool/per-category totals,
detecting file contents / cache hits / thresholds, or any "what counts as X" decision) must be
implemented in the Python backend (analysis/, db/), never in the frontend. The frontend
(ui/src/) should only format, chart-layout, and display numbers the API already computed — it
must not re-derive token counts, percentages, categories, or aggregates from raw block data in
JS/TS. This keeps analysis behavior identical across the CLI, API, and UI, and keeps it covered
by tests/test_providers.py.
When this requires a new field on Block/Request/BlockRecord or a new derived/backfillable
column, update db/models.py and follow the schema-change steps below (db/database.py: _migrate() for additive columns; db/migrations.py _migrate_to_vN + SCHEMA_VERSION bump for
backfilled/derived data) — don't let a schema change ship without its migration step.
When touching existing frontend code, if you notice analysis/classification/aggregation logic
that was implemented client-side, treat it as a bug: move it into the appropriate Python module
(analysis/classifier.py for categorization, analysis/blocks.py for block-level derivations,
a router under api/routers/ to expose it) and have the frontend consume the computed result.
Architecture
Request flow (the core pipeline)
Both proxy modes feed the same pipeline. The key sequence to understand spans these files:
proxy/addon.py—ContextSpyAddonis the mitmproxy addon. It detects the provider from host/port (_HOST_PROVIDER,_OLLAMA_PORTS) and the agent from User-Agent (_UA_AGENTS), then hands the request/response bodies on.analysis/adapters/—get_adapter(endpoint)dispatches by request path (not host) to aWireFormatAdapter(anthropic.py,openai_chat.py,openai_responses.py,ollama.py). Each adapter'sparse_request/parse_response/parse_sseturns provider-specific JSON into provider-agnosticBlocks (analysis/blocks.py) + aUsage— this is the provider-agnostic boundary. Adding a new provider/wire format is a new adapter module, nothing else changes.analysis/classifier.py—classify(analyzed)assigns each inputBlockacategory(classify_blocks) using heuristics (e.g._is_file_contentregexes for detecting embedded file contents) and aggregates into the 8 categories:system_prompt,tool_definitions,tool_results,file_contents,conversation_history,current_user_message,assistant_prefill,uncategorized, plustokens_output_text/tokens_output_thinkingon the output side. Priority order for category assignment is documented inclassify_blocks().per_tool_tokensproduces per-tool breakdowns.analysis/tokenizer.py—count_tokensvia tiktokeno200k_base(ENCODING_NAME; wascl100k_baseup to 0.3.3). All counts are estimates (see docs/development.md for per-provider error bands); when the provider reports exact counts they are stored alongside.db/crud.py+db/models.py— theRequestrow (aggregate token counts) plus oneBlockrow per content part (db/models.py: BlockRecord), content-addressed intoblock_contentsfor dedup across a session. Then broadcast over WebSocket (api/websocket.pyConnectionManager) so the dashboard updates live.
Two proxy modes
- Cloud mode — mitmproxy as a forward proxy (default port 8888) that TLS-terminates and
forwards to cloud APIs. Requires the user to install a CA cert (
contextspy install-cert). - Local mode — mitmproxy as a reverse proxy (default port 8889) in front of a local LLM
server (Ollama/llama-server/vLLM); plain HTTP, no cert. Uses
provider_overridesince the upstream host doesn't identify the provider. Launched viastart_local_proxiesinproxy/runner.py.
proxy/runner.py runs mitmproxy on a background thread and watches its logs to confirm the
port actually bound (_BindWatcher) — port-in-use is a common failure surfaced to the user.
Web server
api/main.py create_app(settings) is an app factory (note --factory in the uvicorn
commands). Its lifespan starts the DB and the proxy thread, so running the FastAPI app is
running the whole tool. Routers under api/routers/ (requests, sessions, stats, proxy,
tokenize) back the SPA; the built SPA is served as static files from contextspy/_web/.
CLI
cli.py (Typer, entrypoint contextspy) is the user-facing surface: start, start-local,
status, install-cert, session commands, report, reset-db, db-upgrade, db-stats, and
setup-* helpers (setup-claude, setup-copilot, setup-ollama, setup-vllm, etc.) that
write the proxy/base-url config into each agent.
Database schema changes — REQUIRED steps
db/database.py: init_db() only creates new tables automatically (Base.metadata.create_all
does not add columns to existing tables). Any change to db/models.py — a new column on an
existing table, a new table whose rows need populating for pre-existing data, or any change that
existing databases won't already have — must also touch db/migrations.py:
- New column on an existing table (e.g.
Request,BlockRecord): add it to thenew_columnslist indb/database.py: _migrate()(additiveALTER TABLE, applied on every startup — this part runs automatically for all users, no version bump needed on its own). - New derived/backfillable data (a new column or table that needs values computed from
existing rows, e.g. the
session_seqandblocksbackfill in_migrate_to_v2): bumpSCHEMA_VERSIONindb/migrations.py, add a new_migrate_to_vNfunction, and register it in_DATA_MIGRATIONS. This is NOT automatic — it only runs when the user explicitly invokescontextspy db-upgrade(seecheck_and_flag_pending_migrations/apply_data_migrations).cli.py: start/start-localrefuse to boot (_abort_if_migrations_pending) until this is applied or the user runsreset-db.
Forgetting step 1 crashes every command that touches the DB with
OperationalError: no such column: ... on any pre-existing database — this has happened before.
Forgetting step 2 means existing requests silently never get the new derived data.
Frontend
ui/src/ — React + react-router + @tanstack/react-query + recharts + Tailwind. Data comes through
api/client.ts (REST) and api/useWebSocket.ts (live updates). Pages live in pages/
(Dashboard, Requests, RequestDetail, Sessions, SessionDetail, Settings). The main request-detail
surface is components/request/RequestWorkbench.tsx, backed by compact/relative-size block maps,
a persistent inspector, and searchable content viewer. ToolTreemap and ToolBreakdown provide
share-of-total and exact-value tool views; semantic light/dark theme tokens live in index.css.
Reference docs
SPEC.md— current product/technical spec.docs/transport-normalization.md— detailed transport and canonical-invocation contract. Files ending in_PLAN.mdand documents underplans/are historical implementation plans unless their status note says otherwise.docs/development.md— architecture diagrams, data storage layout, token accuracy bands.docs/also has install/cloud-mode/local-mode/examples/cli guides.~/.contextspy/:contextspy.db(SQLite),config.toml(auto-created). Raw request bodies and block contents are purged after capture on server startup (startup_vacuum), per the[retention]settings inconfig.toml(default 7 days for both; 0 = keep forever).