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.
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.
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.