From LLM to HarnessPart 3/11

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.

The boundary between the tool declaration sent to the model and the execution that stays host-side
TL;DR — The model does not run tools. It emits a structured request, and your code decides whether to execute it. That boundary is a trust boundary, and it lives at the type level.

A tool call looks like the model acting

This is what you see in the log when a model “calls a tool”:

the model returns this; it does not run it
{
"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:

FieldRole
nameTool name the model calls
descriptionTells the model when to reach for it
parametersJSON Schema for the arguments
deferLoadingFlag 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.

how the harness builds the model-facing projection — abridged
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.

The trust boundary cuts through a toolFour fields face the model across the boundary; the remaining eight never leave your processFour fields sent to the modelTrust boundaryEight fields stay host-sideexecute runs in your process
Figure 1 — The trust boundary cuts through a tool · tusgino
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:

Trust boundary between proposal and executionThe model only outputs a JSON proposal; your code checks permissions, runs the tool, and returns the resultModelYour CodeTool Function
Figure 2 — Trust boundary between proposal and execution · tusgino
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:

  1. The model proposes: It outputs plain JSON containing the tool name and arguments.
  2. Your code takes control: You verify permissions and apply timeouts — the model cannot bypass this check.
  3. Isolated execution: The tool function runs inside your process only when explicitly approved.
  4. 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

  1. 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.
  2. 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.
  3. 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.

Frequently asked questions

Can the model run a tool by itself?
No. The model emits a structured request naming a tool and its arguments. A different program — yours — reads that request, validates it, and decides whether to run it.
Why does the model never see a tool's executable code?
Because an execution function is not data. It cannot be serialised to JSON for transport, so at the type level it simply does not exist in the payload.
What happens when the model sends bad arguments?
The failure returns as an ordinary tool result rather than an exception that crashes the loop. The model can read the error and correct itself on the next turn.
How many fields of a tool are host-only?
In the harness I read: twelve fields in total, four visible to the model. The other eight — the execution function, the content renderers and scheduler metadata — are never sent.
Tú (tusgino)Tú (tusgino)

AI Software Engineer in Da Nang. I write about production system architecture and what each choice costs.