A model can propose an action. The application decides whether it is valid, authorized, scoped, idempotent, useful, and reviewable.
Tool names should express narrow authority. rename_document is easier to reason about than manage_document. delete_generated_artifact is clearer than deleteFile. A narrow schema gives both the model and reviewer less room to improvise.
type CreateTicketArgs = {
workspaceId: string
title: string
severity: "low" | "medium" | "high"
customerImpact: boolean
reason: string
}
Identity belongs in the invocation, not in model-generated text:
type ToolInvocation = {
tool: "create_ticket"
actor: { kind: "user"; id: string }
sessionId: string
args: CreateTicketArgs
scope: { workspaceId: string; allowedProjectIds: string[] }
}
Authorization must use trusted application state.
Side effects need idempotency. A timeout after creating a ticket should not create a second ticket on retry:
type SideEffectToolCall<TArgs> = {
tool: string
args: TArgs
idempotencyKey: string
timeoutMs: number
}
Results should be structured enough for code to act on:
type ToolResult<T> =
| { ok: true; value: T; evidence: string[] }
| { ok: false; reason: "validation_failed" | "permission_denied" | "not_found" | "timeout" | "unsafe_request"; message: string; retryable: boolean }
Approval should attach to a concrete action, target, old value, new value, and recovery path. Logs should keep the validated arguments, actor, scope, policy decision, idempotency key, version, result, retries, and approval.
Language proposes. Code constrains. Tools act. Results provide evidence. That is the contract.
Related posts

About Jeremy London
Engineering leader and builder in Denver. I write about AI platforms, agents, security, reliability, homelab infrastructure, and the parts of engineering work that have to survive production.