# Whisper Agent-Management UI - Developer Guide

> Build a web UI that lets a human (or an agent) **add, delete, manage, and monitor**
> Whisper agents against the production control plane. One endpoint, one header,
> one Cypher verb. This guide is the complete, accurate contract: every operation,
> scope, argument, and response column is taken verbatim from the running service.

---

## 1. The model - read this first

Whisper is **identity-on-the-wire DNS**. The whole system rests on one idea:

> **An agent _is_ a routable IPv6 `/128`. The address is the identity, and the
> address is the auth.**

Each agent gets one address out of `2a04:2a01::/32` (announced by `AS219419`).
That `/128` is its name (reverse DNS → `a<agent>.t<tenant>.agents.whisper.online`),
its WHOIS/RDAP object, and - when it egresses - its source address. There is no
separate "user record"; the identity _is_ the address.

Three things you need to know to build the UI:

1. **One Cypher verb runs everything.** All control-plane operations are a single
   call: `CALL whisper.agents({op:'…', args:{…}})`. You select the operation with
   `op` and pass its parameters in `args`. There is no REST surface with a dozen
   routes - there is one verb and a closed set of `op` values.
2. **The key is the principal.** Every request carries an `X-API-Key` header. The
   server derives the *tenant* from that key, server-side. You never send a tenant
   id, account id, or "owner" argument. One is not merely ignored, it is refused
   `400 BAD_ARGS`, and you can never read another tenant's data. Isolation
   is structural, not a filter you set. To act for another account, send that
   account's key.
3. **Scopes gate operations.** The key's scopes decide which `op` values it may
   call. A read-only dashboard key needs only the read scopes; an admin key that
   mints and revokes agents needs `admin:dns`.

Everything below is built on those three facts.

---

## 2. Endpoint & authentication

| | |
|---|---|
| **Control endpoint** | `https://graph.whisper.online/api/query` |
| **Method** | `POST` (JSON body) - `GET` with `?q=` also accepted (Postel: be liberal) |
| **POST body** | `{"query": "CALL whisper.agents({op:'…', args:{…}})", "parameters": {…}}` (`parameters` optional) |
| **Auth header** | `X-API-Key: whisper_live_<key>` (sent verbatim; keys are issued as `whisper_live_…`) |
| **Monitor (SSE) endpoint** | `https://graph.whisper.online/monitor/stream` (GET or POST) |
| **Graph / cognition endpoint** | `https://graph.whisper.security/api/query` (§4: the read-only `whisper.*` procedures) |
| **RDAP (public, no auth)** | `https://rdap.whisper.online/ip/<v6>` and `https://rdap.whisper.online/domain/<name>` |

> **The control verb moved to its own front door.** `whisper.agents` is served by
> `graph.whisper.online`; `graph.whisper.security` keeps the read-only cognition
> surface and no longer registers `whisper.agents` at all (verified 2026-08-28:
> `CALL db.procedures()` there returns 36 `whisper.*` procedures and `agents` is
> not among them; the control call answers `400 "Unknown procedure:
> whisper.agents"`). The monitor stream is the same split: `200` on
> `graph.whisper.online/monitor/stream`, `404` on `graph.whisper.security`. Those
> two host names are the only public control endpoints; an older doc may name a
> third, and it is not one. If you are updating an existing UI, changing the two
> host names is the whole migration: the request and the envelope are unchanged.

**Your very first call** - confirm the key works in ten seconds, from a shell:

```bash
curl -s https://graph.whisper.online/api/query \
  -H 'Content-Type: application/json' \
  -H 'X-API-Key: whisper_live_…' \
  --data '{"query":"CALL whisper.agents({op:'\''list'\'', args:{kind:'\''agents'\''}})"}'
# → {"columns":["op","ok","status","result","error","retry_after","elapsed_ms"],
#    "rows":[{"op":"list","ok":true,"status":200,
#             "result":{"columns":["kind","item"],"rows":[…]},"error":null,…}]}
```

`rows[0]` is the per-op envelope: `ok:true` with `status:200` means the key and
scopes are good, and `error.code` / `error.message` on a failure tell you exactly
what went wrong (see §9).

**The HTTP status is always `200`.** The real outcome lives in `rows[0].status`,
so a client that trusts `res.ok` reads every refusal as a success and renders an
empty screen instead of the reason. Unwrap first, then check. A control call from
the browser is just `fetch`:

```js
const CONTROL = "https://graph.whisper.online/api/query";
const KEY = "whisper_live_…";              // the operator's key

async function agents(op, args = {}) {
  const query = `CALL whisper.agents({op:'${op}', args:${cypherMap(args)}})`;
  const res = await fetch(CONTROL, {
    method: "POST",
    headers: { "Content-Type": "application/json", "X-API-Key": KEY },
    body: JSON.stringify({ query }),
  });
  const json = await res.json();        // {columns,rows} - rows[0] is the envelope, see §9
  const env = (json.rows || [])[0];     // {op,ok,status,result,error,retry_after,elapsed_ms}
  if (!env) throw json;                 // a transport-level error: {error,detail}, no envelope
  if (env.ok === false) throw env.error; // NEVER trust res.ok: the HTTP status is always 200
  return env.result;                    // {columns,rows} - rows are positional arrays
}
```

`cypherMap` is a tiny helper that renders a JS object as a Cypher map literal
(`{key:'value', n:3}`). A worked, escaping-safe version ships in the dashboard in
§10. (You may also pass values via the `parameters` field and reference them as
`$name` inside the query - both forms are accepted.)

---

## 3. The operation surface (the one table that matters)

This is the **complete, closed** set of operations: 24 of them, taken from the
server's own op enum. Anything not on this list is rejected `UNKNOWN_OP`
(fail-closed). Columns are exactly what the server returns, no invented fields.

> Note: per-agent info/counters come from **`op:agent`**. There is **no `op:stats`**.

> **Served by the backend is not the same as reachable from the front door.** The
> public endpoint carries its own op allowlist, which is a subset. Verified against
> the live front door on 2026-08-28, four of the 24 answer
> `{"error":"unknown_op"}` there: **`budget`**, **`task`**, **`erase`** and
> **`purge`**. That refusal is at the gateway, before the per-op envelope, so it
> arrives in a different shape than an ordinary error (§9). For `budget` the same
> surface is reachable through `op:firewall`'s `budget:{…}` sub-map (§12.3), which
> is what a UI should use. The other 20 all reach the backend.

