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

One runtime dependency (`requests`; add `PySocks` for SOCKS5 egress from the same process). 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: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`:**

```python
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 `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.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:

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

```python
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](/docs/control-plane) — 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.

```python
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:

```python
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](/docs/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

- [Control plane](/docs/control-plane) — every op the `Client` wraps
- [Edge SDK](/docs/sdk-edge) — the dependency-free build for Workers, Deno, and Vercel
