WhisperDocs
API reference

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):

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):

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:

{
  "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:

{ "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.)

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:

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:

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:

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 (default agents). returns (columns): kind, item - each item is {label, fqdn, address, agent, created, state}.

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, 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:

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).

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.

With Whisper:

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). 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, east_west_on_fault, whale_approval, node_key_enrolment, and a calm.* family that belongs to the endpoint scheduler, not this call's args.

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:

dig @2a04:2a01:0:53::53 bad-actor.example AAAA +dnssec   # NXDOMAIN if it's on the block list

With Whisper:

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.

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:

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.

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 (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, the MCP server, and both SDKs (whisper-py, whisper-node) are all thin wrappers over exactly the calls on this page.


Next: CLI & one-command setup for the zero-config path to every op above, or Graph & cognition for the read-only identify/assess verbs that sit next to whisper.agents on the same endpoint.