# Control plane

**Every "manage my fleet of agents" feature you'd otherwise build - mint an identity, wire up egress, set a policy, pull the logs, pull the plug - is one Cypher verb over one HTTP endpoint.**

`whisper.agents({op, args})`: no REST surface to memorize, no SDK required, and every op has a keyless read-only sibling, so a stranger can check your work without your key.

## The pain this removes

Most "agent management" APIs are a sprawl: one endpoint to create, another to list, a third for policy, a fourth for usage, a fifth to delete - each with its own path, its own verb, its own pagination quirks, and its own way of telling you "no." Multiply that by every automation platform you wire in (n8n, Zapier, a bash script, a Python notebook) and you're maintaining N client integrations against effectively the same five ideas: *make one, look at them, govern them, watch them, kill one.*

Whisper collapses all five into one call shape. `op` picks the action, `args` is a map, the response is always the same envelope whether you asked for a fleet listing or a kill-switch. Learn the shape once - `curl`, `whois -h`, a raw `dig`, a spreadsheet macro, and the `whisper` CLI all speak it identically - and every future op - the set has only ever grown additively, never a breaking change - costs you nothing new to integrate.

## 1. Endpoint & auth

| | |
|---|---|
| **Control endpoint** | `POST https://graph.whisper.online/api/query` |
| **Auth** | header `X-API-Key: whisper_live_…` |
| **Content-Type** | `application/json` |
| **Body** | `{"query": "CALL whisper.agents({op:'<op>', args:{…}})"}` |

There is no key in the body - it travels only in the header, so it never ends up logged in a query string or a Cypher literal. Everything under `whisper.agents` is scoped to the caller's own tenant; you cannot see, list, or revoke another account's agents regardless of what `agent`/`address` you pass.

**With stock tools** (no Whisper software - just `curl`):

```bash
curl -s https://graph.whisper.online/api/query \
  -H "X-API-Key: whisper_live_..." \
  -H "content-type: application/json" \
  -d '{"query":"CALL whisper.agents({op:'"'"'list'"'"', args:{kind:'"'"'agents'"'"'}})"}' | jq .
```

**With Whisper** (the CLI does the Cypher-quoting and envelope-unwrapping for you):

```bash
whisper list
```

> Building the query string by hand? Map keys are sorted and string values are single-quoted with `'` doubled to `''` (so `Tim O'Reilly` becomes `'Tim O''Reilly'` - it can never break out of the literal). This is generation, not evaluation: `args` values are data, never executed as Cypher, so there is no injection surface no matter what a label or hostname contains.

## 2. The response envelope - read this once

Every call comes back wrapped in the one procedure-row table `/api/query` always returns. `rows` is a list of **objects** keyed by `columns`, not positional arrays - unwrap `rows[0]` for a single-op call, then `result.rows` (positional arrays aligned to `result.columns`) for the payload underneath:

```json
{
  "columns": ["op","ok","status","result","error","retry_after","elapsed_ms"],
  "rows": [{
    "op": "list", "ok": true, "status": 200,
    "result": { "columns": ["kind","item"], "rows": [["agents", { "label":"my-agent", "...": "..." }]] },
    "error": null, "retry_after": null, "elapsed_ms": 16
  }]
}
```

`elapsed_ms` is server-side timing, safe to ignore. Check `ok` first; on `ok:false`, `error` is a small object, never a string - surface `error.message`, not a `detail` field, because there isn't one:

```json
{ "code": "FORBIDDEN_SCOPE", "message": "Missing required scope: dns:logs:read", "retryable": false }
```

(The RFC 7807 `type`/`title`/`status`/`detail` shape belongs to the raw-Cypher error path - a malformed `MATCH`/`CALL` you wrote yourself - not to a `whisper.agents` op; see [Graph & cognition](/docs/graph-api).)

A `429`/`503` may carry `retry_after` (seconds) - back off exactly that long, no guessing.

## 3. Every op