| Op | Write? | Required scope | Args (required **bold**, optional plain) | Result columns | What it does |
|---|---|---|---|---|---|
| **register** | yes | `admin:dns` | **label**; `contact`/`contact_email`; `contactName`/`friendly_name` | `api_key, agent, address, fqdn, ptr` | Mint a new agent: a fresh API key + its own routable `/128` identity (AAAA + canonical CNAME + reverse PTR). The `api_key` is shown **once** - capture it. The identity lives under the *agent's own* tenant. |
| **host** | yes | `dns:zone:write` | **name**, **type** (`A`/`AAAA`/`CNAME`/`TXT` only), **value**; `ttl`; `delete` | `record_id, fqdn, type, value, ttl, status` | Upsert (or `delete:true`) a dynamic-DNS record in the caller's sub-zone. `status` ∈ `CREATED`/`UPDATED`/`DELETED`/`NOT_FOUND`. Type whitelist + sub-zone confinement enforced. |
| **identity** | yes | `dns:identity` | `label`; `contact`/`contact_email`; `contactName`/`friendly_name`; `release`; `address` (with `release`) | `address, fqdn, ptr, state` | Allocate the caller's one routable `/128` (idempotent - one principal = one `/128`), or `release:true` (+ `address`) to give it back. |
| **connect** | yes | `dns:connect` | `tier` (`socks5` default, `anyip` for Tier-1.5; `wireguard`/`wireproxy` for Tier-1 routed); `label`; `contact`/`contact_email`; `contactName`/`friendly_name`; `parent`; `identity_public_key` (routed tier only) | Tier-1.5: `tier, http_proxy, connection_string, socks5_endpoint, address, fqdn, ptr, dns, tls, note, doh_url, dns_note` · routed: `tier, wireguard_config, server_public_key, endpoint, client_public_key, client_private_key, address, allowed_ips, fqdn, ptr, dns, note` | Provision egress bound to the agent's `/128`, and allocate the identity if it has none. Tier-1.5 returns ready-to-use proxy strings with a bearer token baked in; `wireguard`/`wireproxy` returns a routed per-agent peer config instead, and is the only tier that accepts an agent-held `identity_public_key`. Any other `tier` is a clear `400 BAD_ARGS` naming the four. |
| **disconnect** | yes | `dns:connect` | `agent` _or_ `address` (default: the caller's own identity); `reverse` | `agent, status, address, wg_peers_removed, egress_tokens_revoked` | Really tear the selected agent's connectivity down: every Tier-1 WireGuard peer bound to the `/128` and every Tier-1.5 egress token. The identity itself survives (that is `op:revoke`), so `op:connect` brings it back. The counts make it measurable, never just an echoed status. `reverse:true` restores a previous disconnect from the audit trail. Idempotent: a repeat disconnect succeeds with zero counts. With an **agent key** it tears down only that agent's own tunnels and the tokens it minted, and appends `tokens_remaining` (tokens others minted for the `/128`, such as its owner's monitor token, which stay bound); `status` is `none_of_yours` when it found nothing of its own and others' tokens remain. |
| **policy** | yes | `dns:policy:write` | `block` (list); `allow` (list); `default`/`defaultAction` (`allow`/`deny`); optional device selector `agent` id or `address` `/128` | `key, value` (one row per entry: `default`, then each `block`/`allow`; a device call adds a `device` row) | Set the caller's per-tenant DNS resolver policy. Pass an `agent`/`address` selector to set/read the policy for **one device** (keyed by its `/128`, precedence over the tenant policy - §12.2). Call with **no args** (or just the selector) to read the current policy back. Max 1000 entries (`block` + `allow` combined) → over the cap is `400 BAD_ARGS`. An **agent key** may only add blocks to its own tenant policy: `{block:[...]}` is merged into the list in force (it never removes one) and the answer ends with a `block.note` row saying so. An **owner** reads or writes its agent's tenant policy with `of_agent` (the agent's id or `/128`), with the same keys and replace semantics as its own. |
| **domain** | yes | `admin:dns` | `op` (`assess`/`submit`/`verify`/`status`/`list`/`withdraw`, default `submit`); **name** (required by every sub-op except `list`) | arg-discriminated: `kind, value` (assess/submit) · `name, state, key, value` (status) · `name, state` (list) | BYOD onboarding and delegation proof, arg-discriminated on `args.op` the way `policy` carries bundles. `assess` writes nothing; `submit` opens the pending delegation; `status` returns one `missing` row per actionable gap; `withdraw` unserves the apex. |
| **verify** | yes | `admin:dns` | **name** | `kind, value` | Attest one name. For a name inside a Whisper-served zone it returns the agent-identity attestation (FCrDNS + DANE + nesting) or a clean `verified:false` when the name is in bailiwick but not a live identity. For a foreign apex it falls through to the BYOD `domain{op:'submit'}` flow. Superseded by `op:domain`; kept for existing callers. |
| **token** | yes | `dns:connect` | **agent**; `revoke`; `expires` (epoch ms) | mint: `agent, token, address, scope, expires` · revoke: `agent, status` | Return-or-mint an `et_` **monitor bearer token** bound to the agent's `/128`, *without* opening a tunnel - for read-only live monitoring. `revoke:true` drops all tokens for the agent. With an **agent key** the token is the agent's own (never its owner's), and `revoke:true` drops only the tokens it minted: revoke answers `agent, status, tokens_remaining`, with `status` `revoked`, `none_of_yours` (others' tokens remain) or `not_found`. |
| **resolver** | yes | `dns:resolve` | `release` | `address, state` | Bring up (or `release:true`) the caller's dedicated `:53` resolver `/128`. `state` ∈ `active`/`pending`/`released`. |
| **firewall** | yes | `dns:firewall:write` | **`agent` _or_ `address`**; `rules` (list of `{action,match,value}`); `default`/`defaultAction` | `action, match, value, agent` (one row per rule, `default` first) | Set the per-agent egress firewall. `action` ∈ `allow`/`deny`; `match` ∈ `ip`/`cidr`/`host`/`port`. Call with no `rules` to read the current set back. Max 1000 rules. With an **agent key** the write lands in the agent's own layer (max 256 rules), which can only narrow what its owner allows; `rules:[]` clears that layer, while `rules:null` or `default:null` is no argument and reads it back. |
| **budget** | yes | `dns:connect` | **`agent` _or_ `address`**; `bytes_up_cap`; `bytes_down_cap`; `connections_cap`; `requests_cap`; `cost_usd`; `kill`; `delete` | `agent, address, bytes_up_cap, bytes_down_cap, connections_cap, requests_cap, cost_usd, kill, status` | The per-agent egress budget + kill-switch. Partial upsert: each absent cap keeps its current value; no cap arg at all reads the budget back; `delete:true` removes it (no budget = unlimited). **Not on the public front door's op allowlist** - from a UI, set the same thing through `op:firewall`'s `budget:{…}` sub-map (§12.3). |
| **task** | yes | `dns:firewall:write` | **`agent` _or_ `address`**; `tasks` (list, replaces the whole backlog) _or_ `id` + `action` (`complete`/`dismiss`/`snooze`/`reopen`/`prioritize`) | `agent, addr, id, kind, title, priority, evidence, state, firstSeen, remediation, autoResolvesWhen, exposure, fusedPriority, origin, confidence, actor` | The per-`/128` calm task backlog. Submitting replaces the backlog whole, so a fixed finding simply disappears from the next submit; an empty list clears it. With neither `tasks` nor `action` it reads the current backlog back. **Not on the public front door's op allowlist** today. |
| **respond** | yes | `dns:respond:write` | **`agent` _or_ `address`**; `action` (`process.stop`/`package.update`/`quarantine.file`/`service.restart`, or `cancel` + `id`); `params` | `agent, addr, id, action, params, state, actor, issued_us, dispatched_us, ttl_ms, not_before_ms, outcome, detail` | Enqueue one bounded, allowlist-only on-host response command, cancel a still-queued one, or (no `action`) read the backlog. `dns:respond:write` is a dedicated operator grant that is **never** auto-enrolled: an owner without it gets a hard 403, never a silent grant. |
| **partner** | yes | `dns:zone:read` + a `partner:<org>` enrolment | **op** (`get`/`brand`/`customer.add`/`customer.list`/`customer.rename`/`customer.remove`/`grant.add`/`grant.list`/`grant.revoke`, default `get`) + that sub-op's args | arg-discriminated: `org, name, accent, logo_ref, attribution, customers, updated` · `customer, name, kind, domain, accounts, created` · `customer, account, kind, created, status` · status pairs | The MSP/MSSP partner roster: brand, customers, and the grants recording which accounts the firm acts for. The firm comes from the key's own `partner:<organization id>` scope, never an arg, so one firm cannot address another's roster. A grant carries a userId and **no credential of any kind**. A grant must name a REAL principal: `grant.add` resolves the `account` against the shared auth store first, so an id it has never seen is a `400 PARTNER_UNKNOWN_ACCOUNT` naming the id and nothing is written. When that store cannot be asked, the grant is still recorded (your outage must not cost you a legitimate grant) but its `status` reads `unverified` rather than `granted`/`active` until a later `grant.add` settles it. `grant.revoke` never verifies: withdrawing authority has to work for an account that has since been deleted. |
| **revoke** | yes | `admin:dns` | **agent**; `reverse` | `agent, status` | Fully revoke an agent: withdraw its `/128` (AAAA/CNAME/PTR/ledger), drop egress tokens, revoke the API key. Idempotent: `status` ∈ `revoked`/`not_found`. `reverse:true` re-mints an identity this tenant previously revoked, from its own audit trail. |
| **rename** | yes | `admin:dns` | **`agent`/`id` _or_ `address`**; **`label` _or_ `name`** | `agent, address, label, status` | Relabel an agent or device the caller owns. The new label reaches `op:list`, `op:agent`, RDAP, and the reverse-DNS friendly name at once. Owner-confined (a foreign selector is a clean `not_found`), label-policed before any side effect, and idempotent (`status:unchanged` when the label already matches). |
| **erase** | yes | `admin:dns` | none | `scope, identities_erased, trail_purged, status` | The tenant-level Art.17 erase: release every one of the caller's identities (zone withdrawal + ledger revocation leaf + crypto-shred of that `/128`'s ledger salts), then purge the whole tenant's alloc-trail, lookup, and activity rows. Scoped to the caller's own tenant with no argument by which to name another. Idempotent. **Not on the public front door's op allowlist** today. |
| **purge** | yes | `admin:dns` | **address** | `address, trail_purged, status` | The subject-targeted purge of one `/128`'s historical re-identification surface: crypto-shred its ledger salts and purge its alloc-trail, inbound-lookup, and activity rows. Unlike `erase` it does **not** release the identity. Cross-tenant or unknown ⇒ `404`. **Not on the public front door's op allowlist** today. |
| **list** | no | `dns:zone:read` | **kind** (22 kinds, below); `mode:'count'` | `kind, item` (one row per item; `item` is a map) | List one family of the caller's own objects. `agents` rows are enriched with `created`, `state`, and (opt-in) `contact`. `mode:'count'` returns the TRUE total for the kind as one synthetic row, never clipped by the display cap. **See the caveat in §9.** |
| **agent** | no | `dns:zone:read` | **`agent` _or_ `address`**; `roam` | `agent, address, fqdn, ptr, label, state, allocated_at, contact, last_seen, dns_queries, dns_blocked, dns_nxdomain, packets, bytes_up, bytes_down, connections_active, connections_total, parent, children, children_total, children_truncated, depth, owner_agents_raw, owner_agents_nested_deducted, roam` | Per-agent **info + counters**. INFO from the live ledger; `connections_active` is the live egress counter *now*; the rest are aggregated from warm storage. The nesting columns (`parent` … `owner_agents_nested_deducted`) are the current tree; `roam` is populated only with `{roam:true}`. Fail-open: read error → zero-filled, never a 500. **This is the per-agent detail view - use it, not a non-existent `op:stats`.** |
| **logs** | no | `dns:logs:read` | `from`; `to`; `kind` (`dns`/`conn`/`alloc`/`all` - **omit for all kinds**); `agent` (the LIST id **or** the `/128` address); `limit` (default 1000, cap 10k) | `ts, kind, qname, qtype, rcode, decision, source, answer, latency_ms, agent, peer, bytes_up, bytes_down, duration_ms, reason, client_src, packets_up, packets_down` | Query the caller's recent activity from warm local storage. The **poll** counterpart of the live monitor (§6). Omitting `kind` returns every kind interleaved (the hybrid backfill relies on this). |
| **lookups** | no | `dns:logs:read` | `from`; `to`; `kind` (`dns` = authoritative-zone lookups, `rdap` = RDAP accesses); `agent` (LIST id) _or_ `address` (`/128`); `limit` | the same warm-activity columns as `logs` (the `kind` column is always `lookups`) | The INBOUND identity-lookup feed: who resolved or verified this agent, the inverse of `op:logs`. Sources are k-anonymised. A wide `from` is narrowed to 7 days with an honest `note` row rather than a silent trim. An empty feed is a normal "nobody yet". |
| **history** | no | `dns:history:read` | **address**; `from`; `to` | `holder, agent, from, to, open, from_at_least_log_start` | "Who held this `/128` when." Returns one row per ownership interval (oldest→newest); the open interval is the current holder. `holder` is the opaque `t<sha256>` handle - never a raw account. |

