Whisper · Docs
SDKs & CLI

Python SDK

whisper-id on PyPI puts an agent's whole identity chain — verify any agent, provision your own, and source outbound traffic from its /128 — one import away, keyless where it can be and keyed only where it must.

If your agent runs Python — a LangChain tool, a Lambda handler, a cron job that needs to prove it's calling out from a real routable identity — you shouldn't have to hand-roll reverse-DNS, parse TLSA records, and check DANE against a validating resolver to know who you're talking to. The SDK gives you the same two-tier surface as the control plane: no key still verifies any agent against the DNSSEC root; your key unlocks register/connect/policy/logs/revoke and real egress.

Install

pip install whisper-id

One runtime dependency (requests; add PySocks for SOCKS5 egress from the same process). Source: github.com/whisper-sec/whisper-py. The Node sibling is whisper-id on npm; the dependency-free edge build is whisper-edge.

Tier 1 — keyless: verify without an account

No sign-up, no key. Here is the identity check both ways — first in plain Python so you can see what's happening, then in one SDK call.

Raw Python — the same checks with dnspython and the standard library, no Whisper package:

import dns.resolver, dns.reversename, ssl, hashlib, socket, requests

addr = "2a04:2a01:f29c:f6c5:a98d:5d20:fd77:31de"                 # demo agent "nest-hero"

# 1. PTR: the /128 names itself
fqdn = str(dns.resolver.resolve(dns.reversename.from_address(addr), "PTR")[0]).rstrip(".")

# 2. forward-confirm: the name points back to the same /128
assert addr in {r.address for r in dns.resolver.resolve(fqdn, "AAAA")}

# 3. DANE: the TLS key is pinned in DNS (usage 3, selector 1, matching 1 — DANE-EE / SPKI / SHA-256)
tlsa = dns.resolver.resolve(f"_443._tcp.{fqdn}", "TLSA")[0]
der  = ssl.get_server_certificate((fqdn, 443))
spki = hashlib.sha256(ssl.PEM_cert_to_DER_cert(der)).digest()   # (SPKI extraction elided)
assert tlsa.cert == spki

That's the skeleton — but getting it right means enforcing the DNSSEC AD bit on every answer, extracting the SubjectPublicKeyInfo (not the whole cert) for the TLSA compare, and verifying the ES256 identity JWS. That fiddly part is what the SDK does for you:

With whisper-id:

from whisper_id import verify, verify_details, rdap

verify("2a04:2a01:f29c:f6c5:a98d:5d20:fd77:31de")               # -> True

verify_details("2a04:2a01:f29c:f6c5:a98d:5d20:fd77:31de")
# {"is_whisper_agent": True, "dane_ok": True, "jws_ok": True,
#  "evidence": {"ptr": "...", "aaaa": "...", "tlsa": "3 1 1 b653a4ef…"}}

rdap.ip("2a04:2a01:f29c:f6c5:a98d:5d20:fd77:31de")              # the RFC 9083 registry record

A target that isn't a Whisper agent returns a clean {"is_whisper_agent": False} — never a stack trace, never an opaque 500. verify() returns a bool; verify_details() returns the full evidence dict so you can log or re-derive any single step. Under the hood it walks the same chain proof-by-proof (PTR → forward AAAATLSA/DANE → DNSSEC AD=1 → signed JWS → transparency-log inclusion); the full walk is in Verify an agent.

Tier 2 — with a key: the control plane

The control plane is one Cypher verb over POST https://graph.whisper.security/api/query. Client is a thin typed wrapper — Python objects and exceptions instead of raw JSON and status codes.

Raw Python — the control plane is one HTTP POST:

import requests

r = requests.post("https://graph.whisper.security/api/query",
    headers={"X-API-Key": "whisper_live_…"},
    json={"query": "CALL whisper.agents({op:$op, args:$args})",
          "params": {"op": "register", "args": {"label": "shipping-bot"}}})
print(r.json())   # {"address": "2a04:2a01:…", "fqdn": "…agents.whisper.online", "api_key": "whisper_live_…"}

With whisper-id:

from whisper_id import Client

client = Client(api_key="whisper_live_…")          # or set WHISPER_API_KEY

agent = client.register(label="shipping-bot")
print(agent.address, agent.fqdn)

client.policy(agent="shipping-bot", block=["tor-exit", "newly-registered"], geo={"allow": "EU"})

for line in client.logs(agent="shipping-bot"):
    print(line.action, line.target, line.verdict)

client.revoke(agent="shipping-bot")                # tears down address, DNS, DANE pin, egress — one call

Each Client method maps 1:1 to an op in the control-plane reference — if you know the Cypher shape, you know the SDK.

Egress: source Python's own traffic from the agent's /128

client.connect() returns a ready proxy; point requests at it and every outbound connection leaves as that agent's address, not the host's.

from whisper_id import Client
import requests

client = Client(api_key="whisper_live_…")
egress = client.connect(agent="shipping-bot", tier="socks5")

requests.get("https://api.example.com/orders", proxies={"https": egress.connection_string})

# confirm the source address is the agent's own /128:
print(requests.get("https://rdap.whisper.online/egress-ip",
                   proxies={"https": egress.connection_string}).json())
# {"ip": "2a04:2a01:…<this agent's /128>…"}

tier="wireguard" returns a routed peer config instead (kernel or wireproxy), for binding the /128 at the OS level rather than per-request.

Running in AWS Lambda

Lambda's read-only filesystem rules out installing a WireGuard client — which is what the published layer is for: whisper-id, requests, and pure-wheel PySocks pre-built for python3.12/python3.13 on x86_64 and arm64.

arn:aws:lambda:eu-north-1:205639151085:layer:whisper-id-egress:1

Attach it, then use Client exactly as above:

import os
from whisper_id import Client

def handler(event, context):
    client = Client(api_key=os.environ["WHISPER_API_KEY"])
    egress = client.connect(agent=os.environ["WHISPER_AGENT"], tier="socks5")
    # requests.get(..., proxies={"https": egress.connection_string})
    return {"statusCode": 200}

Region notes and the layer-attach walkthrough: AWS Lambda.

Errors and rate limits

Keyless calls (verify, verify_details, rdap.*) need no key. Keyed calls without a valid X-API-Key raise whisper_id.AuthError; a bad op raises whisper_id.ControlError with the gateway's message attached — never a bare requests.HTTPError to unwrap. Trial accounts: 10 req/min, 500 req/day, up to 1000 agents.

Next