Scopes: `register`, `policy`, and `revoke` need `admin:dns`; the rest need the matching read/write scope. `agent=<id>` and `address=<addr>` are interchangeable everywhere below - anything containing `:` is treated as an address.

### `register` - mint a brand-new agent, with its own key

```
CALL whisper.agents({op:'register', args:{label:'checkout-bot', contact_email:'ops@example.com'}})
```
**args:** `label` (required), `contact_email` (optional), `whale` (optional - the Whalenet intent map, see below).
**returns:** `agent`, `address` (a real routable `/128` off `2a04:2a01::/32`), `fqdn`, `ptr`, `doh_url` (a per-agent DoH URL with the key already embedded in the path), `resolver_ip`, `member` (the fully-qualified label under your tenant zone), `member_published` (whether that member name was actually put in the zone), and **`api_key`** - the new agent's own key, handed back exactly once. Capture it or the agent has to be re-keyed.

**With stock tools:**
```bash
curl -s https://graph.whisper.online/api/query -H "X-API-Key: whisper_live_..." \
  --data-urlencode "q=CALL whisper.agents({op:'register', args:{label:'checkout-bot'}})"
```
**With Whisper:**
```bash
whisper create --register --name checkout-bot
```

**Declining the member name.** A new agent is published under two names: its canonical `a<hex>.t<hash>.agents.whisper.online`, and the short `member` name in your fleet's flat namespace. The short one is guessable, so a node can opt out of it at mint time and nowhere else:

```
CALL whisper.agents({op:'register', args:{label:'db-01', whale:{v:1, publish_name:false}}})
```

`member` comes back empty, `member_published` comes back `false`, and the short name does not resolve:

```bash
$ dig +short db-01.t{your-tenant-hash}.agents.whisper.online CNAME    # NXDOMAIN
```

Read `member_published`, not an empty `member`: the field is also empty when the label you chose normalises to nothing publishable, and those are different answers. Declining a name costs the node nothing else - the `/128`, the canonical name, the reverse DNS and the `label` that `op:list`, `op:agent` and RDAP show are all unchanged. Say nothing, or say `publish_name:true`, and you get today's behaviour. A value that is neither `true` nor `false` is a `400`, never a silent `false`.

### `identity` - allocate a `/128` you'll drive yourself

Same allocation machinery as `register`, minus the second API key - for a caller that already has a key and just wants an address.

```
CALL whisper.agents({op:'identity', args:{label:'my-agent'}})
CALL whisper.agents({op:'identity', args:{release:true, address:'2a04:2a01:eb5a:ca74:cef2:2a:323d:40d4'}})
```
**args:** `label` (required), `contact_email` (optional); or `release:true` + `address` to hand one back.
**returns:** `address`, `fqdn`, `ptr`, `state`, `doh_url`, `resolver_ip` - no separate `agent` id on this op, the address is the handle.

### `list` - the fleet

```
CALL whisper.agents({op:'list', args:{kind:'agents'}})
```
**args:** `kind` = `agents` | `identities` | `records` | `activityTenants` (default `agents`); with `activityTenants`, `view` = `all` | `self` (absent means `all`).
**returns (columns):** `kind`, `item` - each `item` is `{label, fqdn, address, agent, created, state}`; for `activityTenants` it is `{tenant, agents, self, agent_tenants}`.

`view:'self'` is one row, your own tenant handle, the one `whisper whoami` prints. Each agent logs under a tenant of its own, so `agent_tenants` counts those, not yours:

```
CALL whisper.agents({op:'list', args:{kind:'activityTenants', view:'self'}})
```
```json
["activityTenants", { "tenant": "t332b313d917d9a36aae46d949f003a00", "agents": 1, "self": true, "agent_tenants": 51 }]
```

A `view` outside the vocabulary is refused by name, never defaulted (`ok:false`, `error.code` `BAD_ARGS`):

```
view must be one of all|self for kind:'activityTenants' (absent means all); 'sef' is not a view this build serves
```