**Reading the table**

- *Write?* "yes" ops require a key with the listed scope; an anonymous (keyless)
  write is rejected `403 ANONYMOUS_WRITE` before it ever reaches the handler.
- *Args are liberal-in.* Optional aliases are accepted in pairs - `contact` **or**
  `contact_email`, and `contactName` **or** `friendly_name` reach the same field
  (the primary wins if you send both). Send whichever your form produces.
- *Per-tenant isolation is structural.* A cross-tenant `agent`/`address` selector
  returns a clean `not_found`, never another tenant's row.
- *There is no tenant argument.* The principal comes from the API key alone, so
  `tenant`, `tenantId`, `tenant_id`, `on_behalf_of` and `onBehalfOf` are refused
  `400 BAD_ARGS` on **every** op rather than silently ignored. To act for
  another account, send that account's key. Selector args (`agent`, `address`,
  `customer`, `account`, `seed`) must be a single string: a list-shaped one is
  refused too, because a selector we cannot read would otherwise look absent and
  silently widen the call to the whole tenant.
- *Selector forms - know which takes which.* `op:agent` and `op:logs` accept **either**
  the LIST `agent` id (`agent-…`) **or** the `/128` `address` - liberal-in. The
  **monitor SSE `?agent=` narrow is the exception**: it takes the `/128` `address`
  only (§6.1). So a row from `op:list` gives you both `item.agent` (for `op:agent`/
  `op:logs`) and `item.address` (for the live stream) - use the address for the stream.

**The 22 `op:list` kinds.** `kind` is required and closed; an unknown one is a
clean `400` naming the whole set. Every row keeps the same `kind, item` shape, so
one renderer handles all of them.

| Kind | What one item is |
|---|---|
| `agents` | the caller's registered agents (with `created`, `state`, opt-in `contact`) |
| `records` | the caller's dynamic-DNS records |
| `identities` | the caller's allocated `/128`s (`address`, `fqdn`, `friendly`, `ptr`, `state`) |
| `devices` | the caller's resolve-only device credentials |
| `fleets` | the caller's bulk-MDM fleets |
| `fleet-devices` | one fleet's sighted devices (owner-private; takes a fleet selector) |
| `posture` | per-endpoint vulnerability posture |
| `task` | per-endpoint calm task backlogs |
| `compliance` | per-framework compliance posture derived from live signals |
| `calmplan` | per-endpoint calm hints (the pull plane) |
| `calm` | the fleet's published-SLA calm posture |
| `proc` | per-endpoint process graphs, with reputation |
| `eventchain` | the per-endpoint write/exec/connect story, time-ordered |
| `insight` | the per-endpoint graph reads (reputation, blastRadius, graphStats, baseline, exfilPath, exfilFusion, attackPath, centrality, anomaly, beaconing) |
| `attackCoverage` | the ATT&CK coverage matrix (`attackcoverage` also accepted) |
| `audit` | the caller's containment-audit trail: `revoke` / `reverse` / `firewall` / `disconnect` / `reconnect` / `runscope` / `respond.*`, and the partner-roster writes that move a tenancy boundary (`partner.grant.add` / `partner.grant.revoke`). Every outcome is recorded, refusals included, so it is evidence rather than a highlight reel |
| `action` | the per-endpoint on-host response backlog (live + recent) |
| `inbound` | who looked the caller's agents up, grouped by enriched source |
| `fingerprint` | per-`/128` JA3/JA4 memos with a 7-day baseline and novelty |
| `egressSummary` | the per-destination egress rollup for one agent, plus its coverage envelope (`egresssummary` also accepted) |
| `trend` | the day-partitioned daily activity aggregate |
| `fingerprintCluster` | the caller's (ja3, ja4) clusters and known-bad corpus matches (`fingerprintcluster` also accepted) |

Eight of these (`proc`, `eventchain`, `insight`, `attackCoverage`, `inbound`,
`egressSummary`, `trend`, `fingerprintCluster`) ride a dedicated pool server-side
because they fan out; they can answer `503 READ_BUSY` under load, which is a
back-off signal, not an error to surface as a failure.

---

## 4. Cognition verbs (read intelligence, on the graph)

Separate from the `whisper.agents` control verb, and on its own host
(`https://graph.whisper.security/api/query`, §2), sits the **cognition surface**:
read-only `whisper.*` procedures over the shared Whisper graph, attributed to your
principal. Use these to enrich an agent view (e.g. assess a domain the agent is
talking to, or look up a TLS fingerprint from a `conn` event):

```
CALL whisper.identify({indicator:'evil.example'})
CALL whisper.assess({indicators:['1.2.3.4']})
CALL whisper.walk({from:'example.com'})
```

