8 The Typed and Authorized Tool Loop
Part II · The portable model runtime
Tool and JSON Schema sources checked: 2026-10-07
A tool call crosses from probabilistic generation into software authority. Treat the model’s proposed name and arguments as untrusted input, validate them against a pinned schema, authorize the action, execute under limits, and correlate exactly one result with the call.
A typed tool loop is a bounded state machine that advertises tool contracts, receives proposed calls, validates and authorizes them, executes approved implementations, records correlated results, and gives those results back to the model for the next step.
8.1 The loop
A complete tool step has more stages than “call a function”:
advertise schemas
↓
model proposes call(name, callId, input)
↓
resolve name → validate input → authorize → apply limits
↓
execute implementation → validate/encode output
↓
record result(callId, name, output)
↓
continue model generation or stop
Every arrow is a failure and security boundary. The model can choose an unknown name, produce malformed input, repeat a call, or request an action outside the user’s authority. The implementation can time out, return invalid data, or complete a side effect after cancellation.
8.2 Define one portable tool contract
A neutral contract can use JSON Schema directly. The following is an architecture sketch, not the full v0.4.0 type. That release also carries trusted execution context, authorization and approval hooks, deadlines, and output-byte limits:
interface ToolDefinition {
name: string;
description: string;
inputSchema: Record<string, unknown>;
outputSchema?: Record<string, unknown>;
execute?: (
input: unknown,
context: { abortSignal?: AbortSignal },
) => unknown | Promise<unknown>;
}Use JSON Schema Draft 2020-12 as the project dialect and declare it when schemas leave the process (“JSON Schema Draft 2020-12,” n.d.). Do not assume a provider supports every keyword. The provider-facing adapter may need a documented compatible subset, while the canonical application schema remains unchanged.
A small schema should be precise:
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"prototype": { "enum": ["A", "B"] },
"metric": { "const": "runtime" },
"unit": { "const": "hours" }
},
"required": ["prototype", "metric", "unit"],
"additionalProperties": false
}Descriptions help a model select and fill a tool, but validation keywords enforce the machine contract. Test the validator configuration: in JSON Schema, format is annotation by default unless format assertion behavior is enabled (“JSON Schema Draft 2020-12,” n.d.).
8.3 Schema is not TypeScript
A TypeScript type disappears at runtime. An assertion such as input as MeasurementInput proves nothing about provider data. Runtime validation must occur before execution, and the validated value—not the original object—should reach the implementation.
Conversely, a runtime schema does not automatically give every TypeScript call site a useful static type. Schema-derived types or typed schema libraries can reduce drift, but generated types must be reproducible and checked in CI.
The AI SDK accepts Zod or JSON Schema input definitions, but its low-level jsonSchema helper validates values only when supplied with a validation function (“AI SDK Core,” n.d.-b). Release v0.4.0 supplies that callback using pinned Ajv 8.17.1 and a documented Draft 2020-12 cross-draft keyword profile. Its invalid-generated-input fixture asserts zero executions and a correlated failed outcome. The tested chapter runtime makes the validation step explicit without depending on the SDK. Neither a TypeScript cast nor an SDK wrapper alone proves dialect support.
8.5 Correlation is mandatory
A tool name is not enough to match calls and results. The same tool can be called twice or in parallel. These architecture types carry an opaque callId from proposal through result:
type ToolCall = {
callId: string;
name: string;
input: unknown;
};
type ToolResult = {
callId: string;
name: string;
output: unknown;
};The tested chapter runtime below receives trusted request context, validates before authorization, executes once per call ID, validates output, and returns one explicit terminal outcome. It is an application policy example, not an Open Responses or MCP implementation. Its complete source is code/foundations.ts.
export type ToolContext = Readonly<{
requestId: string;
principal: string;
tenant?: string;
callId: string;
signal?: AbortSignal;
}>;
export type ToolContract<Input, Output> = {
name: string;
validateInput: (value: unknown) => Input;
authorize: (input: Input, context: ToolContext) => void | Promise<void>;
approve?: (input: Input, context: ToolContext) => void | Promise<void>;
execute: (input: Input, context: ToolContext) => Output | Promise<Output>;
validateOutput: (value: unknown) => Output;
};
export type ToolOutcome<Output> =
| { status: "completed"; output: Output }
| { status: "failed" | "cancelled" | "outcome_unknown"; code: string };
export class ToolRuntime {
readonly #run: Omit<ToolContext, "callId">;
readonly #calls = new Set<string>();
constructor(run: Omit<ToolContext, "callId">) {
this.#run = Object.freeze({ ...run });
}
async invoke<Input, Output>(
tool: ToolContract<Input, Output>,
callId: string,
proposedInput: unknown,
): Promise<ToolOutcome<Output>> {
if (this.#calls.has(callId)) return { status: "failed", code: "duplicate_call_id" };
this.#calls.add(callId);
if (this.#run.signal?.aborted) return { status: "cancelled", code: "cancelled" };
const context = Object.freeze({ ...this.#run, callId });
try {
const input = tool.validateInput(proposedInput);
await tool.authorize(input, context);
await tool.approve?.(input, context);
const output = await tool.execute(input, context);
return { status: "completed", output: tool.validateOutput(output) };
} catch {
return this.#run.signal?.aborted
? { status: "outcome_unknown", code: "cancelled_after_start" }
: { status: "failed", code: "rejected_or_failed" };
}
}
}Check that:
- the result references a known pending call;
- the name agrees with the original call;
- one call does not receive two terminal results;
- a result is recorded before the next model step consumes it; and
- replay does not execute an already completed call again.
Provider item identifiers and tool-call identifiers may be different. Preserve both when needed rather than inventing equality.
8.6 Streamed arguments
Some provider protocols stream function arguments as string fragments; SDKs may expose only a validated final input. If your boundary receives fragments, accumulate them by call identifier under byte and time limits. Do not execute a prefix that happens to parse as JSON. Wait for the protocol’s argument-done or tool-call event, parse exactly once, then validate.
Malformed arguments are a model/tool-call error, not an invitation to guess. A repair mechanism, if enabled, is another bounded model step with its own telemetry and disclosure policy.
The application contract should normally emit a complete tool-call event. Raw argument deltas belong only in a lower-level protocol lesson or diagnostic stream.
8.7 Execute under limits
Each tool needs policy beyond its schema:
| Limit | Purpose |
|---|---|
| Timeout/deadline | Bound downstream latency |
| Cancellation signal | Stop cooperative work when the run ends |
| Concurrency | Protect local and remote resources |
| Input/output bytes | Prevent memory and context exhaustion |
| Rate or cost budget | Bound financial and service impact |
| Network/filesystem sandbox | Limit reachable authority |
| Idempotency key | Make retries and replay safer |
The tool description shown to the model should explain function, not disclose credentials, internal topology, or policy bypasses.
8.8 Return structured results
A tool result is data for the model and possibly the user. Prefer a bounded object with explicit fields over an unstructured log dump. Validate an output schema when downstream code depends on the shape.
Tool output remains untrusted even when it comes from your own connector. A remote page, database row, or MCP server can contain instructions aimed at the model. Label provenance and keep data distinct from system instructions.
Structured model output is related but separate. A response schema constrains the model’s final generated object; a tool output schema constrains software returned by a tool. Neither guarantees factual correctness or authorization.
8.9 Continue or stop
After tool results are added to the transcript, the model can produce text or request another tool. The loop needs a hard stop condition. Release v0.4.0 uses the AI SDK’s step-count stop condition: four steps by default when executable tools are present, otherwise one, unless maxSteps is supplied (“AI SDK Core,” n.d.-a).
A production policy can combine:
- maximum model steps;
- maximum total tool calls;
- per-tool call limits;
- elapsed deadline;
- token and monetary budgets;
- repeated-call detection; and
- an application-specific terminal tool or result.
Count completed steps precisely and define whether the initial model call is step one. Ambiguous counters create off-by-one safety defects.
A model can repeatedly request a harmless tool and still exhaust tokens, money, or downstream capacity. Enforce limits outside the model and report which guard ended the run.
8.10 Tool errors
Distinguish at least:
- unknown tool;
- invalid arguments;
- unauthorized action;
- approval denied or expired;
- timeout or cancellation;
- execution failure;
- invalid tool output; and
- budget exhausted.
Decide which errors are safe to return to the model. A concise “resource unavailable” result may help it recover; a database stack trace or authorization internals may disclose sensitive information. Public application errors and protected operator diagnostics should be separate objects linked by a correlation identifier.
Do not automatically retry non-idempotent tools. For an ambiguous timeout, the correct result may be “outcome unknown; reconcile” rather than “failed.”
8.11 Parallel tool calls
Parallel execution can reduce latency when calls are independent. It also complicates budgets, ordering, cancellation, and side effects. Before enabling it, define:
- whether calls commute;
- a maximum concurrency;
- deterministic result ordering;
- whether one failure cancels siblings;
- how partial success is represented; and
- whether the next model step waits for every result.
Sequential execution is a reasonable portable default. Parallelism is a capability and policy choice, not a free optimization.
8.13 Portability and evidence ledger
| Dimension | Chapter claim | Evidence | Not established |
|---|---|---|---|
| Transport | Tool policy is independent of local or remote invocation | Tested local port | MCP transport behavior |
| Provider | Neutral contracts cross the common model loop | v0.4.0 continuation and invalid-input/output fixtures |
Live tool-policy equivalence across providers |
| Protocol | Open Responses function calls guide the design | Pinned source and current compatible HTTP subset | Formal conformance |
| Feature | Validation, authorization, correlation, and replay are separate controls | Book runtime and tagged adapter/runtime tests | Durable replay, full approval, sandbox, and cost enforcement |
| Semantic | Structured output does not guarantee truth | No positive claim | Equivalent tool selection or answer quality |
8.14 Security checklist
Before enabling a tool:
8.15 Pattern summary
Problem: Model-generated tool calls can be malformed, unauthorized, duplicated, unbounded, or incorrectly correlated.
Pattern: Validate a pinned schema, authorize with trusted context, execute under limits, validate and correlate the result, then continue through a bounded shared loop.
Result: Provider-specific tool syntax becomes a controlled application operation with explicit authority and evidence.
Cost: Each tool needs schemas, policy, limits, error design, idempotency analysis, and deterministic tests.
8.16 Exercises
- Add an output schema to the measurement tool and define the behavior for invalid connector data.
- Design an approval token that cannot be replayed with different arguments.
- Decide how an ambiguous timeout is represented for a payment tool.
- Add repeated-call detection without blocking two legitimate calls with different inputs.
- Specify deterministic ordering for three parallel, read-only tool results.
8.17 Further reading
Use JSON Schema Draft 2020-12 for the canonical contract (“JSON Schema Draft 2020-12,” n.d.), the pinned AI SDK tool documentation for the current implementation boundary (“AI SDK Core,” n.d.-b), and the Open Responses function-call item model as the later protocol target (“Open Responses Reference 2026-04-24,” n.d.). Chapter 18 applies the same application policy to tools discovered through MCP (“Model Context Protocol Specification 2025-11-25,” n.d.).
8.18 Chapter contract
- Scope: tool schemas, authorization, execution, correlation, and bounded results.
- Canonical claims: generated arguments propose an action; only server policy authorizes execution.
- Migration inputs: former typed-tool-loop chapter and Northstar measurement tool.
- Evidence gates: malformed input, denied authority, duplicate calls, cancellation, and output validation.
- Demonstration slots: deterministic local tool trace.
- Source-claim ledger: base runtime claims use
v0.4.0; server-owned research tools/progress use snapshot45a903c; API resemblance and compatible-subset behavior are not conformance. - Exclusions: provider translation belongs to Chapter 5, protocol item/event vocabulary to Chapter 6, stream handling to Chapter 7, skill procedure to Chapter 9, retries/reconciliation to Chapter 15, telemetry to Chapter 16, and remote MCP transport to Chapter 18.