Chapter 6

Unify the Business Contract Without Hiding the API's Semantics

It's tempting to hide three different APIs behind one "universal" interface. But a useful shared layer knows exactly what's genuinely common: tool calls, results, usage, and opaque provider state. It does NOT pretend that all APIs have identical messages or an identical way to continue the conversation - that false symmetry is exactly where homemade wrappers break.

The contract fits in a few types.

TypeScript
export type ToolCall = {
  id: string;
  name: string;
  arguments: unknown;
};

export type ToolResult = {
  callId: string;
  name: string;
  output: unknown;
  isError: boolean;
};

export type ProviderTurn =
  | { kind: "tool_calls"; calls: ToolCall[]; state: unknown; usage?: Usage }
  | { kind: "text"; text: string; state: unknown; usage?: Usage };

export interface ProviderAdapter {
  start(input: AgentInput): Promise<ProviderTurn>;
  resume(state: unknown, results: ToolResult[]): Promise<ProviderTurn>;
}

export type AgentInput = {
  message: string;
  instructions: string;
  tools: ToolDefinition[];
  traceId: string;
};

The key detail here is state: unknown. The shared runtime stores the continuation object but never looks inside it: for OpenAI it may be a previous_response_id, for Anthropic the full Messages array, for Google a previous_interaction_id. A simple split helps sort out what carries over and what doesn't. Stable - tool names, JSON schemas, risk classes, outcomes, actor context, eval cases. Adapted - the tool-definition shape, call ids, content blocks, the continuation method. Not portable - built-in provider tools, traces, managed sessions, prompt caching, and beta features.

That's exactly why opaque state is a deliberate decision, not laziness.

Opaque state is deliberate. The shared runtime stores the continuation object but doesn't interpret it. The moment you try to "understand" another provider's state and port it to a different one, you get a silent incompatibility instead of an honest boundary.

The contract sets the shape. The first thing that lands in it is tools - and a good tool is closer to an API contract than to a "function for the model."

Links