**The registry is live-enumerable, so read it rather than trusting a list in a
doc:** `CALL db.procedures()`. It answered with 36 `whisper.*` procedures on
2026-08-28, the ones a management UI usually wants being `identify`, `assess`,
`assessUrl`, `walk`, `watch`, `submit`, `enrich`, `explain` (+ `explain.bundle`),
`variants`, `origins`, `history` (+ `history.bgp`, `history.whois`), `resolve`,
`search`, `lookupTlsFingerprint`, `lookupTorRelay`, `danglingCname`,
`vulnPosture`, `cve.byPackage`, `psl.*`, `threatIntel.*`, `asSet`, `asnCountries`,
`asnThreatDensity`, `bgpDegreeDistribution`, `topAsnsByPrefixCount`, `export`,
`quota` and `version`. These read the **shared** graph (not per-tenant data); the
key attributes the call to your principal. They are optional for a basic
management UI but make a rich "what is this agent talking to?" panel trivial.

---

## 5. Lifecycle recipes

The everyday flows a management UI needs, as exact `op` calls.

**List the fleet (dashboard load)**
```
op:list  args:{kind:'agents'}
→ rows of {kind:'agents', item:{agent, address, fqdn, label, created, state, contact?}}
```

**Create - allocate-or-reuse the caller's own `/128`** (self-service identity)
```
op:identity  args:{label:'crawler-1'}
→ {address, fqdn, ptr, state:'active'}   // idempotent: one principal = one /128
```

**Create with a public contact** (opt-in WHOIS/RDAP contact, surfaced verbatim)
```
op:identity  args:{label:'crawler-1', contact_email:'abuse@acme.test', friendly_name:'Acme Crawl Team'}
```

**Mint a brand-new agent with its own key** (admin: spawn a distinct agent)
```
op:register  args:{label:'worker-7', contact_email:'ops@acme.test'}
→ {api_key, agent, address, fqdn, ptr}   // api_key shown ONCE - store it now
```

**Reuse** - calling `op:identity` (or `op:connect`) again returns the same `/128`;
there is no second allocation. Treat identity as idempotent everywhere.

**Give an agent egress**
```
op:connect  args:{}                       // tier defaults to 'socks5'
→ {http_proxy, connection_string, socks5_endpoint, address, fqdn, …, note}
// http_proxy is the secure default when TLS-to-proxy is available; read `note`.
```

**Set a DNS policy / an egress firewall**
```
op:policy    args:{block:['ads.example','tracker.test'], default:'allow'}
op:firewall  args:{agent:'agent-ab12cd', rules:[{action:'deny', match:'cidr', value:'10.0.0.0/8'}]}
```

**Revoke (delete) an agent** - irreversible
```
op:revoke  args:{agent:'agent-ab12cd'}
→ {agent, status:'revoked'}   // withdraws /128, PTR, tokens, and the API key
```

**Mint a read-only monitor token** (e.g. to embed a per-agent live view safely)
```
op:token  args:{agent:'agent-ab12cd'}
→ {agent, token:'et_…', address, scope:'dns:connect', expires}
```

---

## 6. Real-time monitoring - the live picture

The product differentiator for a management UI is **live observability**. There are
two complementary surfaces; use **both** (the hybrid pattern below).

### 6.1 The monitor SSE stream (live)

```
GET https://graph.whisper.online/monitor/stream
Headers: X-API-Key: whisper-<owner-key>     (or Authorization: Bearer et_<agent-token>)
Optional: ?agent=<agent's /128 address>     (narrows within YOUR tenant only)
```

Auth decides the scope of the stream **server-side** (never a query arg that could
widen it):

- **Owner API key** → every event for **all** of the tenant's agents.
- **Agent monitor token** (`et_…` from `op:token`) → only **that** agent's events.
- `?agent=` only *narrows* within the caller's own tenant; it can never widen.

> **Narrowing is by `/128` address, not by agent id (today).** The `?agent=` narrow
> currently accepts only a **`/128` literal** (the agent's `address` from `op:list`/
> `op:agent`); a handle-based narrow (passing the `agent-…` id) is a future
> enhancement in whisper-ns and is **silently ignored** for now - so passing the id
> leaves the stream **tenant-wide**. Pass the agent's `address`, and as a
> belt-and-suspenders also drop events whose `addr128` ≠ the selected address
> client-side (the dashboard in §10 does both). To watch exactly one agent with no
> client-side filtering, mint an `op:token` for it and connect with that `et_…`
> bearer instead (the token pins the stream to that one `/128` server-side).

Connect from the browser. `EventSource` cannot set headers, so either (a) proxy the
stream through your own backend that adds the header, or (b) use `fetch` with a
streaming reader. The simplest robust pattern is a tiny same-origin proxy; the
dashboard in §10 reads the stream with `fetch` + a `ReadableStream` reader so it can
attach `X-API-Key` directly.

Each event is `event: <kind>` + `data: <JSON>`, one of three kinds:

Every event carries a leading `node` field (the emitting ns box, e.g. `"ns1"`).

**`dns`** - a resolution decision:
```json
{ "ts": 1718000000000000, "node": "ns1", "kind": "dns", "tenant": "t…", "agent": "a…",
  "addr128": "2a04:2a01::1", "qname": "example.com", "qtype": "A", "qclass": "IN",
  "rcode": "NOERROR", "decision": "allow", "source": "upstream", "answer": null,
  "latency_us": 45000, "client_subnet": "192.0.2.0/24", "edns": true, "do": false }
```
> `rcode` is the **token** (`NOERROR`/`NXDOMAIN`/`REFUSED`/…), not an integer.
> `decision` is **lowercase**: `allow`/`block`/`sinkhole`/`rewrite`/`refused`.
> `source` ∈ `cache`/`graph`/`upstream`/`tenant-block`/`authoritative`. `answer`
> (a capped rdata summary) is **not yet populated** - it is `null` today; render a
> `-` placeholder and don't depend on it.

**`conn`** - an egress connection (the egress side of the activity chain):
```json
{ "ts": 1718000000000000, "node": "ns1", "kind": "conn", "tenant": "t…", "agent": "a…",
  "addr128": "2a04:2a01::1", "proto": "socks5", "direction": "egress",
  "peer_host": "203.0.113.5", "peer_port": 443,
  "bytes_up": 2048, "bytes_down": 4096, "packets_up": 12, "packets_down": 8,
  "duration_us": 500000, "reason": "closed",
  "token_masked": "et_xxx…", "client_src": "192.0.2.0/24" }
```
> `proto` ∈ `socks5`/`http_connect`/`tls`. `reason` is the **close** reason ∈
> `closed`/`idle`/`reset`/`cap`/`error`/`ssrf-block`/`fw-allow`/`fw-deny`. A live
> `conn` event has **no `qname`** - see §6.2 for how the chain is completed.

**`alloc`** - an identity allocate/release:
```json
{ "ts": 1718000000000000, "node": "ns1", "kind": "alloc", "tenant": "t…", "agent": "a…",
  "addr128": "2a04:2a01::1", "action": "allocate", "allocated_at": 1718000000000 }
```

Notes:
- Every event leads with `node` (the emitting box), then `ts`, `kind`, `tenant`,
  `agent`, `addr128`, then the kind-specific fields.
- `ts` on the stream is **epoch microseconds** (native unit; preserves ordering).
  `latency_us`/`duration_us` are microseconds. `alloc.allocated_at` is epoch ms.
- Null fields are **omitted** (compact) - a field you don't see was null for that
  event. A heartbeat comment (`event: hb`) arrives every few seconds so you can
  show "connected" even when idle.
- The stream is bounded and drop-on-full server-side: it never blocks the service.
  On `503` the subscriber cap is reached (`Retry-After: 5`) - back off and retry.

### 6.2 The activity chain

The full story of one egress hop is **`client_src` → `qname` →
`peer_host:peer_port` + `bytes_up`/`bytes_down` + `packets_up`/`packets_down`**:
*which original client* drove the agent, *what it looked up*, *who it then talked
to*, and *how much* flowed.

