What should be identical in a five-language agent SDK
Which parts of a multi-language SDK must match exactly, such as errors, identity and event order, how shared fixtures enforce them, and what each language keeps.

Imagine a service answers a request with a rate-limit error. The TypeScript client treats that as retryable and backs off. The Go client, written with equally good instincts, hands it to the caller as a failure, and the agent built on top of it stops halfway through a task. Both SDKs can be perfectly idiomatic, and the same agent now behaves differently depending on which language its author happened to prefer. That is the parity failure I care about in a multi-language SDK, and it has very little to do with whether five client libraries offer the same convenience methods.
Between April and June 2026 I developed our shared agent protocol and expanded its TypeScript, Go, Python, Rust and Swift SDKs, building on existing SDK foundations. I also took the Rust server from a stub to an authenticated registration surface. The answer I arrived at is narrow. Keep identical whatever another party can observe or act on: method names, wire shapes, error codes and their retryability, the fields of a verified identity, the order of events in a stream and the rules for picking one up again. Let almost everything else vary.
Names are the cheapest part of that list. A method such as core/initialize kept the same name across SDKs and transports. Operations were grouped into twelve typed profiles that said nothing about how bytes move, and HTTP, WebSocket, SSE and stdio sat underneath them as bindings. With the two layers separated, an SDK can lack a binding without having a different protocol, and a service can change how it is reached without changing what its operations mean.
The harder items can’t be kept identical by describing them in prose, and five languages have no single implementation to share, so the contract had to be data. The conformance fixtures were files that the SDKs’ own tests read. One listed twenty error codes and whether each may be retried, with rate_limited, gap_detected and internal among the retryable ones, and tests in Go, Python, TypeScript and Rust read that one file, which is how the disagreement in my opening example gets caught before any agent stops halfway through a task. Another pinned the verified authentication context: its canonical fields, the alias spellings seen from Rust, Python and Go, and two cases that must count as invalid. Others went after behavior rather than vocabulary, such as the order of events in a tool-use stream and an invariant on sequence numbers when a realtime connection comes back.
Authentication is where I would push hardest, because every SDK can look right while a service trusts the wrong thing. The Rust server defined a trait for each profile and advertised only the profiles of the services actually attached to it. When a request carried an authentication envelope, a configured verifier checked it and the verified identity was passed to the handler; if no verifier was configured the request was refused as unauthenticated, rather than being waved through on credentials nobody had checked.
Bindings and convenience layers remained uneven, and the contract needed to say where. Casing still differed, with camelCase as the norm, so the authentication fixture included the accepted aliases. The parity gate checked wire shapes, with deeper fixture coverage in TypeScript than elsewhere. Naming that scope matters: agreeing on an error or an identity field is useful even when one SDK has a transport binding or a convenience layer that another lacks.
Wire shape got this attention because wire shape is where things broke. In our agent runtime, TypeScript emitted stream updates in one form while Python wrapped the same payload in an older envelope of id, timestamp, source and data. Nothing about that was wrong on Python’s own terms, but the extra level of nesting broke event translation downstream. On a branch in April I added a direct path for emitting an event type and its data across the foreign-function boundary, matching the TypeScript payload and keeping fallbacks for older shims, instead of wrapping the new payload in the old envelope. That is the argument for pinning a shape in a fixture: the mismatch then fails in the language that caused it, rather than in the translator that trips over it.
A shared shape can’t create a capability, and one branch adapter showed that well. It connected the protocol’s inference profile to our existing multi-provider inference service, translating requests, checking authentication, scope and the run and thread binding, and mapping provider results and streams onto the shared types. Generation worked. Cancellation and lookup didn’t, because the service kept no cancellable run handles across requests and retained no earlier responses by request id. So Cancel returned Accepted: false, with a comment explaining that “there is nothing to stop”, and Get returned a 404. Those are the right answers given the facts, and a checklist of method names would have marked the adapter complete. The protocol didn’t have to swallow what sat behind it, either. Our established runtime and inference services kept their own native interfaces, and the runtime’s profile was, in the spec’s phrase, a “thin bridge”.
The outer edge needed the same separation. Sidecars exposed threads and turns through the Agent Client Protocol and A2A, mapping session and task creation, prompts, stop and resume, transcripts and status onto our existing gateway, forwarding the caller’s context and keeping session bindings, so that an external client used the same thread runtime instead of a separate copy of agent state for each protocol. On a branch of the runtime, an ACP layer translated messages, thoughts, tools, plans and usage into ACP’s own session updates and sent anything runtime-specific through an extension channel, rather than bending the standard types to fit. Both protocols belong to their authors and that layer was built on the upstream ACP SDK; what I added was the mapping.
If I had to reduce all this to a test, an item belongs on the identical list when two clients written in different languages could disagree about it and a third party would pay for the disagreement: a service retried when it shouldn’t be, an identity read two ways, a translator handed the wrong nesting. A builder in Swift and a context argument in Go will never cause that, and forcing them to look alike only makes each worse in its own language.
Chris Chabot, November 2026