How to Build a Harness

2. The Message Protocol

UserMessage, AssistantMessage, ToolResultMessage — the conversation wire format.

Every conversation is a flat array of three message types. This is the wire format between your harness and the LLM.

File: packages/ai/src/types.ts L194-223

export interface UserMessage {
  role: "user";
  content: string | (TextContent | ImageContent)[];
  timestamp: number;
}

export interface AssistantMessage {
  role: "assistant";
  content: (TextContent | ThinkingContent | ToolCall)[];
  api: Api;
  provider: Provider;
  model: string;
  usage: Usage;
  stopReason: StopReason;
  errorMessage?: string;
  timestamp: number;
}

export interface ToolResultMessage<TDetails = any> {
  role: "toolResult";
  toolCallId: string;
  toolName: string;
  content: (TextContent | ImageContent)[];
  isError: boolean;
  timestamp: number;
}

export type Message = UserMessage | AssistantMessage | ToolResultMessage;

A typical conversation flows: userassistant(toolCall)toolResultassistant(toolCall)toolResultassistant(text).

The AssistantMessage.content array is the critical piece. It can contain:

  • TextContent — the model’s text response
  • ThinkingContent — extended thinking / reasoning (Claude’s chain-of-thought)
  • ToolCall — a request to execute a tool

File: packages/ai/src/types.ts L169-175

export interface ToolCall {
  type: "toolCall";
  id: string;
  name: string;
  arguments: Record<string, any>;
}

Each ToolCall has an id (unique per call), a name (which tool), and arguments (JSON the LLM generated). Your harness must match each ToolResultMessage.toolCallId back to the original ToolCall.id.


Open this chapter inside the full course