Every few months this past year, another agent standard arrived that our platform had to speak. Each one came with its own idea of a conversation. Implement them independently and you end up maintaining four session stores, four resume mechanisms, and four definitions of the same object. I didn’t want that architecture.
The four, for the record: MCP for tools and resources, ACP for editor-style agent sessions, A2A for agent-to-agent task exchange, AG-UI for streaming front ends. This spring at iGent AI we took the other path with all of them. Fabric, the protocol layer of the agent platform I work on, treats one internal protocol as the only source of truth and every standard as a projection of it. Whether that bet holds is graded in CI. There’s one line of output our test suite asserts verbatim, character for character:
Total: 39 passed, 0 failed
That output comes from MCP’s conformance CLI running against our endpoint. The test asserts the literal string. The same battery runs the A2A TCK in its mandatory and full categories, validates the live A2A agent card with the official validator, and drives our AG-UI endpoint through the standard’s verifyEvents verifier. Each run also counts how many calls reached the inference gateway while the compliance suite executed. The count is zero. All four standards pass without a model in the loop, and the battery is cheap enough to rerun on every change.
The platform’s agents appear as authors in the git log because we use the system to build itself. It went from its first umbrella commit to twelve profiles in about ten weeks. The repository is private, so I can describe the implementation but cannot give you code to inspect.
The spine
The internal vocabulary is small and it holds still. A thread is continuity. A turn is one execution-bearing exchange inside it. A run is the work admitted behind the turn. Items, blocks, outputs, and deliveries are the public trail the work leaves. The wire shape is JSON-RPC-compatible JSON, methods named profile/noun.verb:
conversation/thread.create
conversation/turn.start
runtime/run.start
workspace/checkpoint.create
sync/barrier.await
capability/invoke
memory/memory.forget
The protocol has twelve profiles and eighty-five methods. IDs use visible prefixes such as thr_, turn_, run_, and out_, so a log line identifies the object before it is parsed. Everything pushed to a client uses one stream envelope containing a target, projection kind, monotonic sequence number, event type, payload, and trace context. Replay means requesting everything after sequence N. Identity never travels inside method names or parameters. A service authenticates as itself and separately carries delegated caller claims scoped to a tenant, user, and sometimes one thread or run. The memory profile states the general rule exactly: “identity always derived from verified auth — never from params.”
The protocol also refuses things. Internal services keep their native protocols and credentials; Fabric standardizes the boundary where products, clients, and agents meet the platform. The docs put it plainly: “Fabric standardizes meaning, not every implementation choice,” and later, “It is not a lock-in strategy. It is a coordination strategy.” The extension rule keeps it that way: service-specific concerns stay in the service, edge-specific concerns stay in the adapter, and only what is protocol-wide and transport-agnostic gets added to the shared language.
Standards live at the edge
Each standard runs as a separate supervised sidecar behind the gateway and speaks Fabric back to the platform. Together, the four adapters contain about eleven thousand lines. Most of that code handles projection and continuity through state mapping, resume cursors, and dialect shims. Threads, turns, runs, streams, and capabilities already exist underneath. The design document calls a sidecar a translator and continuity layer, not an alternate authority.
ACP is the cleanest illustration. An ACP session is a Fabric thread. The session ID and thread ID are the same string, with no translation table in the adapter. session/prompt starts a turn. Session status maps directly from turn status. The event feed reads the platform’s stream in pages and uses platform sequence numbers as its cursor. Wiping the sidecar’s cache loses nothing because an unknown session can be loaded from the platform thread.
A2A gets the same treatment with more moving parts. A contextId binds one-to-one to a thread, a task is one turn, and task state is a pure function of the turn and its outputs:
export function turnToTaskState(turn: Turn | undefined, outputs: CanonicalOutput[]): A2ATaskState {
if (turn?.status === "cancelled") return "cancelled";
if (turn?.status === "failed") return "failed";
if (turn?.status === "waiting") return "input_required";
if (outputs.length > 0) {
const hasFailed = outputs.some((output) => output.status === "failed");
if (hasFailed) return "failed";
const allFinalized = outputs.every((output) => output.status === "finalized");
if (allFinalized) return "completed";
}
if (!turn) return "queued";
if (turn.status === "completed") return "completed";
if (turn.status === "accepted") return "queued";
if (turn.status === "running") return "working";
return "working";
}
That function feeds two A2A wire dialects: the proto-style TASK_STATE_WORKING family and the newer slash-style working family. The caller’s method-name dialect selects the response. A monotonicity guard prevents a completed task from regressing to working after a stale projection read. If a turn produces no structured output, the adapter sends its assistant transcript as an artifact so the A2A client still receives something inspectable.
AG-UI is a transliteration. The platform’s live stream events map to the AG-UI vocabulary: turn_started becomes RUN_STARTED, and block deltas become TEXT_MESSAGE_CONTENT. The SSE event ID is the platform’s stream sequence number, so a client’s Last-Event-ID header works as a replay cursor. On reconnect, the adapter runs replay and live subscription concurrently, then deduplicates by sequence across the boundary.
MCP has the most reach, but uses the same projection. Every capability registered on the platform appears with its schema in tools/list for every connected MCP client. No adapter code is needed per capability. A tools/call resolves to capability/invoke and carries the caller’s own Authorization header, so MCP adds no ambient authority. Other parts of the surface use the same method. Platform state appears as resources under a fabric:// URI scheme. Sampling and elicitation use the session’s SSE stream. Third-party MCP servers can be mounted as upstreams and re-exported with names such as upstream::tool.
The first responder is an agent
Another agent is increasingly the first responder to a broken run, so Fabric’s observability must work for that reader. Trace context is a schema field on every request and stream event in all five SDK languages. It survives WebSocket, SSE, and stdio transports that would silently drop an HTTP header. The reference server copies thread, run, and turn IDs from each request into trace baggage without extra service code. Span recording attaches with one middleware line.
I got the first version wrong. One browser-driven test generated more than three thousand spans from a single service. Successful high-frequency reads accounted for 88% of them, which left three thousand records that nothing was wrong. Read-heavy methods now emit spans only when they fail. Healthy reads do not appear.
A simpler channel sits beside the traces. When one environment variable is set, a fire-and-forget debug tap mirrors every request, response, and stream delta to a local relay. Otherwise it is a no-op. Its header comment says “remove this entire file to disable,” and the Go implementation writes its own WebSocket frames so the SDK stays dependency-free.
Two consumers read that firehose outside the request path. A live topology visualiser draws services as nodes and animates messages between them. Its README says that it shows the edges the system actually emits, rather than the ones claimed by a stale diagram. A single Rust binary handles the time-indexed data through OTLP ingest over gRPC and HTTP, an embedded waterfall UI, no Docker, no configuration files, no external database, and bounded memory. Its README says debugging the system should not require operating another distributed system. Agents use an API instead of the UI. For each trace it returns the critical path, error chain, and a scored root cause with evidence and confidence. Every response includes an estimated token count and suggested next calls.
Ten hours to a new profile
The newest profile handles memory. It landed in six files and 240 added lines, including the specification table row, roughly ten hours after its first commit. Profiles follow an established template: a types package, a registration helper, and a specification row naming the owning authority and identity rule. Capabilities require even less work. Fill in a descriptor and the capability becomes discoverable over capability/list, invokable over capability/invoke, and visible in every MCP client’s tools/list. Nothing is registered at the edge.
Five SDKs, no code generation. Parity is enforced: a conformance manifest whose first rule is “update the manifest and fixtures before changing SDK behavior,” and a wire-shape gate that parses Go json tags, Rust serde renames, Pydantic aliases, Swift CodingKeys, and TypeScript interfaces into one field-level snapshot, then fails CI on any drift between languages. The non-goals list carries an exceptions table where every entry has an owner and a written condition for its own removal.
Plenty remains unfinished, and the specification records it. The per-language bindings table is “intentionally uneven.” ACP exposes nine methods and has no approval or steering surface yet. The A2A agent card declares streaming: false, with SSE offered as a REST extension. MCP resource subscriptions acknowledge requests but do not yet deliver a real change feed. When a README or older design document disagrees with the code, the code wins.
What a fifth standard would cost
A new sidecar starts from a 66-line stub, and each finished adapter has a three-line entry point. The platform supplies process supervision with restart backoff, header hygiene at the edge, per-caller isolation, shared rate limiting, and crash-safe state files. Every session and task is bound to a hash of the caller’s authentication header, and cross-caller access is refused. Adapters treat their state files strictly as caches. After a restart they reconcile with the platform instead of trusting local disk. The conformance battery reruns on every change, so a fifth adapter would inherit both its tests and its plumbing.
If your platform is standing at the same fork, put each standard’s own conformance suite in your CI before you write the adapter, and build underneath it. Here, the build starts at a 66-line stub.