The model proposes, your code executes
The model sees only a tool declaration, never its execution. A tool call is a structured request your code validates, authorises, and then decides to run.
A tool call looks like the model acting
This is what you see in the log when a model “calls a tool”:
{ "role": "assistant", "tool_calls": [ { "id": "call_a7f3", "type": "function", "function": { "name": "write_file", "arguments": "{\"path\":\"/etc/hosts\",\"content\":\"127.0.0.1 api.internal\"}" } } ]}It reads as though the model just wrote to /etc/hosts. It wrote nothing. It proposed a write, as JSON.
Nothing in that payload is executable. No function, no call site, no target. It is data describing an action, and everything that happens next is a decision made by your code.
This is the most important trust boundary in a harness. It is usually misread in a dangerous direction: people assume the model holds the authority, so they look for ways to restrain the model. The authority sits with whatever runs the call.
Twelve fields, four of them visible
In the harness I read, a registered tool is an object with 12 fields in two clear groups. The first group faces the model:
| Field | Role |
|---|---|
name | Tool name the model calls |
description | Tells the model when to reach for it |
parameters | JSON Schema for the arguments |
deferLoading | Flag to defer loading the definition into context |
The second group stays host-side: 8 fields, namely output, execute, projectContent, finalizeContent, timeoutMs, isConcurrencySafe, presentCall, presentResult.
Those eight are the execution function, the content renderers, the time budget, the parallelism classifier, and two UI presenters. None of them is data. None can be serialised to JSON for transport.
And the harness documentation says so outright:
A ToolSchema (the model-facing fields) plus a mandatory canonical output declaration, the execute function, host-only scheduler metadata, an optional final-content callback, and optional UI presenters. The registry’s schemas() builds the model-facing ToolSchema[] by an explicit allowlist — output/execute/projectContent/finalizeContent/timeoutMs/isConcurrencySafe/presentCall/presentResult must never leak into a model request.— dsh, docs/subsystems/tools.md
“Host-only” is the operative phrase. That metadata belongs to the host, and the boundary is not a convention — it is a function that names each field.
private schemaOf(definition: ToolDefinition, detachParameters: boolean): ToolSchema { const { name, description, parameters, deferLoading } = definition const detached = detachParameters ? snapshotJsonValue(parameters) : parameters if (detached === undefined) { throw new Error(`tool "${name}" parameters must be lossless JSON before schema projection`) } return { name, description, parameters: detached, ...deferLoading === true ? { deferLoading } : {}, }}Read that function slowly, because the whole argument of this post is inside it. It destructures four fields and returns four fields. There is no ...definition spread anywhere.
So execute is not filtered out. It is never read. Sending an execution function over the wire would require editing this function. Nobody edits a function called schemaOf without knowing exactly what they are doing.
View diagram data
type: layers
title: The trust boundary cuts through a tool
desc: Four fields face the model across the boundary; the remaining eight never leave your process
nodes:
- id: wire
label: Four fields sent to the model
kind: external
- id: boundary
label: Trust boundary
kind: security
focal: true
- id: host
label: Eight fields stay host-side
kind: store
- id: exec
label: execute runs in your process
focal: true
edges:
- [wire, boundary]
- [boundary, host]
- [host, exec]Three things only your code knows
The deadline
timeoutMs declares a time budget for one call, and the documentation states it is never sent to the model. The model does not know whether your tool allows five seconds or five minutes.
The consequence: the model cannot adapt its strategy to a time budget. If you need it aware, you must say so in description. But then the model knows a number you told it, not the number the host actually enforces.
Permission to run in parallel
isConcurrencySafe is a synchronous classifier; only an explicit true admits a call to a parallel group. The default is exclusion — a missing field, a throwing function, or any return other than true all mean no.
That default is correct, and it shields you from a class of bug that is painful to reproduce: two tool calls mutating one resource. The model cannot see this flag, so the model cannot switch it on.
How the result gets narrated
render and presentCall turn a raw value into model-facing content and into a UI card. One result, two narrations — both written by you.
The part that matters: the model reads the narration, never the raw value. You control what the model learns about what just happened.
From proposal to execution
From proposal to execution
When the model proposes a tool call, the whole flow crosses a clear trust boundary:
View diagram data
type: sequence
title: Trust boundary between proposal and execution
desc: The model only outputs a JSON proposal; your code checks permissions, runs the tool, and returns the result
nodes:
- id: model
label: Model
kind: external
- id: host
label: Your Code
focal: true
- id: tool
label: Tool Function
kind: security
edges:
- [model, host]
- [host, tool]
- [tool, host]
- [host, model]This cycle has four distinct steps:
- The model proposes: It outputs plain JSON containing the tool name and arguments.
- Your code takes control: You verify permissions and apply timeouts — the model cannot bypass this check.
- Isolated execution: The tool function runs inside your process only when explicitly approved.
- Packaged response: The output is wrapped in a message and returned to the conversation history for the next turn.
That is the right shape. If authorisation sat inside each tool body, every tool would have to remember to check, and one that forgets is a hole. Placed outside, every tool passes the same gate.
A second note: only the tools/execute stage may replace the cancellation signal. No other stage can touch it. It is a small detail, but it is the kind of constraint you only get right if you designed the boundary first.
When the model sends bad arguments
This is the best part, and the easiest to get wrong.
A model guessing a parameter name wrong is routine. The architectural question is: where does that failure go?
The right answer is that it becomes a tool result. Not an exception that kills the loop, not a silent log line. The harness I read throws two named errors: ToolArgsError when arguments miss the schema, and ToolOutputError when a tool body returns a mistyped value. The documentation states both travel the normal tool-error path.
So the model reads the failure and corrects itself on the next turn. The loop does not die because of one misspelled parameter.
Do this now
- Split declaration from execution in the type systemDo not rely on convention. Write a projection function that allowlists fields, destructures exactly those, and never spreads the source object.
- Put the authorisation gate outside the toolAllow, deny and ask must be a stage every tool traverses. Authorisation inside a tool body is authorisation that will be forgotten.
- Return argument failures as resultsA bad parameter is data for the next turn, not an exception. Log it, and send it back for the model to read.
The rule
Reading a harness’s schemaOf is the fastest way to tell whether it has a real boundary or merely a convention. A real boundary is visible in a few lines of code.
The model proposes. Your code executes. If those two live in the same place, you do not have an agent system — you have a shell with a chat interface.— tusgino
- A tool call is JSON, not an action. Nothing in that payload executes on its own.
- What the model sees and what the host keeps are two different field groups; the boundary should be an allowlist function, not a convention in a document.
- Put authorisation (allow/deny/ask) outside the tool, in a stage every call traverses.
- Return argument failures as tool results so the model can correct itself, rather than letting them kill the loop.
- If the model can run what it proposes, you do not have an agent system.