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.4 Validation is still not authorization

A valid request can be forbidden. The following policy-order sketch evaluates authorization with trusted context after structural validation:

const input = validate(tool.inputSchema, proposedInput);

await authorize({
  principal: run.principal,
  action: `tool:${tool.name}`,
  resource: resourceFrom(input),
  tenant: run.tenant,
});

const output = await tool.execute(input, { abortSignal: run.signal });

Do not let the model supply the principal, tenant, approval result, or policy scope as ordinary tool arguments. Those values come from authenticated application context.

For high-impact tools, insert human approval after validation and policy evaluation but before execution. Bind approval to a digest of the exact tool name, normalized arguments, principal, and expiry; otherwise the approved call can be swapped.

The measurement tool is read-only, but it still has a threat model: the model can invent a prototype field, a caller can cross a tenant boundary, a connector can return adversarial text, duplicate call IDs can trigger repeated cost, and cancellation can leave the measurement’s status uncertain. Its trusted request context is never accepted from generated arguments.

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.

WarningA loop limit is a safety control

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.12 The shared SDK-backed loop

The immutable v0.4.0 reference constructs dynamic tools from neutral definitions and supplies them to one streamText call. The shared layer registers explicit input/output validators, delegates executable tools to a request-scoped ToolPolicyRuntime, propagates the SDK abort signal, and continues across bounded model steps. It emits normalized tool calls, terminal tool-result outcomes, and finish events with optional token counts, and sanitizes surfaced errors. Supplying authorization or approval hooks requires trusted toolContext before model execution; the reference does not authenticate that context itself.

The tagged adapter tests exercise this successful sequence:

  1. the model emits a tool call;
  2. the SDK parses the arguments and the configured validator accepts them;
  3. the runtime applies configured policy hooks and executes the implementation;
  4. a completed tool-result outcome is correlated with the call;
  5. the shared loop starts the next model step; and
  6. final text and aggregate usage reach the same application contract.

The successful fixture does not configure every policy hook. Separate adapter tests reject invalid generated input before execution, report invalid output as a correlated failure, and reject policy-bearing definitions without trusted context before calling the model. Tagged runtime tests cover authorization/approval ordering, duplicate-call rejection, pre-execution cancellation, execution timeout, invalid output, and registration checks. These are bounded deterministic proofs, not an authentication service, cryptographically bound approvals, parallel-call safety, or side-effect reconciliation. The retained live text-generation checks do not establish live tool-policy behavior.

8.12.1 Tool ownership across the live client/server boundary

The current reviewed live profile disables Pi-local executable tools and registers five server-owned research tools selected by agent.yaml: image_understanding, web_search, read_source, code_interpreter, and presentation_builder. A sixth server-owned operation, load_skill, admits reviewed non-executable procedure; it does not become an executable capability.

The server owns each selected tool’s schema, policy, progress summaries, request-scoped quota, deadline, execution, and terminal result. Pi renders progress and can retrieve a presentation artifact, but it does not execute these research calls. This differs from a generic Responses client that advertises its own function schemas and later submits outputs; authority belongs to the component that actually executes the operation.

Model-generated fields cannot choose provider credentials, endpoint, arbitrary path, package, executable, or presentation code. Tools that need request assets accept invocation-local source IDs. Current configuration selection still is not principal authentication: the loopback development server is unauthenticated, and production identity/policy remain Chapter 17 gates.

The coordinator output-token ceiling is separate from tool call counts, source budgets, worker deadlines, mounted bytes, result-size limits, and artifact TTL. One configured number never implies complete resource control.

8.12.2 User-visible progress

A long tool can emit bounded progress without exposing its raw arguments or result. The current protocol associates progress with the response and call, uses fixed safe labels/summaries, and lets Pi render expandable cards outside model context. Progress must be monotonic and terminally consistent with the call result.

Progress is not authority, evidence of semantic success, or operator telemetry. Rendering a card cannot execute, retry, approve, or cancel a tool. Sensitive queries, document bodies, image bytes, source IDs, Python code/output, credentials, and raw provider errors stay out of ordinary progress. Chapter 16 separately owns OpenTelemetry once implemented.

8.12.3 Implemented policy and remaining boundary

Control Reviewed evidence Remaining boundary
Input validation v0.4.0 schema callback plus current strict tool-specific schemas Full provider/dialect parity
Trusted run context Required for policy hooks; request/call identity forwarded Production authentication and identity verification
Authorization/approval Hooks run before execution; denial/order fixtures Production policy service and cryptographically bound approval
Limits Steps, per-tool calls, deadlines, cancellation, input/output and artifact ceilings Complete cumulative cost and production isolation
Output validation Schema/serialization checks and correlated invalid-output failures Semantic truth and arbitrary connector safety
Tool result Calls receive one correlated terminal outcome Ambiguous remote-effect reconciliation in Chapter 15
Progress Bounded call-correlated lifecycle cards outside model context Operator traces and metrics in Chapter 16
Replay Duplicate IDs rejected within one runtime instance Durable replay admission and cross-request idempotency in Chapter 15

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

  1. Add an output schema to the measurement tool and define the behavior for invalid connector data.
  2. Design an approval token that cannot be replayed with different arguments.
  3. Decide how an ambiguous timeout is represented for a payment tool.
  4. Add repeated-call detection without blocking two legitimate calls with different inputs.
  5. 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 snapshot 45a903c; 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.
“AI SDK Core: streamText.” n.d.-a. Accessed October 7, 2026. https://ai-sdk.dev/v7/docs/reference/ai-sdk-core/stream-text.
“AI SDK Core: Tools and Tool Calling.” n.d.-b. Accessed October 7, 2026. https://ai-sdk.dev/docs/ai-sdk-core/tools-and-tool-calling.
“JSON Schema Draft 2020-12.” n.d. Accessed October 7, 2026. https://json-schema.org/draft/2020-12.
“Model Context Protocol Specification 2025-11-25.” n.d. Accessed October 7, 2026. https://modelcontextprotocol.io/specification/2025-11-25.
“Open Responses Reference 2026-04-24.” n.d. Accessed October 7, 2026. https://www.openresponses.org/reference/2026-04-24.