# 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.security/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.security/api/query \
  -H "X-API-Key: whisper_live_..." \
  -H "content-type: application/json" \
  -d '{"query":"CALL whisper.agents({op:'"'"'list'"'"', args:{}})"}' | 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

A robust client accepts either shape it may see and always checks `ok` first:

```json
// shape A — procedure-row table (what /api/query returns today)
{
  "columns": ["op","ok","status","result","error","retry_after"],
  "rows": [[ "list", true, 200,
    { "columns": ["kind","item"], "rows": [["agents", { "label":"my-agent", "...": "..." }]] },
    null, null
  ]]
}

// shape B — flat envelope (same fields, no outer table)
{ "ok": true, "status": 200, "result": {"columns":[...], "rows":[...]}, "error": null }
```

Unwrap `result.rows` (positional arrays aligned to `result.columns`) into records; on `ok:false`, surface `error.detail` verbatim — it's written to be actionable and is always secret-free:

```json
{ "type":"about:blank", "title":"Forbidden", "status":403,
  "detail":"scope admin:dns required for op:register", "suggestions":["request the admin:dns scope"] }
```

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).
**returns:** `agent`, `address` (a real routable `/128` off `2a04:2a01::/32`), `fqdn`, `ptr`, `state`, 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.security/api/query -H "X-API-Key: whisper_live_..." \
  -d '{"query":"CALL whisper.agents({op:'"'"'register'"'"', args:{label:'"'"'checkout-bot'"'"'}})"}'
```
**With Whisper:**
```bash
whisper agent create --label checkout-bot
```

### `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:** `agent`, `address`, `fqdn`, `ptr`, `state`.

### `list` — the fleet

```
CALL whisper.agents({op:'list', args:{kind:'agents'}})
```
**args:** `kind` = `agents` | `identities` | `records` (default `agents`).
**returns (columns):** `kind`, `item` — each `item` is `{label, fqdn, address, agent, created, state}`.

```bash
whisper list                 # human table
whisper list --json | jq .   # same data, scriptable
```

### `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`.

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 checkout-bot                      # Tier 1.5, SOCKS5, no root needed
whisper connect checkout-bot --tier wireguard      # Tier 1, routed kernel/wireproxy tunnel
```

### `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).
**returns (columns):** `key`, `value` — e.g. `["default","allow"]`.

**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 set --default deny --allow api.stripe.com
whisper policy get
```

### `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` — empty rows, never an error, when the window has no events.

```bash
whisper logs checkout-bot --since 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 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 fleet cap; accounts that need more open a ticket through the console to raise it, and production tenants routinely run **up to 1,000 concurrently-registered agents** on a single key without any change to the call shape above: `list`/`agent`/`logs` paginate the same way at 5 agents or 1,000. 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.
