Architecture
What this service is
aitp-playground is a FastAPI application that orchestrates demonstrations
of the Agent Identity & Trust Protocol (AITP). A scenario is a YAML
file that declares a set of agents and a workflow that runs against them.
When a scenario is started, the playground:
- Spawns each agent as its own Python subprocess on a dedicated port.
- Waits for each subprocess to signal ready.
- Resolves how peers find each other (static, did:web, or Control Plane discovery).
- Drives AITP handshakes between agent pairs via each agent's
/adminAPI. - Executes the scenario's workflow steps — capability calls, probes, delegation, revocation — and records every event.
Nearly all AITP protocol logic (keygen, manifests, handshake, TCT verify,
delegation, revocation) lives in the aitp Python SDK, which the agent
workers import directly; the playground is the harness around them.
The runner is almost protocol-free. Two exceptions, both deliberate and
both SDK calls rather than reimplementations: spki_pin_check drives
compute_spki_hash/SpkiPinVerifier directly (a pure-SDK exercise with no
agent involved), and cp_provision_trust_anchor calls
aitp.verify_manifest_json before pinning an agent's key into the control
plane's trust store — pinning key material read from an unverified fetch
would let whatever answered at that URL choose the key the CP trusts.
Runtime topology
┌──────────────────────────────────────┐
HTTP client ───▶ │ aitp-playground (port 8000) │
│ │
│ ┌──── API (FastAPI routers) ─────┐ │
│ │ /runs /scenarios /packs │ │
│ │ /agents /healthz /capabilities │ │
│ │ /metrics /dashboard /cp/* │ │
│ │ /webhooks/cp/{run} (← CP) │ │
│ │ /internal/telemetry (← agents)│ │
│ │ /hosted-agents/* (cross-domain)│ │
│ └────────────────────────────────┘ │
│ ┌──── Runner ────────────────────┐ │
│ │ ScenarioRunner.run() │ │
│ │ ├─ RegistryService │ │
│ │ ├─ TrustOrchestrator │ │
│ │ ├─ AgentSupervisor (subproc) │ │
│ │ ├─ BootstrapBuilder │ │
│ │ ├─ AdapterRegistry │ │
│ │ └─ RunStore (events, SSE) │ │
│ └────────────────────────────────┘ │
│ ┌──── CpClient (optional) ───────┐ │
│ │ best-effort registry + events │ │
│ └────────────────────────────────┘ │
└──────────────────────────────────────┘
│ spawn (subprocess.Popen)
▼
┌───────────────────────────────────────────────────────────┐
│ Agent worker A — port 8100 │
│ FastAPI app │
│ ├─ aitp_server.AitpServer │
│ │ /.well-known/aitp-manifest │
│ │ /.well-known/did.json (when did_web_host set) │
│ │ /aitp/handshake/hello, /aitp/handshake/commit │
│ │ /aitp/delegation/redeem │
│ │ /admin/rotate-keys (mounted here, not below) │
│ │ /admin/tct-cache-stats (mounted here, not below) │
│ ├─ agent_admin.build_admin_router │
│ │ /admin/initiate-handshake /admin/invoke │
│ │ /admin/self-execute /admin/delegate │
│ │ /admin/redeem-delegation /admin/revoke-tct │
│ │ /admin/held-tct /admin/renew-tct │
│ │ /admin/process-renewal /admin/enroll-with-cp │
│ │ /admin/refresh-revocations │
│ │ /admin/export-session-bundle …/verify-… │
│ └─ /capabilities/<name> (per-agent worker) │
│ │
│ aitp.AitpAgent — identity, handshake, TCT, delegation │
└───────────────────────────────────────────────────────────┘
┌───────────────────────────────────────────────────────────┐
│ Agent worker B — port 8101 ... same layout │
└───────────────────────────────────────────────────────────┘Every blob inside an agent worker is the SDK's responsibility or thin
HTTP plumbing. The "knows about AITP" line lives at the
aitp_server.AitpServer / agent_admin boundary; everything outside it
just speaks HTTP.
Two /admin/* routes are mounted directly by AitpServer, not by
agent_admin.build_admin_router: POST /admin/rotate-keys (replaces the
agent's keypair and republishes its manifest — key material lives here,
not in the admin router) and GET /admin/tct-cache-stats (RFC-AITP-0005
verification-cache counters). Every other /admin/* route — including
GET /admin/held-tct and POST /admin/refresh-revocations — comes from
build_admin_router. See agents.md
for the full per-route breakdown.
Components
Registry (src/aitp_playground/registry/)
Walks scenarios_dir on startup, validates with Pydantic, and serves
scenario + manifest lookups. _shared/agents/*.yaml are loaded once;
scenario packs are discovered as <pack>/<scenario>/<version>/scenario.yaml.
A !include YAML tag is supported via include_resolver.py for splitting
large scenarios. The registry is read-only at runtime; set
REGISTRY_CACHE_TTL_MS=0 to reload on every lookup (the default).
Hosting (src/aitp_playground/hosting/)
port_allocator.py— hands out unique ports starting fromAGENT_BASE_PORT(default 8100). Honorsport_offseton each agent spec; falls back to monotonic allocation on collision.identity.py— derives a deterministic 32-byte seed from(org, run_id, agent_id). Same scenario → same AIDs across runs. Cross-org scenarios mark agentsorg: externalso their AIDs land in a separate namespace.bootstrap.py— writes a per-agent JSON file containing seed, port, peer placeholders, telemetry URL, and scenario inputs. The agent process readsAITP_BOOTSTRAP_FILEto find it.adapters/—PythonAgentAdapteris the only adapter today; it's registered once per framework (crewai,langchain,langgraph,custom). Adapter responsibilities: validate the manifest, build the process env, and produce aPreparedLaunch.supervisor.py—subprocess.Popenper agent; reads stdout until the worker emitsAITP_AGENT_READY aid=... port=..., then backgrounds stdout/stderr draining. Kills on cleanup or/runs/{id}/cancel.
Runner (src/aitp_playground/runner/)
engine.py—ScenarioRunner.run()is the single entry point. It loads the scenario, validates inputs against the inline JSON Schema, spawns agents, resolves peers, optionally runs eager pairwise handshakes, then walksworkflow.steps. See runner.md.context.py—RunContextaccumulatesRunEvents; every emit also fans out toRunStoreso SSE subscribers see it live.store.py— in-memory pub/sub with per-run event queues. The SSE endpoint (GET /runs/{id}/events) replays the backlog then streams live events with 1s heartbeats. WhenRUN_HISTORY_DB=<path>is set, the store is aSqliteRunStorethat mirrors every write to that SQLite file and rehydrates the in-memory cache from it on startup — runs survive a process restart. Empty (the default) is in-memory only.result.py—RunResultreturned to background-task callers.
Observability (src/aitp_playground/observability/)
Every RunContext.emit also feeds metrics.record_event and is
renderable by narrator. metrics.py is a tiny thread-safe Prometheus
registry behind GET /metrics; narrator.py is a pure event→text
renderer behind GET /runs/{id}/narrate and the CLI trace. The
single-file trust console at GET /dashboard consumes these plus the JSON
APIs. See observability.md.
Trust (src/aitp_playground/trust/)
TrustOrchestrator.resolve_peers() returns {agent_id: {manifest_url, did}}
keyed by the scenario's discovery mode:
static— peers find each other athttp://localhost:<port>.did_web— each peer'sdid:web:<host>resolves through/.well-known/did.jsonto find the manifest URL. The agent serves its own DID doc from itsAitpServerwhendid_web_hostis set.cp_registry— query the optional Control Plane for agents matching a capability; on any failure, fall back to static localhost.
The orchestrator only chooses where to look. The handshake itself
is initiated by the runner POSTing /admin/initiate-handshake to the
agent, which uses the SDK.
oidc_issuer.py mints a per-run mock OIDC issuer (Ed25519 keypair) when a
scenario contains an identity_type: oidc agent. The issuer's private seed
and public JWK ride along in each agent's bootstrap so OIDC agents can mint
ID tokens and every agent can verify OIDC peers. Real deployments would
point at an external IdP instead — see
aitp-integration.md.
Control Plane client (src/aitp_playground/cp_client/)
CpClient is fully optional. When CP_BASE_URL is empty:
discover_by_capability()returns[].ingest_events()is a no-op. On any HTTP error it logs and degrades. No CP call is ever load-bearing.
Agent workers (agents/)
Each agent is a self-contained FastAPI app launched as a subprocess.
The shared bootstrap files in agents/base/ are added to PYTHONPATH
by the adapter so workers can from aitp_server import AitpServer,
from agent_admin import build_admin_router, etc.
Per worker the layout is identical:
load_bootstrap()readsAITP_BOOTSTRAP_FILE.create_agent()builds anaitp.AitpAgentfrom the seed.AitpServermounts the AITP protocol routes.build_admin_router()mounts the/adminroutes used by the runner.- The worker registers its
/capabilities/<name>handlers and starts uvicorn. The lifespan emitsAITP_AGENT_READYonce the port is bound.
See agents.md for how to add a new worker.
Hosted agents (src/aitp_playground/api/hosted.py)
A separate router from /runs, for a different scenario: a cross-domain
handshake between agents hosted by two different instances of this service
(e.g. org-A's playground and org-B's playground), rather than an intra-org
scenario where the runner spawns and drives both sides of a handshake itself.
api/hosted.py mounts the routes; hosting/hosted.py's HostedAgentManager
holds the actual lifecycle logic (spawn, track, stop) and registers no
routes of its own. All six routes live under the /hosted-agents prefix:
POST /hosted-agents— spawn a long-lived agent from a scenario ref at this service's public origin.GET /hosted-agents— list currently hosted agents.GET /hosted-agents/{hosted_id}— fetch one hosted agent's record.DELETE /hosted-agents/{hosted_id}— stop a hosted agent.POST /hosted-agents/{hosted_id}/resolve-and-handshake— resolve a peer'sdid:web(fail-closed: a resolution that lands on a loopback origin is refused, so a green test can't be a same-process handshake in disguise) and drive this hosted agent's SDK handshake against the resolved peer.POST /hosted-agents/{hosted_id}/invoke— invoke a capability on a peer using the TCT this hosted agent obtained during the handshake.
This is what the Level 1 / Level 2 federated e2e stack (federated/) drives
against two real origins; the intra-org /runs flow never touches it.
Data flow for one capability call
runner
└─ POST /admin/invoke (on caller)
└─ caller looks up held TCT for the target's port
POST /capabilities/<name> (on target)
headers: X-AITP-TCT: <held compact-JWS TCT token>
└─ target.AitpServer.verify_capability_tct(...)
└─ agent.verify_tct(...) ✓ or 403
└─ capability handler runs
└─ result returned as JSON
└─ /admin/invoke wraps non-2xx into {error:true, status_code, body}
runner
└─ records step output, emits step.complete (or fails the run)Telemetry events emitted from inside an agent (handshake.started,
handshake.complete, llm.started, etc.) POST to
/internal/telemetry on the playground, which appends them to the
run's event log.
Invariants
- SDK is the only AITP code. No envelope signing, no JCS, no handshake state machine in this repo. If you need one, fix the SDK.
- CP is always optional. Every CP call has a graceful fallback. Scenarios
with
discovery: cp_registrywork without a CP — they fall back to static localhost. - Agents call the SDK themselves. The runner never calls AITP methods
on behalf of an agent; it pokes them via
/adminHTTP. - Scenarios are read-only at runtime. The registry is rebuilt from
disk; nothing in
scenarios/is written by the service. - One process per agent per run. No in-process sharing; each agent has
its own identity and
held_tcts.
Where to read next
- Want to run it? → getting-started.md
- Want to add a scenario? → scenarios.md
- Want to know how a step actually executes? → runner.md
- Want to understand TCTs and handshake? → aitp-integration.md
- Want events / metrics / the dashboard? → observability.md
- Wiring the Control Plane? → control-plane.md
- Which SDK features are installed? → capabilities.md