diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 34b1b83..d69f548 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -72,7 +72,7 @@ So reviewers can tell the change was actually verified:
- **Never** paste client secrets, admin tokens, or other credentials.
- If you cannot run integration tests (no broker, blocked network), say so **explicitly** in the PR and describe what you did verify. Maintainers may still ask for a re-run or a broker-backed check before merge.
-Demo work under [`demo/`](demo/) should follow the same rule: run against a real broker and describe how you tested.
+Demo work under [`demo/`](demo/README.md) (MedAssist) or [`demo2/`](demo2/README.md) (Support Tickets) should follow the same rule: run against a real broker and describe how you tested.
## Pull requests
diff --git a/README.md b/README.md
index db33cef..607ec06 100644
--- a/README.md
+++ b/README.md
@@ -14,276 +14,161 @@
- Ephemeral, task-scoped credentials for AI agents.
- Built on Ed25519 challenge-response and the Ephemeral Agent Credentialing v1.3 pattern.
+ The Python client for the AgentWrit broker — ephemeral, task-scoped credentials for AI agents.
- Why ·
- Install ·
- Prerequisites ·
- Quick Start ·
- Lifecycle ·
- MedAssist Demo ·
- Tickets Demo ·
- Scopes ·
- Delegation ·
- Errors ·
- Architecture ·
- Docs
+ Install ·
+ Quick start ·
+ Core ideas ·
+ Demos ·
+ Docs
---
-## Why AgentWrit?
+## 💡 Why you'd reach for this
-AI agents need credentials to access databases, APIs, and file systems. Most teams give agents shared API keys or inherit user permissions — both create over-privileged, long-lived, unauditable access. AgentWrit takes a different approach:
+Hand an AI agent a long-lived API key and a compromised agent becomes a compromised tenant — no expiry, no blast-radius, no audit. The AgentWrit broker issues short-lived JWTs scoped to one task per token instead. This SDK is the Python client: it registers agents, delegates scope, validates tokens, and cleans up when the task ends.
-- **Ephemeral identities** — every agent gets a unique Ed25519 keypair, generated in memory and never persisted to disk
-- **Task-scoped tokens** — credentials are limited to exactly what the agent needs (`read:data:customers`, not `read:*:*`)
-- **Short-lived by default** — tokens expire in minutes, not hours or days
-- **Delegation chains** — agents can delegate a subset of their permissions to other agents; the broker rejects any attempt to widen
+In five SDK lines:
-This SDK is the Python client for the [AgentWrit broker](https://github.com/devonartis/agentwrit) — the broker is the credential authority, and this SDK is how your Python code talks to it.
-
-## Installation
-
-Install from GitHub (not yet on PyPI):
-
-```bash
-uv add git+https://github.com/devonartis/agentwrit-python.git
+```python
+app = AgentWritApp(broker_url, client_id, client_secret)
+agent = app.create_agent("my-service", "task-1", ["read:data:customer-7291"])
+httpx.get("https://api/customers/7291", headers=agent.bearer_header)
+validate(app.broker_url, agent.access_token) # any service can verify
+agent.release() # token dies at the broker
```
-Or with pip:
-
-```bash
-pip install git+https://github.com/devonartis/agentwrit-python.git
-```
+---
-For local development:
+## 📦 Install
```bash
-git clone https://github.com/devonartis/agentwrit-python.git
-cd agentwrit-python
-uv sync --all-extras
+uv add agentwrit # or: pip install agentwrit
```
-**Requirements:** Python 3.10+. The SDK also needs a broker and credentials — see [Prerequisites](#prerequisites).
-
-## Prerequisites
-
-The SDK is a client. It does **not** run the broker, and it does **not** mint its own credentials. Before any code in [Quick Start](#quick-start) will work, you need three things:
+Requires **Python 3.10+**. The SDK pulls in `httpx` and `cryptography` automatically.
-**1. A reachable AgentWrit broker.**
-The broker is a separate service that issues and validates tokens.
+> ⚠️ **The SDK is synchronous.** v0.3.0 uses `httpx`'s sync client. On FastAPI, Starlette, or Sanic, wrap SDK calls in `asyncio.to_thread(...)` so they don't block the event loop — see the [Developer Guide](docs/developer-guide.md#async--await-support).
-- *Have a platform team running one?* Ask them for the broker URL.
-- *Running it yourself?* Stand one up locally — the [broker repo](https://github.com/devonartis/agentwrit) ships a `docker compose` setup. From this repo:
- ```bash
- docker compose up -d # pulls devonartis/agentwrit from Docker Hub
- ```
-
-**2. App credentials (`client_id` + `client_secret`).**
-These are issued by the **broker operator/admin** when they register your app and set its scope ceiling. The SDK cannot create them for you.
-
-- *Have a broker admin?* Ask them to register your app and send you the `client_id` and `client_secret`.
-- *You are the admin?* Use the included setup script (it registers an app and prints both values):
- ```bash
- export AGENTWRIT_ADMIN_SECRET=""
- uv run python demo/setup.py
- ```
-
-**3. Environment variables set** on the process that uses the SDK:
-```bash
-export AGENTWRIT_BROKER_URL="http://localhost:8080" # from step 1
-export AGENTWRIT_CLIENT_ID=""
-export AGENTWRIT_CLIENT_SECRET=""
-```
-
-> Auth is lazy — the SDK doesn't talk to the broker until your first `create_agent()` call. If that call raises `AuthenticationError`, your `client_id` or `client_secret` is wrong (or the operator rotated them). If it raises `TransportError`, the broker URL is unreachable.
+---
-## Quick Start
+## ⚡ Quick start
-> Assumes [Prerequisites](#prerequisites) are met — broker reachable, app registered, env vars set.
+> **Prerequisites.** The SDK is a client. You need a reachable broker, app credentials (`client_id` + `client_secret` from the broker operator), and three env vars set. If you're starting from zero, [Getting Started](docs/getting-started.md) walks you through all three in about five minutes.
```python
import os
+import httpx
from agentwrit import AgentWritApp, validate
-# Connect to the broker (lazy — no auth until first create_agent)
app = AgentWritApp(
broker_url=os.environ["AGENTWRIT_BROKER_URL"],
- client_id=os.environ["AGENTWRIT_CLIENT_ID"], # from broker admin
- client_secret=os.environ["AGENTWRIT_CLIENT_SECRET"], # from broker admin
+ client_id=os.environ["AGENTWRIT_CLIENT_ID"],
+ client_secret=os.environ["AGENTWRIT_CLIENT_SECRET"],
)
-# Create an agent with specific scope
agent = app.create_agent(
orch_id="my-service",
task_id="read-customer-data",
- requested_scope=["read:data:customers"],
+ requested_scope=["read:data:customer-7291"],
)
-# Use the token as a Bearer credential
-import httpx
-resp = httpx.get(
- "https://your-api/data/customers",
- headers=agent.bearer_header,
-)
+# Use the JWT as a Bearer credential anywhere
+resp = httpx.get("https://your-api/data/customers", headers=agent.bearer_header)
-# Validate the token (any service can do this)
+# Any service can verify a token — no AgentWritApp needed, just the broker URL
result = validate(app.broker_url, agent.access_token)
-print(result.claims.scope) # ['read:data:customers']
+print(result.claims.scope) # ['read:data:customer-7291']
-# Release when done — token is dead immediately
+# Release the moment the task is done
agent.release()
```
-## Agent Lifecycle
-
-```python
-# Create — agent gets a SPIFFE identity and scoped JWT
-agent = app.create_agent(orch_id="svc", task_id="task", requested_scope=["read:data:x"])
+One agent, one scope, one token used, one token killed.
-# Use — agent.access_token is a standard Bearer JWT
-print(agent.agent_id) # spiffe://agentwrit.local/agent/svc/task/a1b2c3d4
-print(agent.scope) # ['read:data:x']
-print(agent.expires_in) # 300 (seconds)
-
-# Renew — new token, same identity, old token revoked
-agent.renew()
-
-# Delegate — pass a subset of scope to another agent (equal or narrower)
-delegated = agent.delegate(delegate_to=other.agent_id, scope=["read:data:x"])
-
-# Release — self-revoke, idempotent
-agent.release()
-```
+---
-## MedAssist AI Demo
+## 🧠 Core ideas
-The [`demo/`](demo/) directory contains **MedAssist AI** — an interactive healthcare demo that showcases every AgentWrit capability against a live broker.
+Everything else in the SDK builds on these five.
-**What it does:** A FastAPI web app where you enter a patient ID and a plain-language request. A local LLM (OpenAI-compatible) chooses which tools to call, and the app dynamically creates broker agents with only the scopes those tools need for that specific patient. Every step — scope enforcement, cross-patient denial, delegation, token renewal, release — appears in a real-time execution trace.
+### Ephemeral identities
-**What it demonstrates:**
+Each agent gets a unique Ed25519 keypair (generated in-memory by default — pass `private_key=` if you need a custom source) and a SPIFFE ID like `spiffe://agentwrit.local/agent/my-service/task-001/a1b2c3d4`. The ID is preserved across `renew()`, discarded after `release()`.
-| Capability | How the demo shows it |
-|------------|----------------------|
-| **Dynamic agent creation** | Agents spawn on demand as the LLM selects tools — clinical, billing, prescription |
-| **Per-patient scope isolation** | Each agent's scopes are parameterized to one patient ID |
-| **Cross-patient denial** | LLM asks for another patient's records → `scope_denied` in the trace |
-| **Delegation** | Clinical agent delegates `write:prescriptions:{patient}` to the prescription agent |
-| **Token lifecycle** | Renewal and release shown at end of each encounter |
-| **Audit trail** | Dedicated audit tab showing hash-chained broker events |
+### Three-segment scopes
-### Running with Docker (recommended)
+Scopes are `action:resource:identifier`. Wildcards only live in the identifier slot.
-```bash
-AGENTWRIT_ADMIN_SECRET="your-secret" \
-LLM_API_KEY="your-llm-key" \
-docker compose up -d broker medassist
+```
+read:data:customer-7291 ✓ specific
+read:data:* ✓ any identifier
+read:*:customers ✗ wildcard in resource — rejected
+*:data:customers ✗ wildcard in action — rejected
```
-Open [http://localhost:5000](http://localhost:5000). The demo auto-registers with the broker on startup — no manual setup needed. You only need an OpenAI-compatible LLM endpoint (set `LLM_BASE_URL` and `LLM_MODEL` if not using OpenAI).
-
-### Running from source
-
-```bash
-# 1. Start the broker
-docker compose up -d broker
-
-# 2. Register the demo app (one-time)
-export AGENTWRIT_ADMIN_SECRET="your-admin-secret"
-uv run python demo/setup.py
+Use `scope_is_subset(required, held)` to gate every action in your app.
-# 3. Configure demo/.env (copy from demo/.env.example)
-cp demo/.env.example demo/.env
+### Delegation only narrows
-# 4. Run it
-uv run uvicorn demo.app:app --reload --port 5000
-```
+`agent.delegate()` accepts equal or narrower scope and refuses to widen. Max chain depth is 5. If a delegator tries to hand over authority it doesn't hold, the broker returns `403 scope_violation`.
-For architecture diagrams and a live presentation script, see [`demo/BEGINNERS_GUIDE.md`](demo/BEGINNERS_GUIDE.md) and [`demo/PRESENTERS_GUIDE.md`](demo/PRESENTERS_GUIDE.md).
+### Trust the token, not the object
-## Support Ticket Demo
+`agent.scope` reflects what you *requested* — useful for gating inside your own process, but client-side. The authoritative, cryptographically signed scope lives in the JWT claims and surfaces via `validate(broker_url, token).claims.scope`. Never make a security decision off `agent.scope` alone.
-The [`demo2/`](demo2/) directory contains **AgentWrit Live** — a support ticket pipeline where three LLM-driven agents (triage, knowledge, response) process customer requests under zero-trust scoped credentials.
+### Revocable at four levels
-```bash
-AGENTWRIT_ADMIN_SECRET="your-secret" \
-LLM_API_KEY="your-llm-key" \
-docker compose up -d broker support-tickets
-```
+One token, one agent, one task, or an entire delegation chain — killed on demand by the operator or the agent itself.
-Open [http://localhost:5001](http://localhost:5001).
+→ Full model, scope gotchas, and trust chain in **[Concepts](docs/concepts.md)**.
-| Capability | How the demo shows it |
-|------------|----------------------|
-| **Identity-gated pipeline** | Anonymous tickets halt at triage — no customer-scoped agents spawn |
-| **Per-customer scope isolation** | Each agent is scoped to the verified customer only |
-| **Cross-customer denial** | Asking about another customer's data → scope denied |
-| **Tool-level enforcement** | `delete_account` and `send_external_email` blocked by scope |
-| **Natural token expiry** | 5-second TTL credential expires on its own |
+---
-## Scope Format
+## 🎬 Demos
-Scopes are three segments: `action:resource:identifier`
+Two complete apps ship with the repo. Both have splash pages that frame what you're looking at before you dive into code.
-```
-read:data:customers — read customer data
-write:data:order-abc-123 — write to a specific order
-read:data:* — wildcard: read ANY data resource
-```
+### 🏥 MedAssist — healthcare walkthrough
-Wildcard `*` only works in the identifier (third) position. Action and resource must match exactly.
+A FastAPI clinical assistant. You ask a plain-language question about a patient; an LLM picks tools (records, labs, billing, prescriptions); the app spawns broker agents on demand, each scoped to *one patient and one category*. Cross-patient questions are denied. Prescription writes flow through a delegation chain.
-```python
-from agentwrit import scope_is_subset
+**[demo/README.md](demo/README.md)** — run instructions, scenario playbook, code map.
-scope_is_subset(["read:data:customers"], ["read:data:*"]) # True
-scope_is_subset(["write:data:customers"], ["read:data:*"]) # False (write != read)
-scope_is_subset(["read:logs:customers"], ["read:data:*"]) # False (logs != data)
-```
-
-## Delegation
+### 🎫 Support Tickets — three-agent pipeline
-Agents delegate a subset of their scope to other agents. Delegation cannot widen authority — equal or narrower scope is accepted; any scope the delegator doesn't hold is rejected.
+Flask + HTMX + SSE. Three LLM-driven agents (triage → knowledge → response) process customer tickets. Anonymous tickets halt at triage. Dangerous tools (`delete_account`, `send_external_email`) are in the LLM's tool list but not in the agent's scope — so they never execute. One scenario deliberately skips `release()` to watch a 5-second TTL die on its own.
-```python
-# A has broad scope
-agent_a = app.create_agent(
- orch_id="pipeline", task_id="orchestrator",
- requested_scope=["read:data:partition-7", "read:data:partition-8"],
-)
+**[demo2/README.md](demo2/README.md)** — run instructions, five scenarios, code map.
-# A delegates ONLY partition-7 to B
-delegated = agent_a.delegate(
- delegate_to=agent_b.agent_id,
- scope=["read:data:partition-7"],
-)
+---
-# Validate: delegated token has only partition-7
-result = validate(app.broker_url, delegated.access_token)
-print(result.claims.scope) # ['read:data:partition-7']
-```
+## ⚠️ Errors
-## Error Handling
+The broker returns RFC 7807 problem details. The SDK parses them into structured exceptions:
```python
from agentwrit.errors import AuthorizationError, TransportError
try:
- agent = app.create_agent(orch_id="svc", task_id="t", requested_scope=scope)
+ agent = app.create_agent(...)
except AuthorizationError as e:
- print(e.status_code) # 403
- print(e.problem.detail) # "scope exceeds app ceiling"
- print(e.problem.error_code) # "scope_violation"
+ print(e.status_code) # 403
+ print(e.problem.detail) # "scope exceeds app ceiling"
+ print(e.problem.error_code) # "scope_violation"
+ print(e.problem.request_id) # matches broker X-Request-ID for log correlation
except TransportError:
print("Broker unreachable")
```
-## Architecture
+→ Full hierarchy and every `ProblemDetail` field: **[Developer Guide › Error handling](docs/developer-guide.md#error-handling)**.
+
+---
+
+## 🏗️ Architecture
```mermaid
graph TB
@@ -302,8 +187,9 @@ graph TB
Agents["Your AI Agents"]
APIs["Protected APIs"]
- Client ==>|"HTTPS"| Broker
+ Client ==>|"app session"| Broker
Client -.->|"create_agent()"| Agents
+ Agents -.->|"renew / release / delegate (agent JWT)"| Broker
Agents ==>|"Bearer JWT"| APIs
style App fill:#dbeafe,stroke:#3b82f6,stroke-width:2px,color:#1e3a5f
@@ -312,61 +198,38 @@ graph TB
style APIs fill:#ede9fe,stroke:#8b5cf6,stroke-width:2px
```
-## Authority Chain
+**Authority narrows at every hop.** Operator sets the app's scope ceiling → app creates agents inside that ceiling → each agent can delegate equal or narrower, up to five hops deep.
-```
-Operator (root of trust)
- │ registers app, sets scope ceiling
- ▼
-Application (your code — AgentWritApp)
- │ creates agents within ceiling
- ▼
-Agent (ephemeral SPIFFE identity + scoped JWT)
- │ delegation cannot widen scope (equal or narrower allowed)
- ▼
-Delegated Agent (sub-agent, max 5 hops)
-```
-
-## Documentation
-
-| Guide | Description |
-|-------|-------------|
-| [Concepts](docs/concepts.md) | Roles, scopes, delegation, trust model, and standards |
-| [Getting Started](docs/getting-started.md) | Install, connect, and create your first agent |
-| [Developer Guide](docs/developer-guide.md) | Delegation patterns, scope gating, error handling |
-| [API Reference](docs/api-reference.md) | Every class, method, parameter, and exception |
-| [Testing Guide](docs/testing-guide.md) | Unit tests, integration tests, running the test suite |
+---
-For broker setup and administration, see the [AgentWrit broker documentation](https://github.com/devonartis/agentwrit/tree/main/docs).
+## 📚 Documentation
-## Standards Alignment
+| I want to… | Go to |
+|-|-|
+| Run my first agent in 5 minutes | [Getting Started](docs/getting-started.md) |
+| Understand roles, scopes, delegation, trust | [Concepts](docs/concepts.md) |
+| Write real code — renewal loops, scope gating, errors | [Developer Guide](docs/developer-guide.md) |
+| Look up every class, method, exception | [API Reference](docs/api-reference.md) |
+| Run unit, integration, and acceptance tests | [Testing Guide](docs/testing-guide.md) |
+| Study eight focused example apps | [Sample Apps](docs/sample-apps/README.md) |
-| Standard | What it addresses |
-|----------|-------------------|
-| **NIST IR 8596** | Unique AI agent identities via SPIFFE IDs |
-| **NIST SP 800-207** | Zero-trust per-request validation |
-| **OWASP Top 10 for Agentic AI (2026)** | ASI03 (Identity/Privilege Abuse), ASI07 (Insecure Inter-Agent Communication) |
-| **IETF WIMSE** | Delegation chain re-binding |
-| **IETF draft-klrc-aiagent-auth-00** | OAuth/WIMSE/SPIFFE framework for AI agents |
+For broker setup, admin APIs, or operator workflows see the **[broker repo](https://github.com/devonartis/agentwrit)** — it's a separate project.
-## Contributing
+---
-See **[CONTRIBUTING.md](CONTRIBUTING.md)** for the full workflow: `uv` setup, **live-broker** verification (clone [agentwrit](https://github.com/devonartis/agentwrit) or use your own broker), and **evidence to include in PRs** so maintainers can review broker-facing changes confidently.
+## 🤝 Contributing
-Quick local checks (no broker required for unit tests):
+See **[CONTRIBUTING.md](CONTRIBUTING.md)** for `uv` setup, the live-broker requirement, and the evidence maintainers need before merging broker-facing changes.
```bash
-git clone https://github.com/devonartis/agentwrit-python.git
-cd agentwrit-python
uv sync --all-extras
-
uv run ruff check .
uv run mypy --strict src/
uv run pytest tests/unit/
```
-## License
+---
-This SDK is licensed under the [MIT License](LICENSE).
+## 📄 License
-The [AgentWrit broker](https://github.com/devonartis/agentwrit) is licensed separately under PolyForm Internal Use 1.0.0. See the broker repo for details.
+MIT. See [LICENSE](LICENSE). The [AgentWrit broker](https://github.com/devonartis/agentwrit) is licensed separately under PolyForm Internal Use 1.0.0.
diff --git a/demo/README.md b/demo/README.md
new file mode 100644
index 0000000..005542d
--- /dev/null
+++ b/demo/README.md
@@ -0,0 +1,144 @@
+MedAssist AI — the healthcare walkthrough
+
+
+ A working FastAPI app that shows every AgentWrit capability against a live broker —
+ dynamic agents, per-patient scope isolation, cross-patient denial, delegation, renewal, release, and a tamper-evident audit trail.
+
+
+
+ What it is ·
+ Why it exists ·
+ What you'll see ·
+ Run it ·
+ How it works ·
+ Code map ·
+ More
+
+
+---
+
+## What it is
+
+MedAssist AI is a small clinical-assistant app. You type a patient ID and a plain-language question. An LLM decides which tools to call (records, labs, billing, prescriptions). The app spawns broker-backed agents on demand, each scoped to **one patient and one category of work**, and every step shows up in a live execution trace — scope checks, denials, delegations, renewals, release.
+
+If you've ever wondered *"what does short-lived, task-scoped, per-user credentialing actually look like in a real app?"* — this is that app.
+
+## Why it exists
+
+Reading about ephemeral credentials is one thing. Watching three agents get spawned, one of them get denied mid-request because it asked about the wrong patient, and then seeing the whole chain die when the encounter ends — that's what makes the pattern stick.
+
+We built MedAssist AI because:
+
+- **Beginners need a story.** "Scoped JWTs" is abstract. "The clinical agent can only read Patient 1042's records, and when it tries Patient 2187 the broker says no" is concrete.
+- **Reviewers need evidence.** The audit tab shows a hash-chained ledger of every broker event, which is what a security reviewer wants to see before approving production use.
+- **Contributors need a reference.** Every SDK feature — `create_agent`, `validate`, `delegate`, `renew`, `release`, `scope_is_subset` — is wired in here, used the way it's meant to be used.
+
+## What you'll see
+
+| Capability | What the demo does |
+|-----------|--------------------|
+| **Dynamic agent creation** | Agents spawn as the LLM picks tools. No pre-allocated pool. |
+| **Per-patient scope isolation** | Each agent's scope contains one patient ID and nothing else. |
+| **Cross-patient denial** | Ask about another patient mid-encounter. The scope check fails. The trace shows `scope_denied`. |
+| **Delegation with attenuation** | The clinical agent delegates `write:prescriptions:{patient}` to the prescription agent. The broker refuses to widen. |
+| **Token lifecycle** | `renew()` issues a fresh token under the same SPIFFE identity. `release()` kills the token immediately. |
+| **Audit trail** | A dedicated tab shows every broker event in a hash chain that can't be retroactively altered. |
+
+The trace panel in the UI is the point. Every capability surfaces as a line in the trace so you can read the whole story of one request.
+
+## Run it
+
+### Option A — Docker (recommended)
+
+One command, no Python setup:
+
+```bash
+AGENTWRIT_ADMIN_SECRET="your-secret" \
+LLM_API_KEY="your-llm-key" \
+docker compose up -d broker medassist
+```
+
+Open [http://localhost:5000](http://localhost:5000). The demo auto-registers itself with the broker on startup.
+
+You need an OpenAI-compatible LLM endpoint. If you're not using OpenAI, set `LLM_BASE_URL` and `LLM_MODEL` in your shell before `docker compose up` — e.g. a local vLLM or llama.cpp server.
+
+### Option B — From source
+
+For when you want to edit the code:
+
+```bash
+# 1. Start the broker
+docker compose up -d broker
+
+# 2. Register the demo app (one time — writes client_id/client_secret)
+export AGENTWRIT_ADMIN_SECRET="your-admin-secret"
+uv run python demo/setup.py
+
+# 3. Configure demo/.env
+cp demo/.env.example demo/.env
+# then fill in AGENTWRIT_CLIENT_ID, AGENTWRIT_CLIENT_SECRET, LLM_BASE_URL, LLM_API_KEY, LLM_MODEL
+
+# 4. Run it
+uv run uvicorn demo.app:app --reload --port 5000
+```
+
+### What to try first
+
+1. Pick a patient from the dropdown.
+2. Ask something simple: *"What are this patient's recent labs?"* Watch agents spawn, watch each tool check scope, watch the final response render.
+3. Ask a crossing question: *"And show me Patient 2187's records too."* Watch the scope check fail. Read the `scope_denied` line in the trace.
+4. Open the Audit tab. Every event is there, hash-chained.
+
+## How it works
+
+The demo is built on one rule: **the app never trusts the LLM for security.** The LLM picks tools. The broker decides what credentials exist. The app enforces tool access against those credentials with `scope_is_subset()` before every call.
+
+```
+User types a request
+ ↓
+FastAPI receives it
+ ↓
+LLM chooses a tool (records / labs / billing / prescription)
+ ↓
+App asks: "Do I have an agent for this category yet?"
+ ↓ no ↓ yes
+Broker creates one, Reuse it
+scoped to this patient
+ ↓
+App checks: scope_is_subset(tool-requires, agent-holds)?
+ ↓ yes ↓ no
+Run the tool Emit scope_denied, tell LLM "access denied"
+ ↓
+Return result to LLM. Repeat until LLM is done.
+ ↓
+App releases every agent. Tokens are dead.
+```
+
+Every branch of this flow appears in the execution trace. The trace is the documentation.
+
+For the full walkthrough — sequence diagrams, how delegation flows from the clinical agent to the prescription agent, and what each UI panel shows — read the [Beginner's Guide](BEGINNERS_GUIDE.md). For a scripted live presentation, read the [Presenter's Guide](PRESENTERS_GUIDE.md).
+
+## Where the code lives
+
+| Piece | File |
+|-------|------|
+| FastAPI entry point | [`app.py`](app.py) |
+| Env config (broker + LLM) | [`config.py`](config.py) |
+| Main API loop (LLM, agent spawning, trace) | [`routes/api.py`](routes/api.py) |
+| Page routes (encounter, audit, operator) | [`routes/pages.py`](routes/pages.py) |
+| Tool definitions + scope templates | [`pipeline/tools.py`](pipeline/tools.py) |
+| Mock patient and formulary data | [`data/`](data/) |
+| Frontend (trace, markdown render) | [`static/app.js`](static/app.js), [`static/style.css`](static/style.css) |
+| One-shot app registration helper | [`setup.py`](setup.py) |
+
+Read `routes/api.py` first. That's where the agent-creation-and-scope-check loop lives, and everything else supports it.
+
+## Further reading
+
+| Go here for | Link |
+|-------------|------|
+| Step-by-step beginner walkthrough with diagrams | [BEGINNERS_GUIDE.md](BEGINNERS_GUIDE.md) |
+| Live presentation script (timing, transitions) | [PRESENTERS_GUIDE.md](PRESENTERS_GUIDE.md) |
+| SDK concepts (roles, scopes, delegation) | [../docs/concepts.md](../docs/concepts.md) |
+| Building real apps with the SDK | [../docs/developer-guide.md](../docs/developer-guide.md) |
+| Broker API (source of truth) | [AgentWrit broker docs](https://github.com/devonartis/agentwrit/tree/main/docs) |
diff --git a/demo2/README.md b/demo2/README.md
new file mode 100644
index 0000000..3be2871
--- /dev/null
+++ b/demo2/README.md
@@ -0,0 +1,160 @@
+AgentWrit Live — the support-ticket pipeline
+
+
+ A zero-trust support desk where three LLM-driven agents — triage, knowledge, response — process customer tickets
+ under broker-issued credentials that are scoped to one verified customer and die the moment the work ends.
+
+
+
+ What it is ·
+ Why it exists ·
+ What you'll see ·
+ Run it ·
+ How it works ·
+ Scenarios ·
+ Code map
+
+
+---
+
+## What it is
+
+A Flask app with HTMX and server-sent events. You submit a customer-support ticket in plain English. Three agents run in sequence:
+
+1. **Triage** reads the ticket, extracts who the customer is, classifies priority and category.
+2. **Knowledge** searches the internal KB for the policies that apply.
+3. **Response** drafts a reply and calls whatever tools it needs to resolve the ticket — pulling balances, writing case notes, issuing refunds.
+
+Every agent holds its own broker-issued JWT, scoped to exactly one customer and the actions that agent legitimately needs. When the agent is done, its token is released and dead. When an LLM asks for something outside scope — another customer's data, a dangerous tool — the scope check blocks it before the call ever runs.
+
+## Why it exists
+
+MedAssist (in [`demo/`](../demo/README.md)) shows what one request looks like end-to-end. This demo shows something different: **a real multi-step pipeline where identity gating and tool-level enforcement both matter.**
+
+Three things are hard to see in a simpler demo:
+
+- **Identity gating.** If triage can't verify the customer, the pipeline halts. No customer-scoped credentials are ever minted for an anonymous request. This is the pattern that prevents "please delete my account" from going through when the system doesn't know who "my" is.
+- **Tool-level enforcement beyond data.** The response agent has tools it can pick from (`delete_account`, `send_external_email`) that aren't in its scope. The scope check denies them at the app, before the tool runs. The broker never sees them.
+- **Natural expiry.** One scenario deliberately skips `release()`. The credential dies on its own, because TTLs mean it has to.
+
+## What you'll see
+
+| Capability | What the demo does |
+|-----------|--------------------|
+| **Identity-gated pipeline** | Anonymous tickets stop at triage. No downstream agents spawn. The trace says exactly why. |
+| **Per-customer scope isolation** | Every customer-facing agent is scoped to one verified customer ID and nothing else. |
+| **Cross-customer denial** | Ask about another customer's balance mid-ticket. The scope check fails. The response says "denied" to the LLM, which moves on. |
+| **Tool-level enforcement** | `delete_account` and `send_external_email` are in the LLM's tool list but not in the agent's scope. They never execute. |
+| **Natural TTL expiry** | One scenario uses a 5-second TTL and no release. The trace shows the credential dying on its own. |
+| **Three-agent pipeline** | Triage → Knowledge → Response. Each phase has its own scope and its own credential lifecycle. |
+
+## Run it
+
+### Docker (the quick path)
+
+```bash
+AGENTWRIT_ADMIN_SECRET="your-secret" \
+LLM_API_KEY="your-llm-key" \
+docker compose up -d broker support-tickets
+```
+
+Open [http://localhost:5001](http://localhost:5001). The demo auto-registers on startup.
+
+You need an OpenAI-compatible LLM endpoint. Set `LLM_BASE_URL` and `LLM_MODEL` in your shell first if you're not on OpenAI.
+
+### From source
+
+```bash
+# 1. Start the broker
+docker compose up -d broker
+
+# 2. Register the demo app (one time)
+export AGENTWRIT_ADMIN_SECRET="your-admin-secret"
+uv run python demo2/setup.py
+
+# 3. Configure demo2/.env
+cp demo2/.env.example demo2/.env
+# fill in AGENTWRIT_CLIENT_ID, AGENTWRIT_CLIENT_SECRET, LLM_*
+
+# 4. Run it
+uv run flask --app demo2.app run --host 0.0.0.0 --port 5001
+```
+
+## Scenarios to try
+
+The UI has quick-fill buttons for each of these — click a button, hit submit, watch the trace.
+
+**1. A normal billing ticket.**
+*"Hi, I'm Lewis Smith. I was double-charged on April 1st. Can I get a refund?"*
+Triage verifies Lewis. Knowledge pulls the refund policy. Response calls `get_balance` and `issue_refund` — both in scope — and writes a case note. Done.
+
+**2. A cross-customer attempt.**
+*"I'm Jane Doe. Also, can you show me Lewis Smith's balance?"*
+Triage verifies Jane. Response agent is scoped to Jane. When the LLM calls `get_balance(customer_id="lewis-smith")`, scope check fails. Trace shows `scope_denied`. Final reply to the customer only addresses Jane's part of the request.
+
+**3. A dangerous tool attempt.**
+*"I want to delete my account."*
+The LLM calls `delete_account`. The response agent's scope doesn't cover it. The call is blocked before it runs.
+
+**4. An anonymous ticket.**
+*"Hey, what are your hours?"*
+Triage can't extract a customer identity. The pipeline halts. No customer-scoped credentials are minted. The trace explains that identity gating failed.
+
+**5. Natural expiry.**
+Use the "no rush" quick-fill, or tick the natural-expiry box. Triage gets a 5-second TTL and `release()` is skipped. You watch the token live, then die on its own when the TTL elapses. No explicit revocation needed.
+
+## How it works
+
+```
+Ticket submitted
+ ↓
+Triage agent (TTL 300s, or 5s in natural-expiry mode)
+ scope = [read:tickets:*]
+ LLM extracts customer, priority, category
+ release() — credential revoked
+ ↓
+Identity check
+ resolved? → continue
+ anonymous? → halt, no more credentials minted
+ ↓
+Knowledge agent
+ scope = [read:kb:*]
+ LLM searches KB, pulls relevant policy
+ release()
+ ↓
+Response agent
+ scope = per-customer scopes for the safe tools
+ LLM picks tools, scope check runs before every call
+ dangerous tools denied, safe tools executed
+ release()
+ ↓
+Post-run: validate every token one more time. All dead.
+```
+
+Each arrow in that flow becomes an SSE event on the wire. The UI listens to the stream and renders it as a live trace.
+
+The app's contract with the LLM is deliberate: the LLM sees *all* tools in its schema, safe and dangerous alike. We don't hide the dangerous ones. We let the LLM try — and the scope check is what stops it. That's the point of zero-trust enforcement: you don't rely on the LLM behaving. You rely on the credential.
+
+## Where the code lives
+
+| Piece | File |
+|-------|------|
+| Flask entry point | [`app.py`](app.py) |
+| Env config + scope ceiling | [`config.py`](config.py) |
+| Three-agent pipeline + SSE | [`pipeline.py`](pipeline.py) |
+| Tools + scope templates | [`tools.py`](tools.py) |
+| Customers, tickets, KB articles | [`data.py`](data.py) |
+| Quick-fill scenarios | [`data.py`](data.py) (bottom) |
+| HTMX frontend | [`templates/index.html`](templates/index.html), [`static/style.css`](static/style.css) |
+| One-shot app registration | [`setup.py`](setup.py) |
+
+Read `pipeline.py` first. The three-phase flow — triage, knowledge, response — is one top-to-bottom function, and every SSE event you see in the UI is a `yield` statement in that file.
+
+## Further reading
+
+| Go here for | Link |
+|-------------|------|
+| The other demo (clinical / per-patient, single-request) | [`../demo/README.md`](../demo/README.md) |
+| SDK concepts (roles, scopes, delegation) | [`../docs/concepts.md`](../docs/concepts.md) |
+| Real-world patterns for your own apps | [`../docs/developer-guide.md`](../docs/developer-guide.md) |
+| Broker API | [AgentWrit broker docs](https://github.com/devonartis/agentwrit/tree/main/docs) |
diff --git a/docs/api-reference.md b/docs/api-reference.md
index a390f92..1816a7d 100644
--- a/docs/api-reference.md
+++ b/docs/api-reference.md
@@ -129,11 +129,13 @@ An ephemeral agent created by `AgentWritApp.create_agent()`. Holds the agent JWT
| `agent_id` | `str` | SPIFFE URI (e.g., `spiffe://agentwrit.local/agent/orch/task/instance`) |
| `access_token` | `str` | JWT string (EdDSA-signed) |
| `expires_in` | `int` | Token TTL in seconds (snapshot from creation or last renewal) |
-| `scope` | `list[str]` | Granted scope list |
+| `scope` | `list[str]` | Scope the agent *requested* at creation. See note below. |
| `orch_id` | `str` | Orchestrator identifier |
| `task_id` | `str` | Task identifier |
| `bearer_header` | `dict[str, str]` | `{"Authorization": "Bearer "}` for HTTP requests |
+> **`agent.scope` is the requested scope, not the broker's signed answer.** The broker only accepts a registration whose scope is covered by the launch token, so in practice the two match. But when making a security-critical decision in a downstream service, don't trust a client-side field — call `validate(app.broker_url, agent.access_token)` and read `result.claims.scope`.
+
### renew()
```python
diff --git a/docs/concepts.md b/docs/concepts.md
index 8e3e4af..ea1a2d5 100644
--- a/docs/concepts.md
+++ b/docs/concepts.md
@@ -21,6 +21,16 @@ These approaches share three failures:
2. Credentials are too broad
3. No way to trace what each agent did
+### An analogy for the shape of the fix
+
+Every office building has three kinds of keys:
+
+- **The master key** lives with the building manager. It opens everything, all the time. If it goes missing, every tenant has to rekey their locks. That's a shared service-account credential.
+- **The tenant key** lets a person into the whole floor their company leases. It lasts as long as they work there. That's a long-lived user credential the agent inherits.
+- **The visitor badge** is printed at reception the morning you arrive. It opens one conference room, for four hours, and it's logged on the way in and the way out. Lose it in the parking garage and nothing bad happens — it's already expired.
+
+AgentWrit is the visitor badge. The broker prints one per task, scopes it to exactly one action-and-resource-and-identifier, expires it in minutes, and leaves a tamper-evident record of every time it was used.
+
---
## The Three Roles
@@ -69,6 +79,8 @@ agent = app.create_agent(
)
```
+> **Trust the token, not the object.** `agent.scope` is populated from the scope you *requested* — it's useful for gating calls inside your own process, but it is a client-side field, not the broker's cryptographically-signed answer. The authoritative scope lives in the JWT claims, retrieved via `validate(app.broker_url, agent.access_token)`. If you're making a security decision (for example, enforcing a privileged operation in a downstream service), always validate. This is the same zero-trust posture that keeps a compromised agent from forging its own authority.
+
### The Authority Chain
```
@@ -281,7 +293,7 @@ other_customer = "customer-9999"
scope_is_subset([f"read:data:{other_customer}"], agent.scope) # False
```
-This is the app's responsibility. The broker sets the scope at creation time, but the app must enforce it before every action. The MedAssist demo shows this pattern end-to-end: each tool declares a scope template (e.g. `"read:records:{patient_id}"`), and the pipeline resolves it with the real patient ID at runtime — see `demo/pipeline/tools.py` for the implementation.
+This is the app's responsibility. The broker sets the scope at creation time, but the app must enforce it before every action. The [MedAssist demo](../demo/README.md) shows this pattern end-to-end: each tool declares a scope template (e.g. `"read:records:{patient_id}"`), and the pipeline resolves it with the real patient ID at runtime. The implementation lives in [`demo/pipeline/tools.py`](../demo/pipeline/tools.py).
---
@@ -424,6 +436,14 @@ result = validate(app.broker_url, agent.access_token)
The broker returns `valid=True` with claims, or `valid=False` with an error message. At the `validate()` endpoint specifically, the broker intentionally returns the same generic error ("token is invalid or expired") for every failure case — expired, revoked, malformed, or unknown — to prevent information leakage. Other endpoints (auth middleware, registration) return more specific messages by design.
+### Why `Agent` has no `validate()` method
+
+An agent does not validate itself. There is deliberately no `agent.validate()` on the `Agent` class — only the module-level `validate(broker_url, token)` and the app-level `AgentWritApp.validate(token)` convenience shortcut.
+
+The reason is a zero-trust one. If an agent's process is compromised, an attacker who calls `agent.validate()` and trusts the result is trusting the thing they already can't trust. Validation is a check that some *other* service does about a token it received — not a check the holder does about its own token. The SDK makes this physically awkward by not giving `Agent` a validate method at all. (This is recorded as ADR SDK-006 in the SDK.)
+
+The same reasoning sits behind `agent.scope` being the requested scope rather than a broker-signed value. If you need the authoritative scope for a security decision, validate.
+
---
## Error Model
@@ -508,4 +528,5 @@ The SDK implements the [Ephemeral Agent Credentialing](https://github.com/devona
| [Developer Guide](developer-guide.md) | Real patterns: delegation, scope gating, error handling |
| [API Reference](api-reference.md) | Every class, method, parameter, and exception |
| [Testing Guide](testing-guide.md) | Unit tests, integration tests, running the test suite |
-| [MedAssist Demo](../demo/) | The concepts above running in a working healthcare app |
+| [MedAssist demo](../demo/README.md) | The concepts above running in a working healthcare app |
+| [Support-ticket demo](../demo2/README.md) | A three-agent pipeline with identity gating and tool-level denial |
diff --git a/docs/developer-guide.md b/docs/developer-guide.md
index 12771f4..cc9513d 100644
--- a/docs/developer-guide.md
+++ b/docs/developer-guide.md
@@ -495,4 +495,5 @@ async def handle_request():
| [Concepts](concepts.md) | Trust model, roles, scopes, and standards |
| [API Reference](api-reference.md) | Every class, method, parameter, and exception |
| [Testing Guide](testing-guide.md) | Unit tests, integration tests, running the test suite |
-| [MedAssist Demo](../demo/) | See every capability in a working healthcare app |
+| [MedAssist demo](../demo/README.md) | See every capability in a working healthcare app |
+| [Support-ticket demo](../demo2/README.md) | A three-agent pipeline with identity gating and tool-level denial |
diff --git a/docs/getting-started.md b/docs/getting-started.md
index 7de7d4e..2ae9c00 100644
--- a/docs/getting-started.md
+++ b/docs/getting-started.md
@@ -43,6 +43,8 @@ pip install git+https://github.com/devonartis/agentwrit-python.git
The SDK depends on `httpx` (HTTP) and `cryptography` (Ed25519 operations). Both are installed automatically.
+> **Heads-up: the SDK is synchronous.** v0.3.0 uses `httpx`'s sync client. If you're inside an async framework (FastAPI, Starlette, Sanic), wrap the SDK calls in `asyncio.to_thread()` so they don't block the event loop. The [Developer Guide](developer-guide.md#async--await-support) shows the pattern.
+
---
## Step 1: Set Up Your Credentials
@@ -77,7 +79,7 @@ app = AgentWritApp(
)
```
-This creates your app instance. No broker call happens yet — the SDK authenticates lazily on the first `create_agent()` call.
+This creates your app instance. No broker call happens yet — the SDK authenticates lazily on the first `create_agent()` call. Once the app token has been issued, the SDK caches it and automatically re-authenticates when it drops below 60 seconds of remaining life, so you don't have to manage the session yourself.
> **If your first `create_agent()` call raises:** `AuthenticationError` means the `client_id` or `client_secret` is wrong (or rotated). `TransportError` means the broker URL is unreachable.
@@ -98,10 +100,12 @@ The SDK just did a lot of work behind the scenes:
1. Authenticated your app with the broker (`POST /v1/app/auth`)
2. Created a launch token with your requested scope (`POST /v1/app/launch-tokens`)
3. Got a challenge nonce from the broker (`GET /v1/challenge`)
-4. Generated a fresh Ed25519 keypair in memory
+4. Generated a fresh Ed25519 keypair in memory (unless you passed `private_key=`)
5. Signed the nonce with the private key
6. Registered the agent (`POST /v1/register`)
+Steps 3–5 are a standard challenge-response handshake. The broker hands the SDK a random nonce, the SDK signs the nonce with the new private key, and the SDK ships the signature *plus* the matching public key to `/v1/register`. The broker verifies the signature against the public key — if that check passes, the broker has proof that whoever posted the registration holds the private key, without the private key ever leaving the calling process. The launch token ties that proof to your app and the scope ceiling you're allowed to mint inside.
+
You get back an `Agent` object with:
```python
@@ -189,4 +193,5 @@ Exactly the scope needed, a unique cryptographic identity, and a token revoked t
| [Developer Guide](developer-guide.md) | Delegation, scope gating, error handling, and real patterns |
| [API Reference](api-reference.md) | Every class, method, parameter, and exception |
| [Testing Guide](testing-guide.md) | Unit tests, integration tests, running the test suite |
-| [MedAssist Demo](../demo/) | See every capability in a working healthcare app |
+| [MedAssist demo](../demo/README.md) | See every capability in a working healthcare app |
+| [Support-ticket demo](../demo2/README.md) | A three-agent pipeline — identity gating, cross-customer denial, natural TTL expiry |
diff --git a/docs/sample-app-mini-max.md b/docs/sample-app-mini-max.md
index afc8cbd..1c39eba 100644
--- a/docs/sample-app-mini-max.md
+++ b/docs/sample-app-mini-max.md
@@ -937,5 +937,6 @@ The loop will detect the dead token, print `"Token invalid: token_revoked"`, and
| Guide | What You'll Learn |
|-------|-------------------|
| [Developer Guide](developer-guide.md) | Delegation chains, error handling, multi-agent patterns |
-| [MedAssist Demo](../demo/) | Full multi-agent healthcare pipeline with LLM tool-calling |
+| [MedAssist demo](../demo/README.md) | Full multi-agent healthcare pipeline with LLM tool-calling |
+| [Support-ticket demo](../demo2/README.md) | Three-agent pipeline with identity gating and tool-level denial |
| [API Reference](api-reference.md) | Every class, method, parameter, and exception |
diff --git a/docs/sample-apps/README.md b/docs/sample-apps/README.md
index df2a88c..8d2db03 100644
--- a/docs/sample-apps/README.md
+++ b/docs/sample-apps/README.md
@@ -51,9 +51,9 @@ The broker enforces: requested ⊆ ceiling
| Scope | Valid? | Why |
|-------|--------|-----|
-| `read:data:*` | ✅ | Wildcard in identifier — covers any specific identifier |
-| `*:data:customers` | ❌ | Wildcard in action — broker rejects this |
-| `read:*:customers` | ❌ | Wildcard in resource — broker rejects this |
+| `read:data:*` | **Yes** | Wildcard in identifier — covers any specific identifier |
+| `*:data:customers` | **No** | Wildcard in action — broker rejects this |
+| `read:*:customers` | **No** | Wildcard in resource — broker rejects this |
This means your ceiling specifies which **actions** on which **resources** your app can ever use, with flexibility on the **specific identifier**.
@@ -164,4 +164,5 @@ Each app document follows the same format:
| Concept explanations (scopes, roles, delegation) | [Concepts](../concepts.md) |
| Real patterns for production code | [Developer Guide](../developer-guide.md) |
| Every method and parameter | [API Reference](../api-reference.md) |
-| Full-stack healthcare demo with LLM + UI | `demo/` directory |
+| Full-stack healthcare demo with LLM + UI | [MedAssist demo](../../demo/README.md) |
+| Three-agent support-ticket pipeline | [Support-ticket demo](../../demo2/README.md) |
diff --git a/docs/testing-guide.md b/docs/testing-guide.md
index 4f42749..c7236c8 100644
--- a/docs/testing-guide.md
+++ b/docs/testing-guide.md
@@ -160,4 +160,5 @@ uv run pytest tests/unit/ # unit tests
| [Developer Guide](developer-guide.md) | Real patterns: delegation, scope gating, error handling |
| [API Reference](api-reference.md) | Every class, method, parameter, and exception |
| [Concepts](concepts.md) | Trust model, roles, scopes, and standards |
-| [MedAssist Demo](../demo/) | See every capability in a working healthcare app |
+| [MedAssist demo](../demo/README.md) | See every capability in a working healthcare app |
+| [Support-ticket demo](../demo2/README.md) | A three-agent pipeline with identity gating and tool-level denial |