But that chain is split across two events on the **live** stream - the `dns` event
carries `qname`; the `conn` event carries `client_src` + `peer_host` + the byte and
packet counters. **A live `conn` event has no `qname` field of its own**; on the
stream you join the two by `agent`/`addr128` (the `dns` lookup just before the
`conn` from the same agent). The single place the whole chain lands
on **one row** - `qname` *and* `client_src` *and* `peer` together - is the warm
**`op:logs`** projection (§6.3), which is why the recommended hybrid (§6.4) backfills from
`op:logs` and tails the stream live.

`packets_up`/`packets_down` are relay read/write **event** counts (a chattiness
signal - small messages vs. a few large ones), not wire packets.
`client_src` is the masked original-client source prefix.

### 6.3 The `op:logs` poll (authoritative history)

The same schema, queried from warm storage - the **historical** counterpart:

```
op:logs  args:{ kind:'conn', agent:'agent-ab12cd', from:'-1h', limit:200 }
→ columns: ts, kind, qname, qtype, rcode, decision, source, answer, latency_ms,
           agent, peer, bytes_up, bytes_down, duration_ms, reason, client_src,
           packets_up, packets_down
```

`from`/`to` are liberal: epoch-ms, RFC-3339, or relative (`-1h`, `-7d`). On the
poll surface durations/latency are **milliseconds** (`latency_ms`, `duration_ms`)
and the peer is a single `peer` column - the warm projection of the live event.

### 6.4 The hybrid pattern (recommended)

Render the **live** picture from the SSE stream, and seed/backfill it from the
**`op:logs`** poll:

1. On opening an agent view, call `op:logs` once to paint the last hour (history).
2. Open the SSE stream (filtered to that agent) and append live events on top.
3. If the stream drops or 503s, fall back to a periodic `op:logs` poll until it
   reconnects.

This gives you an authoritative backfill plus a live tail - and it degrades
gracefully (the live + authoritative pattern the reference `whisper-cli monitor`
uses: SSE first, `op:logs` poll as fallback).

---

## 7. RDAP - public identity lookups

RDAP is **public, unauthenticated**, RFC 9083. Link to it from the UI so an
operator (or anyone) can verify an agent's identity and ownership:

- **`GET https://rdap.whisper.online/ip/<v6>`** - the `/128` object: `handle`,
  `startAddress`/`endAddress`, `status`, `country`, `entities` (the opaque
  `t<sha256>` registrant; an opt-in `abuse` contact when published), `events`
  (registration), and `links` (forward/reverse/domain/RIR). Add `?time=<instant>`
  for the holder at a moment, or `?history` for every ownership interval.
- **`GET https://rdap.whisper.online/domain/<name>`** - the agent's forward-name
  object (`a….t<sha256>.agents.whisper.online`), with the same entities/links.

Media type is `application/rdap+json`. Errors are the RDAP shape
(`{rdapConformance, errorCode, title, description}`). A published contact email is
surfaced **verbatim only when the agent opted in** (see §8). Use RDAP for a
"verify identity" affordance and a deep-link from each agent row.

---

## 8. The privacy contract (build to it)

The control plane is designed so a UI **cannot** leak identity. Honor it:

- **Tenants are opaque.** You only ever see `t<sha256>` handles, never a raw
  account id - in `history`, in RDAP, in events. Don't try to reverse them; treat
  them as stable opaque strings.
- **Sources are masked.** `client_subnet` (dns) is masked to a network; `client_src`
  (conn) is the masked original-client source; `token_masked` is a masked egress
  token. Render them as-is - they are already privacy-safe.
- **Contact email is opt-in.** A `contact`/`contact_email` is published (and shown
  in RDAP / `list` / `agent`) **only** when the operator explicitly set it at
  `register`/`identity`/`connect`. If absent, there simply is no contact - don't
  fabricate or infer one. Surface it verbatim only where the server returns it.
- **Keys are write-once.** `register` returns `api_key` exactly once and it is never
  re-derivable. Capture it at creation; if lost, revoke and re-register. Never log
  or display a full key in the UI after creation.

---

## 9. Errors & the response envelope

**Two layers, and only the inner one is the op's answer.** The response is a
Cypher result whose single row IS the per-op envelope:

```json
{ "columns": ["op","ok","status","result","error","retry_after","elapsed_ms"],
  "rows": [ { "op": "list", "ok": true, "status": 200,
              "result": { "columns": ["kind","item"], "rows": [ … ] },
              "error": null, "retry_after": null, "elapsed_ms": 1 } ] }
```

On failure `ok:false`, `result:null`, and `error` carries a `code` and a
`message`:

```json
{ "rows": [ { "op": "list", "ok": false, "status": 403, "result": null,
              "error": { "code": "FORBIDDEN_SCOPE",
                         "message": "Missing required scope: dns:zone:read" } } ] }
```

> **The HTTP status is `200` on both.** Verified against the live endpoint: a
> refused call, a missing scope and a bad key all arrive as HTTP `200` carrying
> `rows[0].status` of 400 / 403. A UI that branches on `res.ok` therefore treats
> every failure as an empty success. Read `rows[0].status` and `rows[0].error`.

**One shape is different: a gateway-level refusal**, which never reaches the op
and so has no envelope. It is a flat object, and an unwrapper must not assume
`rows` exists:

```json
{ "error": "unknown_op", "detail": "unknown or unsupported op: budget" }
```

**Surface the `message`** to the operator: it is written to be helpful and
secret-free (no stack traces, no input echo, no tokens). Common statuses:

| Status | Meaning | UI action |
|---|---|---|
| `400 BAD_ARGS` / `UNKNOWN_OP` | malformed args / unknown op | show `message`; fix the form |
| `403 ANONYMOUS_WRITE` | write attempted without a key | prompt for a key |
| `403 FORBIDDEN_SCOPE` | key lacks the op's scope | show which scope is needed |
| `404 NOT_FOUND` | agent/identity/record not found (or cross-tenant) | refresh the list |
| `429 TENANT_LIMIT` / `AGENT_LIMIT` / `IDENTITY_LIMIT` | capacity reached | tell the operator |
| `503 STORE_UNAVAILABLE` | write not persisted | safe to retry |

The service **never** returns an opaque 500 for bad input - and read ops
(`logs`/`agent`/`history`) fail **open**: a storage hiccup yields an empty/zero
result with a note, not an error. Design the UI to show "no data yet" gracefully.

> **Honest caveat - `op:list` and connect/egress-created agents.**
> `op:list {kind:'agents'}` currently lists agents created via **`op:register`**,
> but may **miss** agents whose identity was created implicitly by `op:connect`
> (or another egress path). This is being fixed. Until then, do
> not treat `op:list` as the complete live set: the **`op:logs`** query and the
> **monitor SSE** reflect *all* activity (every agent that has resolved or
> connected appears there). A robust dashboard unions `op:list` (the registered
> roster) with the distinct `agent`/`addr128` values seen in logs/stream.

---

## 10. A complete, runnable dashboard (single file)

Save as `whisper-dashboard.html` and open it in a browser. It logs in with a key,
lists agents, creates one via a modal, and shows a **live monitor** feed. It uses
only `fetch` + a streaming reader - no build step, no dependencies.

> **CORS - a same-origin proxy is required in production.** The control plane does
> **not** emit permissive CORS: a cross-origin preflight to
> `graph.whisper.online/api/query` is answered `400 bad_request` with no
> `Access-Control-Allow-Origin` at all (verified against the live endpoint on
> 2026-08-28; `graph.whisper.security`, which used to carry the control verb,
> answered that preflight `403 "Invalid CORS request"`, equally without the
> header). A browser on a foreign origin therefore cannot call it directly; put a thin
> **same-origin proxy** in front of `/api/query` and `/monitor/stream` that injects the
> `X-API-Key` header (this also keeps the key off the client). The code below talks
> to the control plane directly for clarity; behind a same-origin proxy, point
> `CONTROL`/`MONITOR` at your proxy paths and drop the `X-API-Key` from the client.

