---
title: "The model proposes, your code executes"
description: "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."
pubDate: 2026-09-21
author: tusgino
tags: [ai-engineering, architecture, security, llm]
series:
  id: tu-llm-den-harness
  title: "From LLM to Harness"
  order: 3
cover: /images/covers/model-de-nghi-code-thuc-thi.svg
coverAlt: "The boundary between the tool declaration sent to the model and the execution that stays host-side"
lang: en
faq:
  - q: "Can the model run a tool by itself?"
    a: "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."
  - q: "Why does the model never see a tool's executable code?"
    a: "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."
  - q: "What happens when the model sends bad arguments?"
    a: "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."
  - q: "How many fields of a tool are host-only?"
    a: "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."
---

<TLDR>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.</TLDR>

## A tool call looks like the model acting

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

```json title="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:

| 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:

<Quote cite="dsh, docs/subsystems/tools.md">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.</Quote>

"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.

```ts title="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.

```diagram
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:

```diagram
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.

<Callout type="warn" title="If the model can run what it proposes, you do not have an agent system">
You have a shell with a chat interface. The difference is not that you write more careful prompts. It is that a program stands between the proposal and the action, and you wrote that program.
</Callout>

## Do this now

<Steps>
<Step title="Split declaration from execution in the type system">Do not rely on convention. Write a projection function that allowlists fields, destructures exactly those, and never spreads the source object.</Step>
<Step title="Put the authorisation gate outside the tool">Allow, deny and ask must be a stage every tool traverses. Authorisation inside a tool body is authorisation that will be forgotten.</Step>
<Step title="Return argument failures as results">A bad parameter is data for the next turn, not an exception. Log it, and send it back for the model to read.</Step>
</Steps>

## 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.

<Quote cite="tusgino">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.</Quote>

<KeyTakeaways>
- 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.
</KeyTakeaways>
