# 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](/docs/control-plane): no key still verifies any agent against the DNSSEC root; your key unlocks `register`/`connect`/`policy`/`logs`/`revoke` and real egress.

## Install

```bash
pip install whisper-id
```

Zero runtime dependencies - `verify`/`verify_details`/`rdap` are pure standard-library `urllib` calls, no `requests` needed. The control-plane and egress calls (`register`, `identity`, `policy`, `logs`, `revoke`, `agent`, `list_agents`, `egress`) drive the `whisper` CLI binary as a subprocess, so install that too (`curl -fsSL https://get.whisper.online | sh`, or set `WHISPER_BIN` to point at it) if you need anything past keyless verification. Source: [github.com/whisper-sec/whisper-py](https://github.com/whisper-sec/whisper-py). The Node sibling is `whisper-id` on npm; the dependency-free edge build is [`whisper-edge`](/docs/sdk-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:

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

addr = "2a04:2a01:b69a:6717:e3b0:51ff:3bf7:f478"                 # demo agent "scout"

# 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`:**

```python
from whisper_id import verify, verify_details, rdap

verify("2a04:2a01:b69a:6717:e3b0:51ff:3bf7:f478")               # -> True

verify_details("2a04:2a01:b69a:6717:e3b0:51ff:3bf7:f478")
# {"is_whisper_agent": True, "fqdn": "...", "dane_ok": True, "jws_ok": True,
#  "evidence": {"ptr": "...", "forward_aaaa": "...", "dane_tlsa_sha256": "9ec1ef18a1f15e54…", "dane": {...}}}

rdap("2a04:2a01:b69a:6717:e3b0:51ff:3bf7:f478")                 # the RFC 9083 registry record - keyless, CLI-free
```

A target that isn't a Whisper agent returns `None` from `verify_details()` (and `False` from `verify()`) - 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 `AAAA` → `TLSA`/DANE → DNSSEC `AD=1` → signed JWS → [transparency-log](/docs/transparency) inclusion); the full walk is in [Verify an agent](/docs/verify).

## Tier 2 - with a key: the control plane

The control plane is one Cypher verb over `POST https://graph.whisper.online/api/query`. `whisper-id` doesn't re-implement that HTTP call in Python - each control function is a thin wrapper that drives the `whisper` CLI as a subprocess (JSON out, parsed back into Python) and decodes its output, so behavior stays identical to the CLI by construction and there's exactly one client to keep in sync with the wire protocol.

**Raw Python** - what the wrapped call actually sends:

```python
import requests

r = requests.post("https://graph.whisper.online/api/query",
    headers={"X-API-Key": "whisper_live_…", "content-type": "application/json"},
    json={"query": "CALL whisper.agents({op:'register', args:{label:'shipping-bot'}})"})
print(r.json())   # the procedure-row envelope; result.rows holds address/fqdn/api_key/...
```

**With `whisper-id`** - module-level functions, not a client object; `WHISPER_API_KEY` (or a `whisper login`-saved key) is picked up automatically, or pass `key=` explicitly:

```python
from whisper_id import register, policy, logs, revoke

agent = register("shipping-bot")                   # op:identity via `whisper create --name`
print(agent.address, agent.name)                    # Agent has no .fqdn - look it up with agent()/rdap() if you need it

policy(block=["tor-exit", "newly-registered"])       # geography/routing policy isn't a param here yet - see /docs/resolver

for line in logs(agent=agent.address, kind="dns"):
    print(line["qname"], line["decision"])

revoke(agent.address)                                # tears down address, DNS, DANE pin, egress - one call
```

Pass `new_key=True` to `register()` to mint a brand-new agent with its own API key (`op:register`) instead of claiming the caller's own `/128` (`op:identity`, the default). Each function maps 1:1 to an `op` in the [control-plane reference](/docs/control-plane) - if you know the Cypher shape, you know the SDK.

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

`egress()` is a context manager: it brings up the local proxy (driving `whisper connect --ensure`, idempotent, shared across calls) and, by default, points the standard `HTTP_PROXY`/`HTTPS_PROXY`/`ALL_PROXY` env vars at it for the life of the block - so `requests` (and anything else that honors those vars) needs no explicit `proxies=` argument at all.

```python
from whisper_id import egress
import requests

with egress(agent="shipping-bot", tier="socks5") as e:
    requests.get("https://api.example.com/orders")           # picks up the env vars automatically

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

Set `set_env=False` if you only want the `Egress` handle (`.proxy_url`, `.socks_url`, `.proxies`) without mutating the process environment. `tier="wireguard"` brings up the routed Tier-1 tunnel instead, for binding the `/128` at the OS level rather than per-process.

## Running in AWS Lambda

`egress()` needs the `whisper` binary reachable on `PATH` (or `WHISPER_BIN`) at call time - Lambda's read-only filesystem means that binary has to arrive as part of a layer or the deployment package, alongside `whisper-id` itself; there is no separate crypto library to bundle since the SDK has zero pip dependencies of its own.

```python
import os, requests
from whisper_id import egress

def handler(event, context):
    with egress(agent=os.environ["WHISPER_AGENT"], tier="socks5") as e:
        requests.get("https://api.example.com/orders", proxies=e.proxies)
    return {"statusCode": 200}
```

Region notes and the layer-build walkthrough: [AWS Lambda](/docs/aws-lambda).

## Graph & cognition from Python

Beyond identity and control, `whisper_id.Graph` is a typed wrapper over the same [Graph & cognition](/docs/graph-api) verbs - `identify`, `assess`, `explain`, `variants`, `walk`, `origins`, `history`, and more, each keyless as a rate-limited taste (~100/window) and unlimited with a key - plus `run_flow()` for the multi-step recipe catalog (attack-path, subdomain-takeover, typosquat, and the rest) over server-sent events. `Graph().recipes()` lists the whole catalog with no network call at all.

```python
from whisper_id import Graph

g = Graph()                                    # keyless: identify/assess/explain/... at the rate-limited taste
g.identify("api.openai.com")                    # -> [{"host": "...", "canonical_name": "Cloudflare", ...}]

gk = Graph("whisper_live_…")                    # keyed: lifts the rate limit, unlocks run_flow/query/submit
gk.run_flow("typosquat", {"domain": "paypal.com"})
```

## Errors and rate limits

Keyless calls (`verify`, `verify_details`, `rdap`) need no key and raise nothing on a miss - they just return `None`/`False`. Every other failure - a missing key, a bad `op`, the `whisper` binary not found on `PATH`, a control-plane error - raises the single exception type `whisper_id.WhisperError` with the underlying CLI or gateway message attached, never a bare `requests.HTTPError` or `subprocess.CalledProcessError` to unwrap. Fleet size is capped per tenant (5 agents and 5 identities by default, raised on request - see [Control plane](/docs/control-plane) for the live numbers); `list_agents()`/`logs()` return the same shape at any scale, no separate pagination call to learn.

## Next

- [Control plane](/docs/control-plane) - every op the SDK's functions wrap
- [Edge SDK](/docs/sdk-edge) - the dependency-free build for Workers, Deno, and Vercel
