# Agent Instructions: `mcp_servers/nostr_explorer_mcp`

> **Scope:** These instructions apply to files under `mcp_servers/nostr_explorer_mcp/`.

> **Maintenance rule:** Keep this file current. Replace obsolete guidance instead of appending a historical changelog.

## Purpose

nostr_explorer_mcp — current state (architecture, WS contracts,

decisions, gotchas). NOT a changelog; if something stops being true, overwrite it.

## What it is

Web GUI over the `nostr` sub-agent from `jou/agents/nostr`. FastAPI + WebSocket.
Each browser tab has its own `sid` and its own `Agent` instance (with its own JOU
store). The agent lives server-side; the browser talks to it over one bidirectional
WS — commands one way, events the other.

## Files

- main.py           — entrypoint: logging (console INFO, file DEBUG → explorer.log),
```text
                  uvicorn on DEFAULT_PORT=5001 (or argv[1]). Deterministic port:
                  if taken, uvicorn raises OSError and the process dies (no
                  free-port fallback — hard-fail beats a white page on the "right"
                  URL).
```

- server.py         — FastAPI app; lifespan inits SharedRuntime + SessionsManager.
```text
                  Endpoints: /api/health, /api/models, /api/topup/confirm,
                  /api/ws (websocket), /api/session/{sid}.
```

- agent_runtime.py  — the runtime core:
                      * SharedRuntime — singleton: gateway connection + capability pool
```text
                    + AgentConfig for `nostr` + LLM creds. setup() reconnects to the
                    gateway in a loop (3s backoff). KlientMCP also reconnects
                    internally (1s→30s exp), so a gateway crash needs no restart.
                  * Session — per-sid: own Agent, cmd_queue, fan-out subscribers
                    (one queue per active WS), asyncio.Task running loop(). Emits
                    started / assistant_text / done (cost_usd, took_ms) / balance
                    (after each done) / model (after set_model), or error on
                    exception. Holds balance_sat (DEFAULT 10000, simulated); model
                    changeable via set_model(...).
                  * SessionsManager — session registry + GC (30 min idle → teardown)
                    + hard cap MAX_SESSIONS=50.
                  * _zainstaluj_wrapper_sql — key thing, see below.
```

- billing.py        — usd_to_sat / get_btc_usd_rate (CoinGecko, 60s cache).
- models_catalog.py — fetch_chat_models (PPQ /v1/models, 5 min cache). Filter: only
```text
                  models with `tools` in supported_parameters and `text` in
                  output_modalities. Sort: popular first, then alpha by owned_by+name.
```

- static/           — index.html, app.js (WS client + model dropdown + balance + topup),
```text
                  style.css.
```

- run.sh            — venv + pip + python main.py "$@"
- requirements.txt  — fastapi, uvicorn, websockets, httpx, anthropic, openai, mcp==1.26.0
- tests/            — testy_payperq, testy_billing, testy_session, testy_models_catalog,
```text
                  testy_aliasy (`./.venv/bin/python -m unittest discover tests`)
```

## GUI presentation (the key architectural decision)

Every SQL query goes through a user-approval gate and — once approved — renders to the
user as a table card. Applies to both `run_sql` (via the GUI wrapper) and
`explorer__show_result`. The user consciously sees what was queried; if they don't want
it, Reject and the agent gets a ToolError.

### Four capabilities installed per-session at build_agent

