Playground integration contract

This document is the CP side of the contract with aitp-playground: which of this service's endpoints and fields the playground depends on, and what the CP may or may not change without coordinating. Anything marked load-bearing below is a breaking change if altered.

It is deliberately not a description of how the playground works. The playground-side map — which playground feature calls which CP endpoint, the CpClient methods, the scenario step types that drive them, and the degradation mechanics — is owned by and documented in the playground repo: aitp-playground/docs/control-plane.md (see its "CP endpoints the playground uses" and "What lives where" sections). This page links there rather than restating it, exactly as that page links back here for endpoint shapes.

The one invariant: everything degrades gracefully

No CP call is required for a scenario to run. The playground treats every CP call as optional and best-effort — when CP_BASE_URL is unset, or any call times out or errors, it falls back to a no-op and the run still completes, losing only the CP-backed discovery, telemetry, and inspection features. The exact fallback values and per-call timeout are the playground's concern; see control-plane.md.

The CP obligation this implies — and the only part of the invariant this repo owns — is: return 2xx quickly on the hot-path calls (discovery and POST /api/events) and never require the playground to send anything it can't cheaply produce.

Load-bearing endpoints

These two are the historical core and the ones a run's behavior can depend on. Breaking either is a breaking change.

1. Capability discovery — GET /api/registry/agents?capability=<cap>

Used when a scenario sets trust.discovery: cp_registry for an org: external agent (discover_by_capability, driven from the trust orchestrator). The playground reads agents[0].handshakeEndpoint to point its peer-discovery TrustOrchestrator at the first match.

{ "agents": [ { "aid": "did:pubkey:z:...", "handshakeEndpoint": "http://agent-host:8101/aitp", "offeredCaps": ["demo.echo"], "status": "active", "namespace": "default", "...": "..." } ] }

The CP must include handshakeEndpoint on every record. If this call returns [] or a non-2xx, the scenario fails over to static discovery. (Full record shape: api.md.)

2. Telemetry ingestion — POST /api/events

Fire-and-forget after a run completes (ingest_events). Body is { "events": [...] } with snake_case keys — this is canonical from the playground:

{ "events": [ { "type": "handshake.completed", "ts": "2026-05-25T12:00:00.000Z", "aid_a": "did:pubkey:z:...", "aid_b": "did:pubkey:z:...", "session_id": "uuid", "run_id": "run-abc", "grants": ["demo.echo"], "payload": { "...": "..." }, "playground": { "run_id": "run-abc", "scenario": "research-and-write" } } ] }

The CP must: return 2xx quickly; normalize aid_a/aid_b/session_id/run_id to camelCase internally; de-dupe on event id (ON CONFLICT DO NOTHING); and tolerate unknown event types (record as-is, never 4xx). See events.md for which types drive projections and webhooks.

Batch limits still apply: ≤ 256 KiB per request, ≤ 500 events per batch, ≤ 64 KiB per event payload — an over-cap batch gets 413 PAYLOAD_TOO_LARGE, not a fire-and-forget 2xx. Runners producing more than 500 events per run must split the batch.

⚠️ The playground emits handshake.completed (past tense), but the CP's session/TCT projection and webhook fan-out key on handshake.complete. Events are stored and streamed either way, but completed projects no session. This is a known naming mismatch between the two repos — see events.md § naming nuance. Resolve it on one side before relying on session projections from playground telemetry.

Endpoints the playground depends on

The CP endpoints the playground currently calls, with the CP-side contract for each — the request keys it sends and response keys it reads that must not break. The playground-internal mapping (client method, the scenario step type or API proxy that drives each call) lives in control-plane.md; treat that as the source of truth for the current call list, and this table as the CP's record of what those calls depend on.

EndpointLoad-bearing?CP-side contract the playground relies on
GET /api/registry/agents?capability=yesreads agents[0].handshakeEndpoint (see above)
POST /api/eventsyessnake_case envelope keys; 2xx quickly (see above)
POST /api/revocation/entriesnobody {jti, reason?}; idempotent
GET /.well-known/aitp-revocation-listnopublic, no auth; reads entries[].jti (tolerates a revocation_list wrapper)
GET /api/events/historynoparams run_id, aid, type, limit
GET /api/sessionsnoparams run_id, aid, status, limit
GET /api/sessions/{id}/replaynoparams since, until, limit; reads events
POST /api/webhooksnorun-scoped delivery URL; events: [] ⇒ all deliverable types
DELETE /api/webhooks/{id}no404 treated as success
GET /api/tctsnosends sessionId (camelCase), active=true as string
GET /api/delegationsnoroot_jti walks the tree
GET /api/dashboard/overviewno⚠️ param drift — see below
GET /api/dashboard/agentsnoreads agents
GET /api/trust-anchorsnoreads trustAnchors; namespace filter
POST /api/trust-anchorsnobody {issuerUrl, namespace?, jwksUrl?, label?}
GET /api/pinned-keysnoreads pinnedKeys; namespace filter
POST /api/pinned-keysnobody {aid, pubkey, namespace?, label?}

The playground also receives webhook deliveries the CP POSTs to a run-scoped URL it registers via POST /api/webhooks. The CP signs those with X-AITP-Signature — see api.md § Webhooks.

Enrollment (POST /api/registry/enrollPOST /api/registry/agents) is not called by the playground — agents enroll themselves; the playground only discovers them.

Known contract drift

Surfaced while reconciling this doc with the live client — fix on whichever side owns the field:

  • Dashboard window is ignored. The playground sends ?window=<window> to GET /api/dashboard/overview, but the CP route reads ?range=. The CP therefore always returns the default 24h window regardless of what the playground requests. Fix on either side: rename the playground param to range, or have the CP accept window as an alias.

Configuration mapping

Playground configCP env varNotes
cp_base_urlCP_BASE_URLEmpty ⇒ the whole client is a no-op
cp_api_keyone of API_KEYSSent as Authorization: Bearer on every call except the public revocation-list fetch. If unset on the playground but API_KEYS is set on the CP, gated calls 401 (and degrade). In dev with API_KEYS= the CP accepts unauthenticated calls.
cp_timeout_msn/aClient-side timeout, default 5000 ms

Versioning

The CP follows semver; this contract is stable under v0.x — additions are allowed, breaking changes need coordination.

  • Adding optional fields to the agent record or event envelope: non-breaking.
  • Renaming/removing handshakeEndpoint, aid, aid_a, aid_b, session_id, run_id: breaking (load-bearing).
  • Changing auth on discovery or /api/events: breaking.
  • Renaming response keys the client reads (agents, events, sessions, tcts, delegations, trustAnchors, pinnedKeys, entries) or request keys it sends (issuerUrl, jwksUrl, pubkey, sessionId, jti): breaking for that feature — coordinate.

Verifying locally

# Start the CP
docker compose up -d postgres
npm run db:migrate
npm run dev

Then start the playground pointed at it — the run command, ports, and scenario payloads are playground-owned; follow aitp-playground/docs/getting-started.md and point it at the CP with:

export CP_BASE_URL=http://localhost:4000
export CP_API_KEY=""   # leave empty for local dev (no API_KEYS set on the CP)

After a run, confirm the load-bearing telemetry path from the CP side:

curl 'http://localhost:4000/api/events/history?limit=10' | jq

If the events show up, the load-bearing integration is healthy. The broader surface is exercised by scenarios using the cp_subscribe_webhook, revoke_tct (via_cp: true), cp_provision_trust_anchor, and cp_delegation_tree step types — all defined and documented in the playground repo.