Typed Message Contracts Between AI Agents in TypeScript
Your orchestrator agent calls the specialist agent. It passes an object with customer_id, amount and action. The specialist agent reads customerId (camelCase, not snake_case), gets undefined, and continues processing with empty data.
No exception is thrown. No error in the logs. The operation "completes" with a silently incorrect result.
This failure exists in the majority of multi-agent systems we audit — and it typically gets discovered weeks later, when someone notices the numbers don't add up. At that point there's no way to know how many operations failed that way.
Why inter-agent messages have no contract
When you build a single agent, you trust the LLM returns what you asked for. When you connect two agents, that trust compounds: you trust that Agent A returns what Agent B expects, with the same field name, the same type, the same value range.
In practice, inter-agent contracts break for three reasons:
1. Independent evolution. Agent A adds a field, changes a type, or renames a property. Nobody updates Agent B. The change passes A's unit tests because A works alone. It fails in production when A and B work together.
2. Optional fields becoming required. What started as metadata?: Record<string, unknown> becomes a field Agent B needs to make decisions. But because it was optional, A doesn't always include it.
3. Implicit types. Agent A returns amount as the string "1250.00". Agent B expects a number. TypeScript at compile time doesn't catch this because the LLM output is typed as unknown or any.
Defining explicit contracts with Zod
The solution is to treat messages between agents exactly like data arriving from an external API: parse against a schema before using them. If parsing fails, fail fast with a clear error — not silently with an undefined.
import { z } from 'zod'
// Shared contract between Orchestrator Agent and Billing Agent
const BillingMessageSchema = z.object({
customerId: z.string().uuid('customerId must be a valid UUID'),
amount: z.number().positive('Amount must be positive'),
currency: z.enum(['EUR', 'USD', 'GBP']),
action: z.enum(['create_invoice', 'send_reminder', 'cancel']),
version: z.literal('v2'),
})
type BillingMessage = z.infer<typeof BillingMessageSchema>
// In the receiving agent — validate before acting
async function handleBillingRequest(rawMessage: unknown): Promise<void> {
const result = BillingMessageSchema.safeParse(rawMessage)
if (!result.success) {
// Structured log with the exact error for debugging
console.error('Contract violation', {
errors: result.error.issues,
received: rawMessage,
schema: 'BillingMessageSchema@v2',
})
throw new Error(`Invalid message contract: ${result.error.issues[0].message}`)
}
const message = result.data // TypeScript knows the exact type here
await processBillingAction(message)
}
The difference versus adding as BillingMessage in the receiver is total: a TypeScript cast is a promise to the compiler, not a runtime guarantee. Zod validates the actual data, at the moment it arrives.
Contract versioning: evolving without breaking
Contracts change. Version 1 had amount: number. Version 2 adds currency as a required field. If you update the schema without maintaining backward compatibility, you break all agents still sending v1 messages.
The most robust pattern is a version-discriminated schema:
const BillingMessageV1 = z.object({
version: z.literal('v1'),
customer_id: z.string(), // snake_case in v1
amount: z.number(),
})
const BillingMessageV2 = z.object({
version: z.literal('v2'),
customerId: z.string().uuid(), // camelCase in v2
amount: z.number().positive(),
currency: z.enum(['EUR', 'USD', 'GBP']),
action: z.enum(['create_invoice', 'send_reminder', 'cancel']),
})
const BillingMessage = z.discriminatedUnion('version', [
BillingMessageV1,
BillingMessageV2,
])
type BillingMessage = z.infer<typeof BillingMessage>
// In the receiver — handle both versions
async function handleMessage(raw: unknown): Promise<void> {
const parsed = BillingMessage.safeParse(raw)
if (!parsed.success) throw new Error('Unknown message version')
if (parsed.data.version === 'v1') {
// Normalize v1 to v2 before processing
return handleNormalized({
version: 'v2',
customerId: parsed.data.customer_id,
amount: parsed.data.amount,
currency: 'EUR', // default for v1 messages
action: 'create_invoice',
})
}
return handleNormalized(parsed.data)
}
This pattern enables gradual migration: new agents send v2, agents not yet updated send v1, and the receiver handles both without breaking.
Validating on both ends of the message
A practice that cuts the bug surface area in half: validate on both the sender and the receiver side.
// On the SENDER — validate before sending
async function sendToBillingAgent(message: BillingMessage): Promise<void> {
const validated = BillingMessageV2.parse(message)
await agentQueue.publish({
target: 'billing-agent',
payload: validated,
timestamp: new Date().toISOString(),
})
}
// On the RECEIVER — validate on arrival
async function receive(raw: unknown): Promise<void> {
const message = BillingMessage.safeParse(raw)
if (!message.success) {
await alertOps('Contract violation at billing-agent receiver', message.error)
return
}
await process(message.data)
}
When the sender validates before publishing, it catches bugs in the agent generating the message — where most originate. When the receiver validates on arrival, it catches messages from any unexpected source: a message manually enqueued for debugging, an old agent version not yet deployed, a test sending example data.
The multi-agent systems we build for production include this pattern from the first sprint. Adding it after the fact is significantly more expensive — especially when there are multiple agents in the graph.
Centralizing schemas as shared contracts
The most common mistake teams make when adopting this pattern is duplicating schemas. Agent A defines BillingMessageSchema in its own file. Agent B defines another BillingMessageSchema in its own file. The two schemas will drift.
The solution is extracting contracts into a shared package:
// packages/agent-contracts/src/billing.ts
export const BillingMessageSchema = z.object({ ... })
export type BillingMessage = z.infer<typeof BillingMessageSchema>
// In Agent A
import { BillingMessageSchema } from '@company/agent-contracts/billing'
// In Agent B
import { BillingMessageSchema, BillingMessage } from '@company/agent-contracts/billing'
In a monorepo with Turborepo or nx this is immediate. In separate services, a package published to a private registry. The contract lives in one place; when it changes, all consumers update together.
This approach also makes it straightforward to add contract tests — tests that verify the real messages circulating in production are still compatible with the published schema, without spinning up all agents. It's the same idea as Pact or Spring Cloud Contract, applied to AI-powered system integration.
The most expensive silent failure
A mistyped field between agents doesn't produce a visible exception. It produces an incorrect decision made with incorrect data: an invoice created with amount 0, a customer contacted with another customer's message, an action executed when it shouldn't have been.
The cost isn't debugging time — it's the real business impact that already happened while the bug was active.
Typed contracts with runtime validation aren't engineering overhead. They're the guarantee that when two agents collaborate, they do so on data both understand the same way.
If you're building a multi-agent system and want the contracts between your agents to be solid from day one, tell me what you're working on — we'll review the agent graph design together before the first bug reaches production.