```html
<!doctype html>
<html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Whisper Agents</title>
<style>
  body{font:14px/1.5 system-ui,sans-serif;margin:0;background:#0b0e14;color:#dfe6f0}
  header{display:flex;gap:.5rem;align-items:center;padding:.75rem 1rem;background:#11151f;border-bottom:1px solid #1e2636}
  input,button{font:inherit;padding:.4rem .6rem;border-radius:6px;border:1px solid #2a3346;background:#0e1320;color:#dfe6f0}
  button{cursor:pointer;background:#1b2740;border-color:#2f4368}
  main{display:grid;grid-template-columns:1fr 1fr;gap:1rem;padding:1rem}
  table{width:100%;border-collapse:collapse} th,td{text-align:left;padding:.35rem .5rem;border-bottom:1px solid #1e2636;font-size:13px}
  tr.sel{background:#16243d} td.addr{font-family:ui-monospace,monospace;color:#9fc1ff}
  #feed{height:60vh;overflow:auto;background:#0e1320;border:1px solid #1e2636;border-radius:8px;padding:.5rem;font-family:ui-monospace,monospace;font-size:12px}
  .ev{padding:.15rem 0;border-bottom:1px solid #141b2a;white-space:pre-wrap}
  .dns{color:#8fe9b0}.conn{color:#9fc1ff}.alloc{color:#e9c98f}.err{color:#ff8f8f}
  dialog{background:#11151f;color:#dfe6f0;border:1px solid #2a3346;border-radius:10px;padding:1rem;min-width:320px}
  h2{margin:.2rem 0 .6rem;font-size:15px;color:#aebbcf}
  .muted{color:#7a8aa5}
</style></head>
<body>
<header>
  <strong>Whisper Agents</strong>
  <input id="key" type="password" placeholder="whisper_live_…" size="28">
  <button id="login">Connect</button>
  <button id="new" disabled>+ New agent</button>
  <span id="status" class="muted"></span>
</header>
<main>
  <section>
    <h2>Fleet <span class="muted" id="count"></span></h2>
    <table><thead><tr><th>agent</th><th>address</th><th>label</th><th>state</th><th></th></tr></thead>
      <tbody id="rows"></tbody></table>
  </section>
  <section>
    <h2>Live monitor <span class="muted" id="watching"></span></h2>
    <div id="feed"></div>
  </section>
</main>

<dialog id="modal">
  <h2>Create agent</h2>
  <p><input id="m_label" placeholder="label (required)" size="30"></p>
  <p><input id="m_contact" placeholder="contact email (optional, public)" size="30"></p>
  <p class="muted">Uses op:register → mints a key + a /128. The key is shown once.</p>
  <p><button id="m_create">Create</button> <button id="m_cancel">Cancel</button></p>
  <pre id="m_out" class="muted"></pre>
</dialog>

<script>
const CONTROL = "https://graph.whisper.online/api/query";
const MONITOR = "https://graph.whisper.online/monitor/stream";
let KEY = "", abort = null, selectedAddr = null;

// Render a JS value as a Cypher literal (string-escaped) - liberal-in, safe-out.
function lit(v){
  if (Array.isArray(v)) return "[" + v.map(lit).join(",") + "]";
  if (v && typeof v === "object") return cypherMap(v);
  if (typeof v === "number" || typeof v === "boolean") return String(v);
  return "'" + String(v).replace(/\\/g,"\\\\").replace(/'/g,"\\'") + "'";
}
function cypherMap(o){ return "{" + Object.entries(o).map(([k,v]) => `${k}:${lit(v)}`).join(",") + "}"; }

async function agents(op, args = {}) {
  const query = `CALL whisper.agents({op:'${op}', args:${cypherMap(args)}})`;
  const res = await fetch(CONTROL, {
    method: "POST",
    headers: { "Content-Type": "application/json", "X-API-Key": KEY },
    body: JSON.stringify({ query }),
  });
  const json = await res.json().catch(() => ({ error:"bad_reply", detail:"non-JSON reply" }));
  // Two layers (§9). The HTTP status is ALWAYS 200, so res.ok proves nothing: a flat
  // {error,detail} is a gateway refusal, and rows[0] is the per-op envelope.
  const env = (json.rows || [])[0];
  if (!env) throw { code: json.error || "bad_reply", message: json.detail || "no envelope" };
  if (env.ok === false) throw (env.error || { code:String(env.status), message:"failed" });
  // result.{columns,rows}: map each row to an object keyed by column name.
  const { columns = [], rows = [] } = env.result || {};
  return rows.map(r => Object.fromEntries(columns.map((c,i) => [c, r[i]])));
}

function setStatus(msg, isErr){ const s=document.getElementById("status"); s.textContent=msg; s.className=isErr?"err":"muted"; }

async function loadFleet(){
  try {
    const rows = await agents("list", { kind:"agents" });
    // op:list is the REGISTERED roster; the live set also appears in logs/stream.
    const tbody = document.getElementById("rows"); tbody.innerHTML="";
    rows.forEach(({ item }) => {
      const a = item || {};
      const tr = document.createElement("tr");
      tr.innerHTML = `<td>${a.agent||""}</td><td class="addr">${a.address||""}</td>`+
                     `<td>${a.label||""}</td><td>${a.state||""}</td>`+
                     `<td><button>watch</button></td>`;
      // Watch by /128 ADDRESS (the SSE narrow takes the address, not the agent id - §6.1).
      tr.querySelector("button").onclick = () => watch(a.agent, a.address, tr);
      tbody.appendChild(tr);
    });
    document.getElementById("count").textContent = `(${rows.length} registered)`;
    setStatus("connected");
  } catch (e){ setStatus(e.message || e.code || "load failed", true); }
}

function addEvent(ev, kind){
  const feed = document.getElementById("feed");
  const div = document.createElement("div");
  div.className = "ev " + kind;
  // Latency/duration: the live stream carries *_us (micros); the op:logs backfill carries *_ms.
  const ms = v_us => Math.round((v_us || 0) / 1000);
  if (kind === "conn")
    // A live conn event has NO qname (it lives on the matching dns event / the op:logs row); show
    // qname only when present (i.e. on an op:logs conn row, which carries it) - never a blank arrow.
    div.textContent = `conn ${ev.client_src||"?"} → `+
                      (ev.qname ? `${ev.qname} → ` : "")+
                      `${ev.peer_host||"?"}:${ev.peer_port||""} `+
                      `↑${ev.bytes_up||0}/↓${ev.bytes_down||0}b  pkt ↑${ev.packets_up||0}/↓${ev.packets_down||0}  ${ev.reason||""}`;
  else if (kind === "dns")
    div.textContent = `dns  ${ev.qname||""} ${ev.qtype||""} → ${ev.decision||""} (${ev.answer||"-"}) ${ms(ev.latency_us)||ev.latency_ms||0}ms`;
  else
    div.textContent = `alloc ${ev.action||""} ${ev.addr128||""}`;
  feed.prepend(div);
  while (feed.childNodes.length > 300) feed.lastChild.remove();
}

// Hybrid: backfill the last hour via op:logs, then tail the live SSE stream.
// Watch by /128 ADDRESS: the SSE ?agent= narrow takes the address (handle-based narrow is not yet
// wired - §6.1), so we pass `addr` to the stream AND filter live events by addr128 client-side as a
// belt-and-suspenders (without it the stream would be tenant-wide). op:logs filters by the agent id.
async function watch(agent, addr, tr){
  document.querySelectorAll("tr.sel").forEach(t => t.classList.remove("sel"));
  if (tr) tr.classList.add("sel");
  selectedAddr = addr || null;
  document.getElementById("watching").textContent = "· " + agent + (addr ? " (" + addr + ")" : "");
  document.getElementById("feed").innerHTML = "";
  if (abort) abort.abort();
  abort = new AbortController();

  try {
    const past = await agents("logs", { agent, from:"-1h", limit:100 });
    past.reverse().forEach(e => addEvent(e, e.kind || "conn"));
  } catch (e){ /* fail-open: empty backfill is fine */ }

  if (!addr){
    // No /128 yet (e.g. an agent that has never allocated): the SSE narrow can't pin it, so don't
    // open a tenant-wide stream under a single agent's label - show the backfill only.
    setStatus("no /128 for this agent yet - showing history only", false);
    return;
  }

  try {
    const res = await fetch(`${MONITOR}?agent=${encodeURIComponent(addr)}`, {
      headers: { "X-API-Key": KEY }, signal: abort.signal,
    });
    const reader = res.body.getReader(); const dec = new TextDecoder(); let buf = "", evName = "message";
    for (;;) {
      const { value, done } = await reader.read(); if (done) break;
      buf += dec.decode(value, { stream:true });
      let nl; while ((nl = buf.indexOf("\n")) >= 0) {
        const line = buf.slice(0, nl).trimEnd(); buf = buf.slice(nl + 1);
        if (line.startsWith("event:")) evName = line.slice(6).trim();
        else if (line.startsWith("data:")) {
          try {
            const ev = JSON.parse(line.slice(5).trim());
            // Belt-and-suspenders: while the ?agent= /128 narrow is in effect server-side, also drop
            // any event whose addr128 isn't the selected agent (no-op once the narrow is exact).
            if (!selectedAddr || !ev.addr128 || ev.addr128 === selectedAddr) addEvent(ev, evName);
          } catch {}
        }
      }
    }
  } catch (e){ if (e.name !== "AbortError") setStatus("stream ended; poll op:logs to refresh", true); }
}

document.getElementById("login").onclick = () => {
  KEY = document.getElementById("key").value.trim();
  if (!KEY) return setStatus("enter a key", true);
  document.getElementById("new").disabled = false;
  loadFleet();
};
document.getElementById("new").onclick = () => document.getElementById("modal").showModal();
document.getElementById("m_cancel").onclick = () => document.getElementById("modal").close();
document.getElementById("m_create").onclick = async () => {
  const label = document.getElementById("m_label").value.trim();
  const contact = document.getElementById("m_contact").value.trim();
  const args = { label }; if (contact) args.contact_email = contact;
  try {
    const [row] = await agents("register", args);
    document.getElementById("m_out").textContent =
      `agent ${row.agent} @ ${row.address}\nAPI KEY (shown once!):\n${row.api_key}`;
    loadFleet();
  } catch (e){ document.getElementById("m_out").textContent = "error: " + (e.message || e.code); }
};
</script>
</body></html>
```

