Tool calling is a contract between a model and a system that has consequences.
The model can propose an action. The application has to decide whether the proposal is valid, authorized, scoped, idempotent, useful, and reviewable. That decision cannot live in vibes. It has to live in a contract the code can enforce.
A syntactically valid tool call can still be wrong. The tool name may be correct while the arguments point at the wrong resource. The JSON may validate while the requested action exceeds the user’s permission. The tool may return success while the system changed nothing useful. The retry may succeed by duplicating state.
That is why I think tool calling belongs closer to API design than prompt design.
the schema is the first policy layer
The schema should do more than tell the model which keys exist.
It should make illegal states hard to express. If a tool can only create low, medium, or high severity tickets, the schema should say that. If a tool needs a workspace ID, make it required. If a tool accepts a date range, define the format. If an action needs a reason, require one.
type CreateTicketArgs = {
workspaceId: string
title: string
severity: "low" | "medium" | "high"
customerImpact: boolean
reason: string
}
That type is still incomplete, but it is already better than args: Record<string, unknown>. The model has fewer places to hide ambiguity.
I also like making dangerous tools narrower than feels convenient. deleteFile(path) is a scary tool. deleteGeneratedArtifact({ artifactId }) is easier to reason about. The narrow tool may require more application code, but it gives the model less room to improvise.
identity belongs in the call path
A tool call without identity is a background job wearing a mask.
The system should know who requested the action, which session produced it, what resource scope was available, and whether the model was acting as a user, assistant, automation rule, or delegated workflow.
type ToolInvocation = {
tool: "create_ticket"
actor: {
kind: "user"
id: string
}
sessionId: string
args: CreateTicketArgs
scope: {
workspaceId: string
allowedProjectIds: string[]
}
}
The model does not get to invent this identity. The application supplies it. That distinction matters because authorization has to be checked against trusted state, not against text the model produced.
This is where a lot of demos quietly cheat. The tool works because it has broad credentials behind it. The model asks for something plausible, and the tool does it. That is fun until the first confused-deputy problem appears.
idempotency is not optional for side effects
Any tool with side effects needs an idempotency story.
If the model creates a ticket and the network times out after the ticket is created, what happens when the agent retries? If the tool sends an email and the caller does not receive the result, does the retry send a second email? If the model updates a CRM record twice, which update should count?
The contract needs an idempotency key that reflects the intended action.
type SideEffectToolCall<TArgs> = {
tool: string
args: TArgs
idempotencyKey: string
timeoutMs: number
}
The key should come from the application or a deterministic action plan, not from a fresh random value on every retry. Otherwise the retry protection is fake.
For read-only tools, idempotency is less dramatic. For writes, it is the difference between a recoverable timeout and a duplicate operation.
result shapes should explain what happened
Returning free-form text from a tool is tempting because the model can read it. It is also where contracts go soft.
I would rather return structured results and give the model text as one field among several.
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
}
That shape lets the application make decisions without parsing prose. The model can still summarize the result, but it does not own the result semantics.
The retryable field is important. A validation failure and a timeout should not trigger the same behavior. Permission denial should not be solved by asking the model to try harder.
tool names should be boring and specific
Tool names are part of the model’s interface.
manage_document is too broad. It invites the model to decide which operation belongs inside the verb. rename_document, archive_document, and create_document_comment are easier to reason about.
The same is true for descriptions. A tool description should say what the tool does, what it refuses to do, and which identifiers it expects.
Bad:
Use this tool to update a project.
Better:
Rename an existing project in the current workspace. The caller must provide
the project ID and the new display name. This tool does not create projects,
move projects, or change permissions.
That description gives the model a smaller target. It also gives reviewers a way to spot misuse.
approval should attach to the planned action
Approval buttons are weak when they approve a sentence.
The assistant wants to update the project.
That does not tell the user enough. Which project? What field? What old value? What new value? Can it be undone?
The approval surface should attach to the tool contract:
{
"tool": "rename_project",
"project_id": "proj_123",
"old_name": "Migration notes",
"new_name": "Q3 migration notes",
"reversible": true
}
Now the user is approving an action, not a vague intent.
Some tools should never require approval because they are safe reads. Some should always require approval because they mutate external state. Some should require approval only when scope or risk crosses a threshold. The important part is that approval policy is attached to the tool and action shape, not improvised after the model speaks.
logs should reconstruct the contract
When a tool call goes wrong, the log should let another engineer reconstruct the decision.
I want to see:
- proposed tool name
- validated arguments
- actor identity
- resource scope
- policy decision
- idempotency key
- tool version
- result status
- retry count
- approval record when one existed
That is not because every tool call is a courtroom exhibit. It is because agent failures are otherwise slippery. The model said something plausible. The tool did something concrete. The gap between those two things is where most debugging happens.
the model proposes, the contract decides
Tool calling works when the model is good at intent and the application is good at boundaries.
The model can choose a likely tool, fill an argument shape, and explain why it wants to act. The application validates the schema, checks identity, applies policy, executes the narrow operation, and returns a structured result.
That split keeps the system honest. If the model can bypass the contract through prose, the contract is decorative. If the application ignores the model’s context entirely, the tool call becomes a brittle command form.
The useful version is somewhere in the middle: language proposes, code constrains, tools act, results come back with enough structure to decide the next step.
That is a contract. Treat it like one.
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.