```bash
whisper list                 # human table
whisper list --json | jq .   # same data, scriptable
whisper whoami               # the caller's own handle
```
```
FIELD          VALUE
key            set (WHISPER_API_KEY)
tenant         t332b313d917d9a36aae46d949f003a00
agents         52
agent tenants  51 - each agent logs under its own, not yours
selected       2a04:2a01:9::50
```

### `agent` - one agent's detail + live counters

```
CALL whisper.agents({op:'agent', args:{address:'2a04:2a01:eb5a:ca74:cef2:2a:323d:40d4'}})
```
**args:** `agent` **or** `address`.
**returns (columns):** `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`, plus - on the same row, `null` for a flat unpublished agent - the nested-identity fields (`parent`, `children`, `children_total`, `children_truncated`, `depth`, `owner_agents_raw`, `owner_agents_nested_deducted`, `roam`) and the key-material/publication fields (`identity_spki`, `tlsa_digest`, `sshfp_digest`, `key_source`, `key_binding`, `publication`).

The **keyless equivalent** - anyone, no key, checking the *identity* half of this same record from stock tools:

```bash
dig -x 2a04:2a01:eb5a:ca74:cef2:2a:323d:40d4 +short
curl -s https://rdap.whisper.online/verify-identity?ip=2a04:2a01:eb5a:ca74:cef2:2a:323d:40d4
```
(the usage counters are yours only, behind the key; the identity itself is public by design - see [Verify an agent](/docs/verify)).

### `connect` - egress bound to the agent's own `/128`

```
CALL whisper.agents({op:'connect', args:{agent:'checkout-bot', tier:'socks5'}})
CALL whisper.agents({op:'connect', args:{agent:'checkout-bot', tier:'wireguard', public_key:'{base64}'}})
```
**args:** `agent` (optional - omit to reuse the most recently connected agent), `tier` = `socks5` (default) | `wireguard` | `anyip`.
**returns:** `tier`, `address`, `fqdn`, plus transport fields - `socks5`/`anyip` return `http_proxy` + `connection_string` (each embeds a bearer) and `socks5_endpoint`; `wireguard` returns `server_public_key`, `endpoint`, `dns`, `wireguard_config`, and (only on the zero-key path) `client_private_key`.

> **Bearer hygiene:** never persist the SOCKS5 bearer or a WireGuard private key to logs, workflow storage, argv, or a child's environment dump. `whisper connect` hands it straight to a local proxy and only ever prints a bearer-free `socks5h://127.0.0.1:<port>`. Full mechanics: [Connect & egress](/docs/connect).

**With Whisper:**
```bash
whisper connect --agent checkout-bot                      # Tier 1.5, SOCKS5, no root needed
whisper connect --agent checkout-bot --tier wireguard      # Tier 1, routed userspace WireGuard (wireguard-go netstack)
```

### `policy` - the per-tenant resolver policy

```
CALL whisper.agents({op:'policy', args:{default:'deny', block:['bad-actor.example'], allow:['api.stripe.com']}})
CALL whisper.agents({op:'policy', args:{}})   # no args -> reads the current policy back
```
**args (set):** `default` = `allow` | `deny`; `block`/`allow` = arrays of names (max 1000 combined); `allow_sources` = the addresses and CIDR prefixes permitted to use your dedicated resolver `/128` (max 64).
**returns (columns):** `key`, `value` - e.g. `["default","allow"]`. A no-args read hands back every operational key the tenant currently has, not only the three above - `mode`, `retention`, `allow_sources`, `east_west_on_fault`, `whale_approval`, `node_key_enrolment`, and a `calm.*` family that belongs to the endpoint scheduler, not this call's `args`.

#### Who may use your resolver

Your dedicated `/128` is a resolver you point a stub at from wherever you are, so by default it answers **any** source that knows the address, and the read-back says exactly that: `allow_sources: any`. That is the right default for a laptop on hotel wifi or a CI runner that gets a new address every job, and it is what roaming needs.

