API integration
This document covers how the UI Console talks to its two upstream services. Endpoint schemas and request/response details live in the upstream API docs and are referenced rather than duplicated here.
Endpoint references — the UI integrates against two HTTP services. For full endpoint schemas, error shapes, and semantics see the upstream docs:
- Control Plane —
macp-control-plane/docs/API.md,macp-control-plane/docs/INTEGRATION.md,macp-control-plane/docs/ARCHITECTURE.md- Examples Service —
macp-playground/docs/api-reference.md,macp-playground/docs/architecture.mdWhat follows is UI-specific: the proxy model, the subset of endpoints the UI calls, the normalizers that bridge upstream shapes to UI types, and the SSE / demo-mode plumbing.
Overview
The UI integrates with two upstream services:
- Examples Service — scenario catalog, agent profiles, launch schema, launch compilation, optional one-shot bootstrap.
- Control Plane — observer-only run lifecycle, state projection, canonical events (per-run + cross-run), SSE streaming, metrics/traces/artifacts, runtime metadata + policy registry, webhooks, audit, admin.
Under the observer-only macp-control-plane model, agents emit envelopes (messages, signals,
context updates) directly to the runtime via macp-sdk-python / macp-sdk-typescript.
The UI only reads from the CP; it never originates agent traffic. See
macp-control-plane/docs/ARCHITECTURE.md § Request Flow
for the authority model and
macp-playground/docs/direct-agent-auth.md
for how macp-playground spawns those agents.
Proxy routes
The browser never calls upstream services directly. Two Next.js route handlers forward requests, inject auth, and keep secrets server-side.
| Route | File | Purpose |
|---|---|---|
/api/proxy/[service]/[...path] | app/api/proxy/[service]/[...path]/route.ts | Generic forwarder for macp-playground and macp-control-plane services. Injects auth, strips hop-by-hop headers, streams response body unchanged. |
/api/jaeger/[...path] | app/api/jaeger/[...path]/route.ts | Forwards to JAEGER_BASE_URL/api/*. Used by the trace detail surface to resolve span waterfalls. Returns 502 if Jaeger is unreachable. |
Supported upstream service identifiers ([service] segment): macp-playground, macp-control-plane — the two members of ProxyService in lib/server/integrations.ts:1.
Environment variables
MACP_PLAYGROUND_BASE_URL=http://localhost:3100
MACP_PLAYGROUND_API_KEY=
MACP_CONTROL_PLANE_BASE_URL=http://localhost:3001
MACP_CONTROL_PLANE_API_KEY=
JAEGER_BASE_URL=http://localhost:16686 # server-side (proxy target)
NEXT_PUBLIC_JAEGER_BASE_URL=http://localhost:16686 # client-side (UI deep links)
lib/server/integrations.ts throws when MACP_PLAYGROUND_BASE_URL / MACP_CONTROL_PLANE_BASE_URL
are missing in production; empty API keys log a warning but do not block requests.
Auth forwarding
- Examples Service — the proxy adds
x-api-key: <MACP_PLAYGROUND_API_KEY>when configured. - Control Plane — the proxy adds
authorization: Bearer <MACP_CONTROL_PLANE_API_KEY>when configured.
Headers host, connection, content-length are stripped before forwarding;
content-encoding is stripped on the response. Every proxied response carries
x-macp-ui-proxy: <service> for observability.
Examples Service endpoints used by the UI
Full schemas: macp-playground/docs/api-reference.md.
The UI calls the following subset:
Scenario discovery
GET /packs— pack listing for the catalogGET /packs/:packSlug/scenarios— scenarios within a packGET /scenarios— cross-pack listing; each row includespackSlug,policyVersion,policyHints
Agent profiles
GET /agents— all agent profiles with pre-computed scenario coverage;metricsare best-effort (zero when CP is unavailable). The UI enriches latency / confidence client-side from CP/dashboard/agents/metrics.GET /agents/:agentRef— single profile; the client returnsundefinedon 404 and rethrows other errors
Launch setup
GET /packs/:packSlug/scenarios/:scenarioSlug/versions/:version/launch-schema?template=— drives the schema-driven launch form;launchSummary.policyHintsfeeds the policy badge and launch previewPOST /launch/compile— validates inputs against the scenario JSON Schema and returns a whitelisted-saferunDescriptor(for CPPOST /runs), ascenarioSpec(for agent bootstrap), and a pre-allocated UUID v4sessionId
Optional one-shot bootstrap
-
POST /examples/run— compiles, spawns the example agents with per-agent JWTs (minted via auth-service), and submits the run to the CP. Response (201):{ compiled, hostedAgents[], sessionId, controlPlaneRun? }.controlPlaneRunis the CP'sPOST /runsresponse, and its absence is the only registration signal the console gets. Submission is best-effort and non-fatal by design: it runs concurrently with agent bootstrap, and an unsetMACP_CONTROL_PLANE_URL, a network error, a timeout, a non-2xx (including a 401 from a missing API key), a malformed body, or a response missingrunId/status/sessionIdor whosesessionIddisagrees with the descriptor's, all cause the field to be omitted while the request still succeeds with live agents. A playground old enough never to send it is indistinguishable. The field is also absent, along withsessionId, whenbootstrapAgents: falseshort-circuits the flow; that is "nothing was bootstrapped", not a registration failure.Navigate by
controlPlaneRun.runId, never bysessionId— the two are different ids on this path.POST /runsmakes the CP mint a fresh run id and store the session id separately, so/runs/live/<sessionId>does not resolve. (controlPlaneRun.sessionId, by contrast, is guaranteed equal to the top-levelsessionId: the playground rejects any response where they disagree.)When the field is absent, the run is not necessarily unreachable. With
SESSION_DISCOVERY_ENABLED(default true), the CP registers runs it observes directly from the runtime, and those it keys by session id — so/runs/live/<sessionId>starts resolving once discovery has seen the session. Discovery is neither instant nor guaranteed: if the submission reached the CP and only the reply was lost, a run already exists under a different id and discovery finds it by session id, so no session-keyed run is ever created and that route stays dead. The console therefore does not redirect; it reports that the Example Service did not register the run, declines to attribute a cause, and offers the session route as a link that may or may not resolve.For local real-mode work both
MACP_CONTROL_PLANE_URLandMACP_CONTROL_PLANE_API_KEYmust be set on the playground service — the CP's auth guard rejects a missing Authorization header before it reaches the empty-AUTH_API_KEYSbypass, so an unset key silently yields the omitted field.docker-compose.e2e.ymlsets both.
Control Plane endpoints used by the UI
Full schemas: macp-control-plane/docs/API.md.
The UI calls the following subset. Each bullet below documents what the UI sends
and expects — for the authoritative endpoint contract, follow the links.
Dashboard
GET /dashboard/overview— aggregated KPIs,recentRuns,runtimeHealth, chart series. The UI sendswindow/from+to/scenarioRef/environment. KPIs read:totalRuns,activeRuns,completedRuns,failedRuns,cancelledRuns,totalSignals,totalTokens,totalCostUsd,avgDurationMs. The UI consumesrecentRunsdirectly and only falls back toGET /runswhen an older CP build omits the field. Chart series the UI renders are listed under Chart series below.GET /dashboard/agents/metrics— per-agentruns/signals/messages/averageConfidence/averageLatencyMs(optional). CP returnsparticipantId; the client normalizes toagentRefbefore merging into the Examples Service agent profiles.
Run lifecycle
POST /runs/validate— the UI composesValidateRunResponse { ok, errors, warnings, runtime }from CP's{ valid, errors, warnings, runtime }by mappingvalid && errors.length === 0→ok.POST /runs— only run-creation path. The UI posts whitelisted-safe compiled descriptors from the Examples Service. Scenario-specific fields are rejected with 400 by CP.GET /runs— paginated{ data, total, limit, offset }. The UI always sendslimit/offsetdefaults (required by CP validation) plus the active filters (status,environment,search,tags,scenarioRef,sortBy,sortOrder,createdAfter,createdBefore,includeArchived).GET /runs/:id— run record; flatsourceKind/sourceRefare nested intosource: { kind, ref }bynormalizeRun().POST /runs/:id/cancel— opaque to the UI; CP chooses between the initiator's cancel-callback (default) and directCancelSession(policy-delegated).POST /runs/:id/clone— accepts optional{ tags, context }. Non-emptycontextoverrides are rejected by CP under observer-only rules; the clone form surfaces the error directly.POST /runs/:id/archive— full run record; the client extracts{ ok, runId, archived }. CP's dedicatedarchivedAtcolumn is passed through unchanged (no tag-synthesis bridge).POST /runs/:id/replay— returns a replay descriptor ({ runId, mode, speed, streamUrl, stateUrl }).POST /runs/compare— pairwise comparison.DELETE /runs/:id— permanent delete; only available for terminal runs.
Run state and streaming
GET /runs/:id/state— the projection. The UI consumes:runblock (+contextId,extensionKeys),participants,graph,decision.current(incl.proposals[],resolvedAt,resolvedBy,prompt,outcomePositive: boolean | null,supersedes),signals,progress,timeline,trace,outboundMessages, optionalpolicy(+expectedCommitments,voteTally,quorumStatus), optionalllm({ calls[], totals }).decision.current.supersedes— cross-session commitment lineage (RFC-MACP-0001 §7.3):{ sessionId, commitmentHash, canonical }.canonicalreports whethercommitmentHashis in the RFC-MACP-0013 §9 form — literallysha256:followed by exactly 64 lowercase hex characters.falsemarks a legacy pre-0013 hash, which the control plane deliberately surfaces rather than drops; the console badges the format and keeps the hash (with the full value on thetitleattribute, since the rendered hash is truncated).- The control plane declares
canonicalrequired; this repo mirrors it as optional and gates the badge on=== false, never on falsiness. That is deliberate version-skew tolerance, not a hole in the current CP: a current control plane sets the field on every UI-visible path — via the read-time backfill inProjectionService.get()(which the streaming path also runs, sinceapplyAndPersistloads its base state throughget()), and via the reducer itself on a rebuild — soundefinedshould not arrive from one. It does arrive from a control plane deployed before the backfill existed — and such a row is never rewritten, only re-derived when a newdecision.finalizedarrives. The CP's ownASSUMPTIONS.mdP6 records this and names this console's decision panel as the blast radius, warning thatif (!canonical) badge()would mis-label legacy-but-actually-canonical history. Treatundefinedas "unknown", not as "non-canonical".
GET /runs/:id/events— dual-shape response: bareCanonicalEvent[]on the fast path;{ data, total, limit, nextCursor }when any ofafterTs/beforeTs/typeis supplied. The client handles both and pipes every row throughnormalizeEvent().GET /events— cross-run stream for/logs. When CP returns 404 (older build), the client falls back to per-run fan-out and caches the decision for the browser session so later navigations skip the probe.GET /runs/:id/stream— SSE withincludeSnapshot=true&afterSeq=<n>. Named events:snapshot,canonical_event,heartbeat.GET /runs/:id/replay/state?seq=<n>— state projection at a specific sequence; powers the timeline scrubber.GET /runs/:id/export— full run bundle; query:includeCanonical,includeRaw,eventLimit,format(json | jsonl).
Session canonical-event vocabulary. The control plane emits session.bound,
session.stream.opened, and session.state.changed. Suspend / resume / resolve / expire
/ cancel transitions all arrive as session.state.changed carrying data.state (e.g.
SESSION_STATE_SUSPENDED) — there are no discrete session.opened / .resolved /
.expired events. The /logs Session filter group, summarizeEvent, and the run/session
lifecycle summarizers key on this vocabulary (legacy names are retained only so old
exports still group). Run pause/resume also surface as run.suspended / run.resumed.
Implicit handoff accepts (RFC-MACP-0010 §5.1). When a handoff target stays silent
past the accept window, the runtime emits a synthetic HandoffAccept (sender = the
target, messageId = implicit-accept:<handoff_id>, decodedPayload.implicit = true) that
the CP surfaces as a normal proposal.updated. The console flags these with an
implicit badge (feed, /logs, event dialog) via isImplicitAccept() so a
runtime-synthesized accept is visually distinct from one a participant actually sent.
Multi-round Contribute payloads decode to decodedPayload.value on the CP side
(proto ContributePayload, JSON legacy tolerated); the console reads the decoded
payload and needs no decoding of its own.
Session interaction (observer-only)
Under direct-agent-auth, agents emit envelopes directly to the runtime via the SDKs. The
HTTP bypass endpoints return 410 Gone and the UI does not render forms for them:
— agents usePOST /runs/:id/messagesDecisionSession(client).evaluate(...)orsession.send(...)(macp-sdk-python, macp-sdk-typescript)— agents usePOST /runs/:id/signalsession.signal(...)via the SDK— agents construct aPOST /runs/:id/contextContextUpdateenvelope via SDK helpers
Still supported (scenario-agnostic, CP-local):
POST /runs/:id/artifacts— create an artifact ({ kind, label, uri?, inline? })POST /runs/:id/projection/rebuild— admin: rebuild projection from events
Batch operations
POST /runs/batch/cancel,POST /runs/batch/archive,POST /runs/batch/delete—{ runIds: string[] }→{ results: [{ runId, ok }] }POST /runs/batch/export— returnsRunExportBundle[]
Observability and artifacts
GET /runs/:id/metrics— includespromptTokens,completionTokens,totalTokens,estimatedCostUsd. Cost is derived fromMODEL_COSTSon the CP side (see CP API.md § Token usage convention).GET /runs/:id/traces— summary (traceId,spanCount,linkedArtifacts,runStatus,scenarioRef).GET /runs/:id/artifactsGET /metrics— raw Prometheus exposition. The/observabilitypage parses it client-side vialib/utils/prometheus.ts(counters, gauges, histograms, summaries; percentile interpolation matches Grafana'shistogram_quantile).GET /audit— supportsactor,action,resource,resourceId,createdAfter,createdBefore,limit,offset. Default paging islimit=100&offset=0.
Runtime metadata (pass-through from the runtime)
GET /runtime/manifest,GET /runtime/modes,GET /runtime/roots,GET /runtime/health
Runtime-level semantics (what a "mode" is, what's in a manifest) are documented in the
runtime repo: macp-runtime/docs/modes.md
and macp-runtime/docs/API.md.
Console-relevant notes (the e2e/local stack pins the v0.8.8 runtime image in
docker-compose.e2e.yml; release notes are in the runtime's
changelog):
GET /runtime/modesreturns the five standards-track descriptors only;ext.multi_round.v1is reachable viaListExtModes, which the control plane does not expose. Demo mode still lists six (known divergence, flagged atMOCK_RUNTIME_MODESinlib/data/mock-data.ts). The/modespage rendersmessageTypesandterminalMessageTypesper mode (terminal is always["Commitment"]).GET /runtime/rootsis fetched once per page view and is normally empty. Roots are static (list_changed: false, RFC-MACP-0006 §3.3), so the console does not watch for changes.- Runtime Prometheus counters (on
MACP_METRICS_ADDR) are an ops-only surface the control plane does not re-serve (counters:macp_messages_*,macp_sessions_*,macp_commitments_*,macp_replay_mismatches_total; env var in the runtime's deployment.md). The/observability"Metrics" tab parses the control plane's ownGET /metrics.
Runtime policy registry (RFC-MACP-0012, pass-through)
GET /runtime/policies?mode=<modeId>— filterable listGET /runtime/policies/:policyIdPOST /runtime/policies—{ policyId, mode, description, rules, schemaVersion? }.schemaVersion(1, 2 or 3; default 3, fail-closed) and the rejection of unknown rule keys are enforced by the control plane — seemacp-control-plane/docs/API.md. Console side: the registration form defaults to 3 and offers only those values; validation 400s carry noerrorCode, so readmessageviadescribeApiError; the response type stays forward-compatible, so a registered policy reporting another version still renders.DELETE /runtime/policies/:policyId
Rule schemas are opaque to the control plane; the UI renders them descriptively. The
authoritative per-mode schema lives in macp-runtime/docs/policy.md.
Read-only (file-managed) registry. When the runtime runs with MACP_POLICIES_DIR,
policies are managed on disk and register/unregister RPCs fail. The control plane
surfaces this as HTTP 405 with errorCode: REGISTRY_READ_ONLY. The /policies
policy-management UI detects this (isRegistryReadOnlyError, which also matches the
underlying FAILED_PRECONDITION defensively), shows a persistent "registry is
file-managed (read-only)" banner, and disables the mutation controls rather than looping
a dead-end error toast.
Operational admin
GET /webhooks— subscriptions may includedeliveryStats(total,succeeded,failed,lastDeliveredAt)POST /webhooks,PATCH /webhooks/:id,DELETE /webhooks/:idPOST /admin/circuit-breaker/resetGET /admin/circuit-breaker/history?window=<alias>— state transitions (CLOSED | OPEN | HALF_OPEN) with enter timestamps and optional reasonGET /readyz—{ ok, database, runtime, streamConsumer, circuitBreaker }GET /admin/runtime/sessions— runtime session drift: sessions the runtime holds that the control plane has no run for, and vice versa. Carriescomplete: falsewhen the sweep was cut short by its page or time budget rather than finishing, so a partial answer is never mistaken for "no drift". Whencompleteisfalse,missingFromRuntimeisnull, not[]— the reverse direction cannot be computed from a partial session list, and an empty array would assert "no runs are missing", which is exactly the false reassurance the flag exists to prevent. Render thenullas "not computed", never as zero.
Chart series
GET /dashboard/overview returns { labels, data } pairs; the client converts them to
UI ChartPoint[]. The series the UI renders:
runVolume, latency, errorClasses, signalVolume, throughput, queueDepth,
latencyP50 / P95 / P99, cost, successRate, decisionOutcome (single net series —
positive vs. negative encoded as +1/-1 per bucket, not split into two arrays),
perScenario.
Series semantics are documented in
macp-control-plane/docs/API.md § GET /dashboard/overview.
Jaeger integration
The /traces surface resolves span waterfalls through a Jaeger instance when
configured:
- Server-side fetch:
GET /api/jaeger/traces/:traceId→${JAEGER_BASE_URL}/api/traces/:traceId. Used bygetJaegerTrace(traceId). - Client-side deep link:
getJaegerUiUrl(traceId)builds a URL fromNEXT_PUBLIC_JAEGER_BASE_URL, falling back towindow.location.originwith port swapped to16686.
Client-side integration functions
All UI-facing data access lives in lib/api/client.ts. Every function
branches on NEXT_PUBLIC_MACP_UI_DEMO_MODE — demo returns mock data, real hits the
proxy.
Examples Service
listPacks,listScenariosgetLaunchSchema,compileLaunchrunExample— one-shot bootstrap via/examples/run; returnscontrolPlaneRunonly when the run reached the control plane (demo mode always includes it)getAgentProfiles,getAgentProfile(returnsundefinedon 404)
Control Plane — run lifecycle
validateRun,createRunlistRuns— acceptsPartial<ListRunsQuery>, always sendslimit/offsetgetRun,cancelRun,cloneRun(accepts{ tags, context }),archiveRuncreateReplay,compareRuns,deleteRun,exportRunBundle
Control Plane — state, events, streaming
getRunStategetRunEvents— acceptsRunEventsQuery(limit,afterSeq,afterTs,beforeTs,type); legacy positional signature preservedlistEvents— cross-run wrapper with 404-fallback to per-run fan-outgetTimelineFrame—/runs/:id/replay/state?seq=<n>
Control Plane — observability
getRunMetrics,getRunTraces,getRunArtifacts,createArtifactgetObservabilityRawMetrics— streams raw/metricsexposition (no JSON parsing)getJaegerTrace,getJaegerUiUrlgetLogsData,getTraceData— convenience wrappers for the/logsand/tracespages
Control Plane — dashboard, audit, agents
getDashboardOverview— acceptsDashboardOverviewQuery; returnsdegraded: truewhen CP's/dashboard/overviewis unavailablegetAuditLogs—Partial<ListAuditQuery>getAgentMetrics— logs a warning and returns[]when CP is missing the endpoint
Control Plane — runtime and policies
getRuntimeManifest,getRuntimeModes,getRuntimeRoots,getRuntimeHealthlistRuntimePolicies,getRuntimePolicy,registerRuntimePolicy,unregisterRuntimePolicy
Control Plane — admin
getWebhooks,createWebhook,updateWebhook,deleteWebhookresetCircuitBreaker,getCircuitBreakerHistorygetReadinessProbe,rebuildProjection,getRuntimeSessionDriftbatchCancelRuns,batchArchiveRuns,batchDeleteRuns,batchExportRuns
Utility helpers (no network I/O)
getMockFrames— demo-mode replay frame sourcegetQuickCompareTarget— suggests a comparison target runlistScenarioRefs— all scenario refs from mock data
Response normalization
lib/api/client.ts bridges CP response shapes to UI types so the render layer sees a
consistent type vocabulary regardless of whether rows came from CP or from mock data.
normalizeRun()— maps flatsourceKind/sourceRefinto nestedsource: { kind, ref }; validatesid,status,runtimeKind; passesarchivedAtthrough unchanged from CP.normalizeEvent()— maps flatsourceKind/sourceName/subjectKind/subjectId/rawTypeinto nestedsourceandsubjectobjects. Applied bygetRunEvents,listEvents, and the SSEcanonical_eventhandler.- Pagination unwrapping —
GET /runsreturns{ data, total, limit, offset };listRunsunwraps.data. - Validate response mapping —
validateRuncomposesValidateRunResponsefrom CP's{ valid, errors, warnings, runtime }. - Cancel / archive envelope mapping — CP returns the full updated
RunRecord; the client extracts{ ok, runId, status }/{ ok, runId, archived }. - Dashboard chart conversion — CP's
{ labels, data }is converted to UIChartPoint[]. - Agent metrics field mapping — CP's
participantIdbecomes the UI'sagentRefbefore merging with Examples Service profiles. /eventsendpoint absence —listEventscaches aneventsEndpointMissingflag after a single 404 so older CP builds only get probed once per browser session.
Error handling
lib/api/fetcher.ts exports ApiError with status, statusText, service, path,
the verbatim body, an isNotFound getter, and the two structured accessors below.
Client functions branch on ApiError.isNotFound to return undefined for a missing entity, and
listEvents' fallback is the one degradation path that really is 404-gated. getDashboardOverview
and getAgentMetrics are not: both swallow every error and degrade (client.ts:833,
:1277), so a 500 or a network failure there is indistinguishable from an absent endpoint — the
capability simply reports itself unavailable. Everything else propagates and is caught by React
Query / error boundaries.
The three control-plane error envelopes
The control plane's GlobalExceptionFilter emits three body shapes (base format: API.md § Error Response Format; code list: TROUBLESHOOTING.md; the three shapes are summarized here because upstream docs only show the first).
What the console has to cope with:
- An
AppExceptionbody has a realerrorCode. - A Nest exception with an object body is passed through verbatim — usually no
errorCode(401s and everyPOST /runtime/policiesvalidation 400), but a hand-built body keeps its own (ENDPOINT_REMOVEDon the removed agent endpoints). The key iserrorCode, notcode. - A string-bodied exception or unhandled error is rewritten with a hardcoded
INTERNAL_ERROR, so a throttled 429 arrives labelledINTERNAL_ERROR.
A present errorCode is not always a real classification. Switch only on codes you handle and treat the
rest — INTERNAL_ERROR included — as unclassified.
ApiError.errorCode: string | undefined— the machine-readable code, present only on theAppExceptionenvelope.undefinedmeans "the backend did not classify this", never "unknown code". Use it to tell apart failures that share a status:CIRCUIT_BREAKER_OPENandRUNTIME_UNAVAILABLEare both 503 but mean different things to an operator.ApiError.detail: string | undefined— the human sentence, read frommessagein any envelope (an arraymessage, theValidationPipeshape, is joined). It deliberately does not fall back to the Nesterrorfield ("Bad Request","Unauthorized") — that restates the status rather than describing the failure. Fallbacks, in order: the raw body when it is not JSON; the raw body when it parses to an object carrying no usablemessage(so a pathological envelope still shows something rather than nothing — the trade-off is that the user may see raw JSON);undefinedwhen there is no body at all.describeApiError(error: unknown): string— renders any thrown value to a sentence fit for a user: anApiError'sdetail, else itsRequest failed with status N; a plainError'smessage; a non-emptystringverbatim; else a generic fallback. Never returns''or[object Object]. The plain-Errorrow is the commonest input, not an edge case — a network failure rejects beforefetchJsonreaches its status check, so it arrives as a bareTypeError. The result is capped at 500 characters, because an upstream body is arbitrary bytes (a multi-megabyte nginx page, a full stack trace) and this is the show-it-to-a-user boundary;detailandmessagestay uncapped for logging and matching.
Parsing is lazy and memoised: constructing an ApiError costs no JSON.parse, and an empty
body is never parsed at all. .message is unchanged — it is still the raw body, or
Request failed with status N when the body is empty.
Demo mode
When NEXT_PUBLIC_MACP_UI_DEMO_MODE=true, every client function short-circuits to mock
data from lib/data/mock-data.ts. This keeps the entire product surface
exercisable with no backend. Live-run streaming is simulated with 1600ms frame ticks
over MOCK_RUN_FRAMES.
SSE integration
Live execution uses:
GET /api/proxy/macp-control-plane/runs/:id/stream?includeSnapshot=true&afterSeq=<n>
lib/hooks/use-live-run.ts manages the subscription:
- Named events handled:
snapshot,canonical_event,heartbeat. - Auto-reconnect with exponential backoff (max 8 attempts).
- Heartbeat timeout detection (45s) — silent connections are treated as failed.
- Bounded event buffer (500 events); event IDs deduped.
- Incoming
canonical_eventpayloads run throughnormalizeEventbefore being appended. - Connection state surfaced to the UI:
idle | connecting | live | reconnecting | ended | error.
The resume cursor derives only from events actually received — never from a snapshot. afterSeq is
seeded from the highest seq among events already held, advanced only by canonical_event, and never
decreases. The server's timeline.latestSeq is its head, not what the client holds (getRunEvents fetches
at most 500 events), and snapshots are republished on every commit batch — so seeding from either skips
undelivered events permanently. The snapshot handler still applies its payload to state; it just does not
touch the cursor.
afterSeq is exclusive and the control plane replays only when afterSeq > 0 (0 = snapshot plus live
tail, nothing replayed — verified in the CP's runs.controller.ts; stream contract in the CP's
API.md).
A cold mount therefore gets history from the separate getRunEvents query, and a resume with a real cursor
makes the stream replay the missed range.
Reconnects always target the run currently mounted. RunWorkbench is reused across client-side
navigation between runs (no key={runId}), and attemptReconnect is memoized with empty deps, so it
reconnects through connectSSERef — kept pointed at the connectSSE bound to the current runId — rather
than a captured closure. Without it a heartbeat timeout or SSE error after navigating run A → B silently
re-subscribed to A and spliced A's events into B's view. Regression-tested in
lib/hooks/use-live-run.test.ts.
Two limits: the client buffer holds 500 events (MAX_EVENT_BUFFER), evicting the oldest; and
useLiveRun().events is in arrival order — the workbench merges it with fetched history via
mergeEventStreams, ordered by seq.
Gap visibility
run.historyGap (RunStateProjection.run.historyGap) is the control plane's signal that a run's
event history is known-incomplete, mirroring RunSummaryProjection.historyGap. It is set when the CP
could not resume the runtime's per-session StreamSession from its last envelope ordinal because that
history had been compacted away (the runtime answers FAILED_PRECONDITION). The CP's own doc comment
states that the console surfaces this as a fidelity warning; the console renders it as a single notice
in the event feed. Absent or false means no known gap; only true warns. The matching
session.stream.gap canonical event is emitted at the same time, carries
{ requestedAfter, detail }, and is filterable under Session in /logs.
This gap is genuinely unrecoverable, and the notice says so instead of offering a retry: the envelopes were compacted out of the runtime before the control plane could read them, so no refetch produces them.
There is deliberately no client-side seq-delta gap detector, and one should not be added.
Canonical seq values are not contiguous per run. RunEventService.persistRawAndCanonical
allocates 1 + canonicalEvents.length values from the single runs.last_event_seq counter, gives
the first to the raw row and startSeq + index + 1 to the canonical events. Raw and canonical are
separate tables, and only canonical events are streamed — so every persisted batch burns one seq that
no subscriber will ever see. The CP's own unit spec pins the behaviour (raw@5, canonical@6 and @7),
and the projection's separate timeline.latestSeq and timeline.totalEvents counters are the
corroborating tell.
A detector built on "seq jumped, therefore an event was lost" therefore fires on every healthy
run — one false warning per batch. An earlier revision of this phase shipped exactly that and it was
removed; lib/hooks/use-live-run.test.ts carries a regression test asserting the hook stays quiet on
the real 2, 4, 6, 8 pattern.
Detecting the one hole the server cannot see — an event persisted but never published, which the
non-blocking post-commit publish makes possible — needs a signal that does not assume contiguity
(comparing timeline.totalEvents against the number of distinct canonical events held is the obvious
candidate). That is deferred rather than guessed at.
The CP-side stream contract (passive-subscribe frame, replay-from-afterSeq, heartbeat
cadence) is documented in
macp-control-plane/docs/API.md § SSE Streaming
and macp-control-plane/docs/INTEGRATION.md § Consuming SSE Streams.
Run launch sequence in the UI
Standard flow
- Load launch schema (Examples Service)
- Compile with Examples Service (
POST /launch/compile) — returns whitelisted-saferunDescriptor+ pre-allocatedsessionId - Validate with Control Plane (
POST /runs/validate) - Submit to Control Plane (
POST /runs) - Redirect to the live workbench at
/runs/live/[runId]
One-shot bootstrap flow
- Call Examples Service
POST /examples/run - Examples Service compiles, mints per-agent JWTs, spawns worker processes with bootstrap files, and submits the run to the CP best-effort (see
macp-playground/docs/direct-agent-auth.md) - If
controlPlaneRuncame back, the UI redirects to/runs/live/<controlPlaneRun.runId>— the CP's own run id, which is not the session id on this path - If it did not, the UI stays on the page and warns that the Example Service did not register the run, without attributing a cause. The run may still be picked up by session discovery, which keys it by session id, so the session route is offered as a link rather than an automatic redirect. See
POST /examples/runabove.