ADR-0012: Surfaces: WebSocket thread protocol, REST commands, MCP¶
Status: Superseded by ADR-0048 Date: 2026-09-28 Deciders: Alex Nodeland
Context¶
Clients need:
- replay with resume after reconnecting
- several viewers per workspace
- artifacts whose versions the server owns
- live output for runs
External agents (coding assistants, desktop assistants, other services) should be able to join a workspace as participants.
Existing agent-to-UI protocols, AG-UI and the Vercel AI UI message stream, both have adapters in pydantic-ai. Both are scoped to a single HTTP request per run, and in AG-UI the client supplies the state.
Decision¶
v1 ships three surfaces. All of them submit through Workspace.commit (ADR-0002) and read through subscribe (ADR-0005):
- The WebSocket thread protocol (
artifactr.fastapi), specified in protocol.md: a hello withresume_after_seq, durable event frames, live frames, commands and results. - REST (
artifactr.fastapi): the same commands as HTTP endpoints, plus reads. - MCP (
artifactr.mcp), on the MCP SDK'sMCPServer:- artifacts are resources
- commands are tools, attributed to an
external_agentactor - change notifications flow through a
SubscriptionBusfed by the log - it is mounted on the application with
streamable_http_app()
SSE is deferred. AG-UI and Vercel AI adapters may be added later as compatibility surfaces.
Options considered¶
Option A: Our own thread protocol, plus REST and MCP (chosen)¶
| Dimension | Assessment |
|---|---|
| Complexity | Medium: we own a protocol spec |
| Fit with the model | Exact: resume, several viewers, server-owned versions |
| Ecosystem | No off-the-shelf frontend components |
Pros: the protocol expresses the workspace model directly; external agents are first-class. Cons: we maintain the spec and its schema.
Option B: AG-UI¶
| Dimension | Assessment |
|---|---|
| Complexity | Low: the adapter exists |
| Fit with the model | Partial: request-scoped runs, client-supplied state |
| Ecosystem | Strong (CopilotKit) |
Pros: a standard, with JSON Patch state deltas and interrupts that map to deferred tools. Cons: several viewers, resume and server-owned versions would all be extensions on top.
Option C: The Vercel AI UI message stream¶
| Dimension | Assessment |
|---|---|
| Complexity | Low: the adapter exists |
| Fit with the model | Poor: no shared-state concept |
| Ecosystem | Strong for TypeScript (useChat) |
Pros: drop-in for AI SDK frontends. Cons: request-scoped; artifacts would be out of band.
Option D: SSE plus POSTed commands¶
| Dimension | Assessment |
|---|---|
| Complexity | Low |
| Fit with the model | Good |
| Ecosystem | Universal |
Pros: works through restrictive proxies. Cons: a second streaming transport to maintain in v1.
Trade-off analysis¶
The workspace model (a server-owned, versioned, shared log) is the product. Adopting a request-scoped protocol would mean rebuilding that model as extensions to it. The standard protocols remain valuable for simple frontends, and pydantic-ai's adapters make adding them later cheap.
Consequences¶
- Easier: every surface behaves identically, because each is an adapter over the same workspace.
- Easier: external agents collaborate under the same rules as people.
- Harder: frontends need a client for our protocol; we provide a JSON Schema to generate types from.
- Revisit SSE and the compatibility adapters after v1.
Amendment (2026-09-29): every surface takes the authorize hook¶
The router took an authorize(tenant_id, workspace_id, actor) hook, but ArtifactrMcp did not, so an authenticated MCP client could use every workspace of its tenant however the application restricted REST and the WebSocket.
ArtifactrMcp(authorize=...)takes the router's hook and asks it on every tool call, resource read and resource subscription that names a workspace, before the workspace is opened or followed. A refusal is a tool error, or a failed resource read or subscription, carrying the message of the router's 403, "this workspace is not yours to use". Listing the tools and the resource template names no workspace, and is not asked. Without the hook, every workspace of the client's tenant is allowed, as before.- A subscription is checked when it opens. The MCP SDK serves
subscriptions/listenitself, so a middleware on the server makes a resource read's checks for each artifact a stream names: that the artifact is the client's tenant's, andauthorize. Until now a client could listen for changes to any tenant's artifacts, learning which changed and when. The check is made once per stream, as the WebSocket's is made once per connection. The SDK calls its middleware provisional; its version is pinned inuv.lock, and the tests exercise the check through the SDK's client. - The hook's type,
Authorize, lives inartifactr.workspace, besideWorkspaces.open, since an adapter may not import another (ADR-0034).artifactr.fastapi.Authorizeis the same alias, re-exported.
reflexr made the same change for its tools and resource reads in its ADR-0011.
Amendment (2026-09-29): MCP reads what REST reads, and says why it refuses¶
MCP clients could read artifacts and pending proposals, but not revisions, threads or runs, and a resource read the server refused failed with INTERNAL_ERROR, as a crash does (#49).
- Reads.
list_revisions,list_threads,get_threadandget_runreturn what REST's reads return, as JSON, or JSON lines for a list, as reflexr's MCP reads do.list_proposalstakes REST'sstatus, andlist_artifactsitsinclude_archived. They askauthorizeand are scoped to the client's tenant like every other tool.read_events, the log's read, came the same day with ADR-0005's amendment, and returns JSON lines the same way, so MCP now reads everything REST does. - Commands. Every tool carries out its command through the runner, and those that change something take an optional
command_id, deduplicated as REST's is (ADR-0022). - Refusals. A resource read of a missing artifact, of another tenant's, or in a workspace
authorizerefuses fails withINVALID_PARAMS, which the MCP SDK uses for a missing resource, and a refusedsubscriptions/listenfails the same way. The error'sdataholds the URI and the rejection as REST's error body has it, so a client can tellnot_foundfromforbidden, andINTERNAL_ERRORmeans only that the server failed. The resource handler raises the protocol error itself, since the SDK maps every otherResourceErrortoINTERNAL_ERROR. - One resolution per read. A resource read resolves its client once, and opens the workspace for it.
Action items¶
- Implement the WebSocket endpoint and REST routes in
artifactr.fastapi. - Implement
artifactr.mcpwith resources, tools and a log-fedSubscriptionBus. - Generate and check in
schemas/artifactr.v1.json.