1. _zainstaluj_wrapper_sql(agent, session, on_table) → orig
   - forces JOU tagging (prog=0) — the agent never gets raw rows inline, only schema+tag,
   - hard cap MAX_RUN_SQL_PER_TURN (3) — guards against a reformulation loop,
   - user-approval gate — before orig.handler calls session.request_sql_approval(sql,
```text
 "run_sql"). Emits WS sql_confirm_request and blocks on a Future; user clicks
 Approve/Reject. Reject → RuntimeError → ToolError to the LLM. Timeout
 SQL_APPROVAL_TIMEOUT_S (5 min) → also RuntimeError.
```

   - after approval pushes the result to the GUI — if raw is list[dict|...], calls
```text
 on_table({title=SQL-abbrev(first 60 chars), table=compact}). Render failure doesn't
 block the agent (log and continue).
```

   - returns the original Capability (pre-wrapper) so show_result can call it directly
```text
 (not counted against the limit).
```

2. _dodaj_show_result(agent, session, on_table, run_sql_orig)
   - registers capability `explorer__show_result` with the MCP prefix convention
```text
 (<backend>__<tool>). To the LLM it looks like any gateway tool; underneath it's a
 local handler (explorer is not a separate MCP server).
```

   - schema: {title (req), table?, sql?} — EXACTLY one of table/sql, else RuntimeError,
   - table → identifier validated by regex `^[A-Za-z_][A-Za-z0-9_]{0,62}$`, runs
```text
 SELECT * FROM "<table>"; sql → runs directly,
```

   - same user-approval gate (purpose "show_result.table"|"show_result.sql"),
   - result (list[dict]) → _na_compact_format → on_table → WS event
```text
 {"type":"table", "title":..., "table":{columns, rows}},
```

   - non-list result (None/dict/str) → RuntimeError with a hint (DDL without SELECT),
   - returns {shown: True, rows: N} — only the count; full result never returns (free for
```text
 LLM context).
```

3. _dodaj_show_pubkey_status(agent, session, gateway)
   - adds `explorer__show_pubkey_status(pubkey)`. Opens a live GUI panel for one tracked
```text
 pubkey (role, profile, events out/in, top kinds, last_seen per relay,
 secondaries_from_me for primary).
```

   - input: 64-char lowercase hex (if the agent has npub1..., call
```text
 nostr_admin__npub_to_hex first),
```

   - reads resource tracked://pubkey/{hex} via gateway. If tracked:false → returns
```text
 {shown:False, tracked:False, hex, hint}; agent asks the user whether to subscribe as
 primary. Else subscribes to the resource, emits pubkey_panel_open with the initial
 snapshot; callback on each resources/updated re-reads and emits pubkey_panel_update.
```

   - panel stays until user-X or session end. Multiple panels in parallel —
```text
 Session.pubkey_panels: dict[hex, {uri, callback}]. Returns {shown:True} (or
 {shown:True, already:True}).
```

4. _usun_end_turn(agent)
   - removes `end_turn` from the explorer session's registry (JOU keeps it globally).
   - rationale: end_turn saves one LLM round (sentinel instead of final text). Here we
```text
 ALWAYS want the final text — the model already sees show_result output and gives a
 sensible comment. Cost: one extra LLM round/turn (~$0.01–0.02 with prompt cache);
 gain: better UX and kills a bug class where the agent lies in `reason`.
```

Consequences: every SQL query is visible in an approval modal, then as a table card — the
user controls what hits the GUI and what the DB runs. The agent should minimize diagnostics
(every SHOW TABLES / DESCRIBE / count(*) = another modal; the LLM is told this in
OPIS_GUI_DOPISKA). It may answer with text only when the answer is a number/fact.
Pre-formatting for presentation is fine — the GUI shows raw what it gets.

## Pubkey/event-id aliases (anti hex-mangling)

The model never sees a raw 64-hex/npub (it mangled them token-by-token → silent wrong
pubkey). Instead it gets an alias `@p<number><letter>` (e.g. `@p5m`); the letter is a
checksum, chr('a'+(n*7+3)%26).

- AliasRegistry (Session.aliases) — per-session, bidirectional, stable. `expand` RAISES on
  unknown alias / bad letter (hard error, not a silent wrong pubkey).
- _zainstaluj_aliasy — wraps EVERY capability handler: args IN alias→hex, result OUT
  hex/bech32→alias. Called LAST in get_or_create, so it also wraps the run_sql/show_*
  wrappers — SQL expands before approval (modal shows the real hex). OUT is global because
  show_pubkey_status echoes the full hex in the untracked branch.
- assistant_text → _rozwiń_do_wyświetlenia (best-effort; unknown alias stays).
- OPIS_ALIASOW appended to the session system prompt (explorer-only, like OPIS_GUI_DOPISKA)
  — NOT in nostr/system_prompt.md (the REPL shares it and has no aliases).
- Aliases are model I/O only; the GUI/WS gets raw rows (truthful).
- v1: npub and its hex are SEPARATE aliases. Tests: tests/testy_aliasy.py.

## WebSocket contract (JSON both ways)

Client → server:
  {"type":"run", "command":"..."}                    — enqueue a command for the agent
  {"type":"set_model", "model":"..."}                — change the session's LLM model
  {"type":"pubkey_panel_close", "hex":"..."}         — close a pubkey panel (server
```text
                                                    unsubscribes the resource)
```

  {"type":"sql_confirm_response", "request_id":"...",
   "approved":true|false, "reason":"..."}            — reply to sql_confirm_request
  {"type":"ping"}                                     — heartbeat (server replies "pong")

Server → client:
  {"type":"hello", "sid":"...", "backend":"real"|"down", "model":"...", "balance_sat":N}
```text
                                                   — first event after accept
```

  {"type":"started", "command":"..."}
  {"type":"table", "title":"...", "table":{"columns":[...], "rows":[[...],...]}}
  {"type":"assistant_text", "text":"..."}            — agent's final answer (if non-empty)
  {"type":"error", "message":"..."}
  {"type":"done", "cost_usd":0.0, "took_ms":12345}
  {"type":"balance", "sat":N, "last_charge_sat":K, "last_charge_usd":F}
```text
                                                   — after `done`. last_charge_sat>0 =
                                                     charge, <0 = topup.
```

  {"type":"model", "model":"..."}                    — broadcast after set_model (also to
```text
                                                     parallel tabs on the same sid)
```

  {"type":"pubkey_panel_open", "hex":"...", "data":{...}}   — data = JSON of
```text
                                                          tracked://pubkey/{hex}
```

  {"type":"pubkey_panel_update", "hex":"...", "data":{...}} — same shape as open
  {"type":"pubkey_panel_close", "hex":"..."}         — server ended the panel; frontend
```text
                                                     also emits this OUT on user-X
```

  {"type":"sql_confirm_request", "request_id":"...", "sql":"...", "purpose":"..."}
```text
                                                   — before every SQL run. purpose ∈
                                                     {run_sql, show_result.table,
                                                     show_result.sql}. Frontend MUST
                                                     reply with the same request_id,
                                                     else the turn blocks until
                                                     SQL_APPROVAL_TIMEOUT_S (5 min).
```

  {"type":"pong"}

Client disconnect leaves the session alive until GC (30 min idle) so F5 doesn't lose the
agent. Multiple parallel WS on one sid allowed — Session.subscribe() gives each its own
queue, emit() broadcasts to all.

## HTTP endpoints

GET    /                  — index.html
GET    /api/health        — {"backend":"real"|"down", "sessions":N}; `real` requires a LIVE
```text
                        probe gateway.lista_narzędzi() (2s timeout) — don't trust
                        SharedRuntime.ready, the gateway may have died after setup.
```

GET    /api/models        — {"models":[{id,name,owned_by,popular,pricing}]}; proxies PPQ
```text
                        /v1/models (5 min cache), pre-filtered to tool-call + text out.
```

POST   /api/topup/confirm — body {sid, sats}; STUB — adds sats without invoice verification.
WS     /api/ws?sid=...    — single bidirectional channel (contract above)
DELETE /api/session/{sid} — explicit teardown (browser onbeforeunload)

## Configuration (from conf/agent0.json — repo_parent/conf/agent0.json)

- `provider` + provider section (api_key, model)
- `gateway_url` — REQUIRED; setup raises without it

The `nostr` sub-agent must exist in jou/agents/nostr/{config.json,system_prompt.md}.
SharedRuntime composes the system prompt as SYSTEM_PROMPT (jou base) + "\n\n---\n\n" +
agent.system_prompt. AgentConfig.discovery_mode: `lazy_jou` → OdkrycieJOU, else
OdkryciePełne.

## Tuneables (top of agent_runtime.py)

SESSION_IDLE_TIMEOUT_S = 30*60   # idle → GC closes the session
SESSION_GC_INTERVAL_S  = 60      # scan every minute
MAX_SESSIONS           = 50      # hard cap; above → 1011 close from /api/ws
MAX_RUN_SQL_PER_TURN   = 3       # hard cap per turn. Past it the wrapper raises instead of
```text
                             # calling orig — _dispatch serves the error to the LLM.
                             # Reason: jou/sdk/agent.py only catches IDENTICAL repeats
                             # (name+args); the LLM can loop over cosmetic variants.
                             # Counter reset in Session.loop before agent.wyślij.
```

## Reconnect on both sides

- Client (app.js): WS auto-reconnect exp backoff (1s→30s). Ping every 25s to detect a dead
  socket. One-slot pendingCommand buffer — commands sent during reconnect arrive after.
- Server (KlientMCP._utrzymuj_połączenie): on gateway death, rebuilds itself (1s→30s).
  Public methods block on `_gotowy` with a 15s timeout. After reconnect: tool mappings
  re-fetched, resource subscriptions renewed.

## Gotchas / decisions

- Session created LAZILY in get_or_create. Client always opens WS first (gets hello), then
  sends run.
- push_table uses emit_sync (put_nowait per subscriber queue) — same event loop. Do NOT
  `await put()` here.
- If run_sql result is NOT list[dict], the wrapper logs the type but on_table is NOT called
  — GUI gets no table, only assistant_text. Agent still gets the result via JOU.
- The wrapper raises if TWO matching tools (`*__run_sql`) are in the registry.
- Logs: nostr_explorer_mcp.* → one explorer.log, append, DEBUG. Console = INFO.
- timeout_graceful_shutdown=5 in uvicorn — else Ctrl-C hangs on open WS.
- get_or_create may raise (no tool in pool, MAX_SESSIONS) → WS handler does accept +
  hello{down}/error + close 1011 instead of HTTP 500.
- Tool pool (harvest_pool) loaded ONCE at setup — a backend attached later isn't seen
  without an explorer restart. Known limitation, TODO: hot-refresh.

## Billing / balance (simulated)

Simulated per-session sat account: Session.balance_sat starts at DEFAULT_BALANCE_SAT
(10000). After each turn: cost = delta of agent.koszt_usd_łącznie since turn start →
usd_to_sat(cost) at current BTC/USD (CoinGecko) → subtract (clamp 0) → emit `balance`.
Adapter KlientPayPerQ (jou/sdk/adaptery/payperq.py) reads cost: preferred usage.cost (USD),
fallback usage.cost_details.upstream_inference_cost (BYOK, cost==0). Real LN NOT done —
local LND exists but topup is a stub. Next: macaroon/L402 or an LND topup endpoint.

## Model selection

GET /api/models lists all PPQ models supporting tool-calls and `text` output (~250).
Dropdown groups by owned_by + a "popular" checkbox (~10 models). Selection sends
{type:"set_model", ...}; server swaps agent.llm._model, picked up on the next
agent.llm.wyślij (Anthropic SDK doesn't cache _model); the current turn isn't preempted.

## Unknowns (blind spots)

- Backend attached after explorer start — capability_pool doesn't refresh; restart = fix;
  auto refresh-on-list_changed is TODO.
- Per-session balance lost on GC — a real version needs per-user persistence.
- KlientAnthropic (no payperq) still does NOT compute cost (Anthropic pricing skipped).