What it demonstrates, mapped to this guide:
- **Login by key** → §2 (`X-API-Key`, the principal).
- **Fleet table** → §5 (`op:list {kind:'agents'}`), with the caveat in §9 noted.
- **Create modal** → §5 (`op:register`), capturing the once-shown `api_key` (§8).
- **Live monitor** → §6: backfill via `op:logs`, tail the SSE stream, and render
  the `client_src → qname → peer + bytes + packets` chain.
- **Errors** → §9: every call unwraps `rows[0]` and surfaces `error.message`, never
  an opaque failure and never a false success from a 200 that carried a 403.

---

## 11. Quick reference

```
endpoint   POST https://graph.whisper.online/api/query    (GET ?q= also ok)
auth       X-API-Key: whisper_live_<key>
verb       CALL whisper.agents({op:'…', args:{…}})
monitor    GET/POST https://graph.whisper.online/monitor/stream   (X-API-Key or et_ token)
rdap       GET https://rdap.whisper.online/ip/<v6> | /domain/<name>   (public)
graph      POST https://graph.whisper.security/api/query   (the cognition verbs, §4)
ops        register host identity connect disconnect policy domain verify token
           resolver firewall budget task respond partner revoke rename
           erase purge                (writes)
           list agent logs lookups history   (reads)  ← per-agent detail is op:agent
                                                        (NOT op:stats)
           budget/task/erase/purge are NOT on the front door's allowlist: use
           op:firewall{budget:{…}} for caps (§12.3)
envelope   {columns,rows:[{op,ok,status,result:{columns,rows},error:{code,message},
                           retry_after,elapsed_ms}]}      ← HTTP is ALWAYS 200
gateway    {error,detail}   ← a refusal before the op; no envelope, no rows
```

> **Postel, applied to your UI:** be liberal in what you send (POST or GET, either
> contact alias, relative or absolute time windows) and conservative in what you
> render (unwrap `rows[0]` and surface `error.message`, never an opaque error and
> never a false success from a 200; show "no data yet" on a
> fail-open empty; never display a full key after creation). Build it so the common
> case needs no configuration - one key, one call, and the fleet is live.

---

## 12. Policy & enforcement controls - filter / firewall / budget

> **Added 0.72–0.76.** These extend the `policy` and `firewall` ops already in §3
> with named, graph‑backed bundles and per‑agent budgets. All are reachable on the
> same `whisper.agents` verb and the same envelope - no new endpoint. Three distinct,
> composable enforcement layers a UI should present as three panels:
>
> | Layer | Op | Scope | What it controls |
> |---|---|---|---|
> | **DNS policy** (resolution) | `policy` | `dns:policy:write` | what an agent is allowed to *resolve* |
> | **Egress firewall** (connection) | `firewall` | `dns:firewall:write` | where an agent is allowed to *connect* |
> | **Budgets / kill‑switch** (volume) | `firewall` (`budget:`) | `dns:firewall:write` | how *much* an agent may do, and a hard stop |

### 12.1 Named policy bundles (DNS resolution policy)

Beyond the raw `block`/`allow` lists in §3, `op:policy` accepts **named bundles** -
one‑toggle, graph‑evaluated postures compiled live against the Whisper graph at the
moment of resolution. Set them with a `bundles` list; read the whole policy back by
calling `policy` with no args.

```
CALL whisper.agents({op:'policy', args:{bundles:['block:tor-exits','geo:deny:RU,KP']}})
# → result.rows: [["default","allow"],["bundle","block:tor-exits"],["bundle","geo:deny:RU,KP"]]
```

**The bundle vocabulary** (each is one switch in the UI):

| Bundle token | Blocks resolution when the name's infrastructure is… | Graph signal |
|---|---|---|
| `block:tor-exits` | a Tor exit relay | `lookupTorRelay` |
| `block:bulletproof` | bulletproof / high‑threat‑band hosting | `assess` band |
| `block:rpki-invalid` | announced by an RPKI‑invalid route (no covering ROA) | `ROA` nodes |
| `geo:deny:<CC[,CC…]>` | located in a denied country (ISO‑3166 alpha‑2) | `HAS_COUNTRY`/`LOCATED_IN` |
| `block:sanctions` | on a sanctions / threat list | `LISTED_IN` |
| `block:newly-registered` | a domain registered in the last 30 days | `REGISTERED_BY` reg‑date |

- **Escalate‑only + fail‑open.** A bundle can only turn an *allow* into a *block*; it
  never downgrades a hard sinkhole, and **any graph fault / timeout means the bundle is
  skipped and resolution proceeds** (never a block, never a 500). Render bundles as
  best‑effort "tighten" toggles, not guarantees - and don't show an error if the graph
  is briefly unavailable; the agent still resolves.
- **Per‑tenant.** Bundles apply to the calling tenant's resolution; reading back returns
  `default` plus one `bundle` row per active bundle (and any raw `block`/`allow` rows).
- **UI:** a checklist of the six bundles + the country multi‑select for `geo:deny`,
  alongside the existing manual block/allow lists. One `policy` write sets them all.

### 12.2 Per‑agent egress firewall (connection policy)

`op:firewall` (see §3) is the per‑agent allow/deny rule set on the **egress** plane -
`{action: allow|deny, match: ip|cidr|host|port, value}`, with a `default`. Read it
back by calling `firewall` with `agent`/`address` and no `rules`. This is distinct
from §12.1: policy gates *resolution*, firewall gates *where the agent's egress proxy
may connect*. UI: a per‑agent rule table (drag‑order, default allow/deny).

**Where it takes effect (per tier), and where it does not.** The rule set is stored
against the agent's `/128` and consulted at exactly one point: the Whisper egress proxy,
once per outbound connection, at SOCKS5/HTTP CONNECT time: the service enforces the firewall
per source-bound `/128` at CONNECT. It is an application-layer CONNECT
filter, so it governs **proxy-tier egress** (Tier-1.5 SOCKS5/AnyIP, and the wireproxy
default). It does not sit in the path of a device's own off-Whisper traffic. Two honesty
rules a UI must honour:

