Finish With a Typed Outcome
A free-form string doesn't tell the application what to show: a plain answer, a question, a confirmation card, or a "contact an operator" button. The cross-provider trick is to finish the work with terminal tools that have no external side effect - each of them maps to one typed outcome.
export type AgentOutcome =
| { status: "answer"; message: string; citations: Citation[] }
| { status: "clarification_required"; question: string }
| { status: "approval_required"; preview: RefundPreview; explanation: string }
| { status: "handoff_required"; draft: HandoffDraft; reason: string }
| { status: "blocked"; reason: string };The runtime doesn't just accept a terminal call - it completes it into a safe outcome.
Terminal tool · The model's arguments · What the runtime adds
- answer_user - Message and source ids; Checks that the sources were returned by retrieval
- ask_clarification - One specific question; Conversation id and a safe UI
- request_refund_approval - preview_id and an explanation; Fills the amount from the trusted preview store
- request_handoff - draft_id and a reason; Fills the verified draft and the channel
The key subtlety is in the "what the runtime adds" column. The model names a preview_id, but the amount, currency, validity, and order version the runtime takes from the trusted preview store, not from the call's arguments.
Why we don't trust the amount from the terminal call. Even a strict JSON schema guarantees the shape but not the truth of the value. An amount taken from the model's arguments is a proposal, not a fact; the fact sits in the server store keyed by preview_id.The outcome is typed. But while the agent works, it's useful for the user to see progress - and here it matters to stream the right events.
Why doesn't the runtime take the refund amount from the terminal call's arguments?