Chapter 20

Assemble a Shared Runtime Around a Registry

The registry is what ties a tool's public definition to its Zod validation, risk class, and server-only handler in one place. The provider adapter gets only the definitions (what the model sees), and the runtime owns the execution (what the domain sees). One entry - one clear boundary.

TypeScript
type ToolEntry = {
  definition: ToolDefinition;
  input: z.ZodType;
  risk: "read" | "preview" | "terminal";
  execute?: (value: unknown, context: TrustedToolContext) => Promise<unknown>;
};

export const toolRegistry = new Map<string, ToolEntry>([
  ["policy_search", policySearchEntry],
  ["order_read", orderReadEntry],
  ["refund_preview", refundPreviewEntry],
  ["handoff_prepare", handoffPrepareEntry],
  ["answer_user", answerUserTerminal],
  ["ask_clarification", clarificationTerminal],
  ["request_refund_approval", approvalTerminal],
  ["request_handoff", handoffTerminal]
]);

export const providerTools = Array.from(toolRegistry.values()).map(
  function getDefinition(entry) {
    return entry.definition;
  }
);

The server route for a turn brings it all together: it checks the session, applies the rate limit, builds the trusted context, and runs the agent loop with the chosen provider.

TypeScript
const turnInput = z.object({
  conversation_id: z.string().uuid(),
  message: z.string().min(1).max(4_000),
  provider: z.enum(["openai", "anthropic", "google"])
}).strict();

export async function POST(request: Request) {
  const session = await requireSession(request);
  await rateLimit.consume(`support:${session.tenantId}:${session.userId}`);
  const body = turnInput.parse(await request.json());
  const traceId = crypto.randomUUID();

  const context: TrustedToolContext = {
    actorId: session.userId,
    tenantId: session.tenantId,
    locale: session.locale,
    traceId
  };

  const provider = providerFactory.get(body.provider);
  const outcome = await runAgent(provider, {
    message: body.message,
    instructions: buildPrompt(session.locale),
    tools: providerTools,
    traceId
  }, context);

  return Response.json({ trace_id: traceId, outcome });
}

Note the provider field in the input - it's needed for the lab and cross-provider eval, but in production you must not do it this way.

The provider is chosen by configuration. In a real product, don't let an arbitrary user switch providers. The field is shown for the lab and cross-provider eval; production routing is determined by server policy and tenant configuration.

The unified runtime is assembled, and it can work with any provider - because all provider specifics are moved into adapters. Let's take them one at a time, starting with OpenAI.

Links