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.
The runner itself contains no AITP protocol code. All protocol logic
(keygen, manifests, handshake, TCT verify, delegation, revocation) is
in the aitp Python SDK, which the agent workers import directly. The
playground is purely the harness around them.
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)│ │
│ └────────────────────────────────┘ │
│ ┌──── 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 │
│ ├─ agent_admin.build_admin_router │
│ │ /admin/initiate-handshake /admin/invoke │
│ │ /admin/self-execute /admin/delegate │
│ │ /admin/redeem-delegation /admin/revoke-tct │
│ │ /admin/rotate-keys /admin/renew-tct │
│ │ /admin/process-renewal /admin/enroll-with-cp │
│ │ /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.
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.
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 tct envelope>
└─ target.AitpServer.verify_capability_tct(...)
└─ aitp.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