It also means your resolution policy is applied to whoever asks. If your clients query from known networks, say so and the address stops being yours to anyone else:

```
CALL whisper.agents({op:'policy', args:{allow_sources:['203.0.113.0/24','2001:db8:1::/48']}})
CALL whisper.agents({op:'policy', args:{allow_sources:[]}})   # back to any
```

A source outside the set does not get a special refusal that would reveal you had set one. Your `/128` simply stops being a tenant resolver for it and answers exactly as an address with no policy behind it would, for every name, so neither the restriction nor your policy is readable from outside.

**With stock tools**, you can *read* the effect of a deny without a key at all - send the query yourself and watch the resolver refuse:
```bash
dig @2a04:2a01:0:53::53 bad-actor.example AAAA +dnssec   # NXDOMAIN if it's on the block list
```
**With Whisper:**
```bash
whisper policy --default deny --allow api.stripe.com
whisper policy
```

### `logs` - what an agent actually did

```
CALL whisper.agents({op:'logs', args:{agent:'checkout-bot', kind:'dns', from:'-1h', limit:500}})
```
**args:** `agent` (optional), `kind` = `dns` | `conn` | `alloc` (omit for all), `from`/`to` (epoch-ms, RFC 3339, or relative like `-1h`), `limit` (default 1000, cap 10000).
**returns (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, parent_agent, nested` - empty rows, never an error, when the window has no events.

```bash
whisper logs --agent checkout-bot --from -1h --kind dns | jq '.[] | select(.decision=="blocked")'
```

### `revoke` - the kill-switch, irreversible

```
CALL whisper.agents({op:'revoke', args:{agent:'checkout-bot'}})
```
**args:** `agent` (id or address).
**returns:** a status field (`status`/`state`).

Revocation isn't a database flag you have to trust - it's provable with the exact same stock tools that proved the identity existed:

```bash
whisper kill --revoke checkout-bot

# now prove it, with zero Whisper software:
dig -x 2a04:2a01:eb5a:ca74:cef2:2a:323d:40d4 +short          # -> nothing
curl -s https://rdap.whisper.online/verify-identity?ip=2a04:2a01:eb5a:ca74:cef2:2a:323d:40d4
# -> {"is_whisper_agent": false, ...}
```
The event also lands in the signed transparency log's revocation status-list (`GET https://whisper.online/checkpoint/status-list`) - see [Transparency log](/docs/transparency).

## 4. Trial limits and scaling up

A fresh account can register and run agents immediately - no waitlist, no manual approval - up to a default cap of 5 concurrently-registered agents and 5 standing `/128` identities; past either, `register` returns `429 AGENT_LIMIT` and `identity`/`connect` return `429 IDENTITY_LIMIT`. Accounts that need more have the cap raised on request, and the raise is real: this page's own account runs in the hundreds of concurrently-registered agents today, and `list` hands every one of them back in a single `result.rows` array - there's no separate pagination scheme to learn at scale, just a bigger array. There is no per-op rate limit beyond the shared API gate; a `429` carries `retry_after` and should be honored exactly, not retried on a fixed backoff.

## 5. Reference implementation

The [`whisper` CLI](https://github.com/whisper-sec/whisper-cli) (MIT) is the canonical client - `internal/client/client.go` for transport/auth, `cypher.go` for the query builder, `envelope.go` for the two-shape decoder. Match its behavior and any integration you write against `whisper.agents` is correct by construction; the [n8n node](/docs/n8n), the MCP server, and both SDKs ([whisper-py](https://github.com/whisper-sec/whisper-py), [whisper-node](https://github.com/whisper-sec/whisper-node)) are all thin wrappers over exactly the calls on this page.

---

**Next:** [CLI & one-command setup](/docs/cli) for the zero-config path to every op above, or [Graph & cognition](/docs/graph-api) for the read-only `identify`/`assess` verbs that sit next to `whisper.agents` on the same endpoint.