- **A DNS-only (Tier-2) device** keeps its own address and sends its real traffic from
  that address, not through Whisper. `op:firewall` on such a device is accepted and
  stored (HTTP 200, the rules echo straight back), but it filters **nothing today**: that
  device's connections never reach the egress CONNECT path, so `firewallFor(/128)` is
  never consulted for it. The rules are not lost. They **arm automatically** the moment
  the device starts egressing through Whisper (after `op:connect`). Render the "stored,
  and active once this device uses Whisper egress" banner the console already shows; never
  imply the rules filter a DNS-only device's own traffic now.
- **To govern where a DNS-only device may go, use resolution policy (§12.1), not the
  firewall.** Policy gates *resolution* (block/allow/bundles) and takes effect on every
  query the device sends to its Whisper resolver, DNS-only or not. Policy is **per-tenant by
  default** (one posture per resolver `/128`, applied to every device that shares that
  resolver). It can also be set **per device**: pass a device selector (`agent` id or
  `address` `/128`) to `op:policy` and the block/allow/bundles/mode are stored and enforced
  against that one device's `/128`, taking precedence over the tenant policy. This binds to
  the device's `/128`, so it takes effect when the resolver can source-identify the device
  by that `/128` (a Tier-1 routed device, or a device on its dedicated resolver `/128`); a
  device querying via the shared per-tenant DoH (API key, no per-device `/128`) can't be
  distinguished at resolve time and falls back to the tenant policy. To manage a policy
  "per tenant" from the UI, fan the same call out across the tenant's devices.

> Verified live: a registered `/128`-identity agent with **no egress**
> accepted a `deny host:evil.example` rule and echoed it back at HTTP 200 with no
> enforcement surface, confirming the stored-but-dormant behaviour above. The storage is
> deliberately forward-looking (identity-anchored rules that arm on first egress), so it
> is correct, not a bug; the honesty lives in the banner and in this note.

### 12.3 Per‑agent budgets, quotas & kill‑switch

Budgets ride the **same reachable `firewall` op** (a `budget:` sub‑map), so a UI sets
them through the one verb it already uses. There is a dedicated `op:budget` on the
backend with the identical arguments and columns (§3), but it is not on the front
door's op allowlist, so `op:firewall` is the form a UI should send. Per‑/128 caps +
a hard kill‑switch, enforced on the egress path:

```
CALL whisper.agents({op:'firewall', args:{agent:'agent-…', budget:{connections_cap:64, requests_cap:100000, kill:false}}})
# → result columns: agent, address, bytes_up_cap, bytes_down_cap, connections_cap, requests_cap, cost_usd, kill, status
# → e.g.            ["agent-…","2a04:2a01:…",0,0,64,100000,0.0,false,"set"]
```

| Field | Meaning |
|---|---|
| `bytes_up_cap` / `bytes_down_cap` | cumulative byte ceilings (0 = unlimited) |
| `connections_cap` | max **concurrent** egress connections (0 = unlimited; `1` allows exactly 1) |
| `requests_cap` | cumulative CONNECT ceiling (0 = unlimited) |
| `cost_usd` | optional spend ceiling |
| `kill` | **the kill‑switch** - `true` cuts all live tunnels immediately and refuses new ones until cleared |
| `status` | `set` on write |

- **What a breach does:** the egress proxy refuses the over‑cap connection with **HTTP
  `429`** (or SOCKS5 `0x02`), **not** an auth error - so the UI can distinguish "capped"
  from "unauthorized." A `requests_cap` slot is consumed only on an *allowed* connect.
- **Fail‑open:** an agent with no budget set is unlimited; a budget read/enforce fault
  refuses only that one connection, never crashes the plane.
- **Read‑back:** call `op:agent` (§3) for the live counters (`connections_active`,
  `bytes_up`/`down`) to render usage against the caps.
- **UI:** per‑agent cap inputs + a prominent **kill‑switch** toggle; show live usage
  (from `op:agent`) as a bar against each cap; surface a `429`‑sourced "rate‑limited"
  state distinctly from auth failures.

---

## 13. Identity verification & reverse observability (keyless, public)

> **Added 0.72–0.76.** These are **keyless, public** surfaces - no API key, the `/128`
> *is* the identity. Serve/deep‑link them from any host **except** the installer hosts
> (`cli`/`get`/`install`.whisper.online serve the installer for every path) - use
> `rdap.whisper.online` (or any non‑installer gateway host). Every field is the same
> public‑safe data RDAP already exposes (opaque `t<sha256>` / `a…` handles, masked
> sources) - never a raw account.

### 13.1 One‑call peer verification - `verifyIdentity`

```
GET https://rdap.whisper.online/verify-identity?ip=<v6>        (or /verify-identity/<v6>)
# agent →   {"is_whisper_agent":true,"fqdn":"a….agents.whisper.online","operator":"t<sha256>",
#            "tenant":"t<sha256>","dane_ok":true,"jws_ok":true,"verified_at":<ms>,
#            "evidence":{"address":"…","ptr":"…ip6.arpa","forward_aaaa":"…","agent":"a…","allocated_at":…}}
# non-agent → {"is_whisper_agent":false,"evidence":{"address":"…","ptr":null},"detail":"no Whisper agent identity anchors this address"}
```

Runs the full chain server‑side (reverse DNS + DANE‑TLSA + RDAP + the signed
`.well-known/whisper-identity` JWS) and returns **one verdict**. **UI:** a "Verify"
affordance on any address - green check with `dane_ok`/`jws_ok` evidence, or a clean
"not a Whisper agent" - without orchestrating four lookups.

### 13.2 Identity transparency log

```
GET https://rdap.whisper.online/ip/<v6>/transparency
# → {"object":"identity-transparency","address":"…","count":N,
#    "events":[{"event":{"action":"issuance"|"revocation","address":"…","holder":"t…","agent":"a…","timestamp":<ms>},"proof":"<sha256-chain>"}]}
```

Public, append‑only, hash‑chained feed of every issuance/revocation for a `/128`. A
consumer recomputes the chain to verify it wasn't truncated/reordered. **UI:** an
"identity history / provenance" timeline on the agent detail view.

### 13.3 Inbound‑lookup feed - "who verified you"

```
GET https://rdap.whisper.online/ip/<v6>/lookups        (?since/?from/?to, ?limit, ?kind=dns|rdap)
# → {"object":"identity-lookups","address":"…","count":N,"lookups":[ … rows, source masked to /48 … ]}
```

Reverse observability - *who has been resolving/verifying this agent's identity*
(authoritative‑zone + RDAP accesses), source k‑anonymised to `/48`. Empty `lookups:[]`
is a normal "nobody yet," not an error. **UI:** a "who's checking this agent" panel -
a genuinely novel signal no other agent‑identity product offers.

### 13.4 WHOIS - verify with the tool every SOC already has

```
whois -h whois.whisper.online {agent-128}
# → inet6num / netname / fqdn / reverse / status / country / origin (AS219419) / org / aggregate / posture / rdap-uri
```

Standard RFC‑1288 WHOIS on `:43`, mirroring the RDAP record with a referral to it.
**UI:** offer a copy‑paste `whois …` snippet on the agent view for operators who live
in a terminal.

### 13.5 New RDAP fields - jurisdiction + behavioral posture

`GET https://rdap.whisper.online/ip/<v6>` now also carries (when known):

- **`jurisdiction`** - the agent's data‑residency, derived from its allocation
  sub‑block and published in the RFC 9092 geofeed (so it's *provable from the address*,
  not asserted). Absent on pre‑0.74 agents (fail‑open untagged). **UI:** a residency
  badge for regulated‑buyer views.
- **`behavioral_posture`** - a reputation token **computed in‑platform** from the
  agent's own contacted‑host history (30‑day window) run through the graph's `assess`:
  `clean-30d` / `flagged-recent` / `flagged-critical`, or **omitted** when neutral
  (no history / fail‑open). **UI:** a "behavior" chip on the agent - who you are *and
  how you've behaved* - but treat an absent posture as "no claim," never as "clean."

> Both fields are best‑effort and fail‑open: render them when present, show nothing
> (not an error, not a default) when absent.
