Skip to content

Mark a tool read-only

View as Markdown

This guide marks a tool that only reads, so dry runs run it, approval gates let it through, and an agent can run its calls at the same time as other read-only calls.

  • A tool, in the quickstarts' pack, my-pack, with kindgi dev running and Kindgi 0.2's CLI.

A tool declares whether it changes anything outside Kindgi. One that only reads, such as a lookup, a search or a calculation, says so. Ask your coding agent: "My order lookup tool only reads. Mark it read-only." Kindgi's skills in your pack tell it how (How it knows Kindgi). For reference, the tool's definition then looks like this:

// in tools/lookup-order/index.ts
const defined = defineTool({
id: 'my-pack.lookup-order' as ToolId,
// …
effects: [],
mutating: false,
// …
});
Excerpt

Leaving mutating out is the same as mutating: true. That's the safe default: a tool counts as one that changes things until it says otherwise. It decides three things: whether the tool runs in a dry run, whether an approval gate asks before it, and whether an agent can run its calls at the same time as others.

--dry-run runs a flow without changing anything. A tool step runs only if the tool is read-only. --flow is the flow, and --input its input:

Terminal window
pnpm exec kindgi runs start --flow=my-pack.check-order --input='{"orderId":"ord_1001"}' --dry-run
"status": "completed",
"dryRun": true,
…
"output": {
"status": "shipped",
"orderId": "ord_1001",
"totalCents": 4200
},
Excerpt

The first tool that isn't stops the run, before the tool is called:

Terminal window
pnpm exec kindgi runs start --flow=my-pack.note-order --input='{"orderId":"ord_1001","text":"Customer called"}' --dry-run
"status": "failed",
"dryRun": true,
"failureMessage": "dry-run-effectful-tool: tool \"my-pack.add-order-note\" is not declared read-only (mutating: false), so it does not run in a dry run",
Excerpt

An agent step in a dry run skips its model call, so the agent calls no tools. The sample's echo-flow greets with a read-only tool, then hands the greeting to its agent:

Terminal window
pnpm exec kindgi runs start --flow=my-pack.echo-flow --input='{"name":"Ada"}' --dry-run
"status": "completed",
"dryRun": true,
…
"output": {
"reply": "[dry-run: model call skipped]",
"greeting": "Hello, Ada!"
},
Excerpt

Use a dry run to check a flow's wiring before it touches real data.

effects lists what a tool touches, for review and policy: { kind: 'writes', resource: 'external:orders' }. The kinds are reads, writes, deletes, network, spawns-run, emits-event, external-side-effect and sensitive-data-egress.

A tool with a writes, deletes, spawns-run, emits-event or external-side-effect effect doesn't run in a dry run, and an agent runs each call to it on its own, even when it says mutating: false. Keep the two consistent: a tool that only reads declares mutating: false and, at most, reads effects.

An agent can make a person approve tool calls, as Ask before a tool runs shows. With its gates on and no rule for a tool, a read-only tool runs without asking. Any other tool waits for approval on its first use.

mutating: false doesn't lift every wait. In a run no person started, a read that uses a person's connection still waits for that person's yes (Use a connection in a tool). In a run a mail started, so does a read that uses a connection of the run's own mailbox, its app and whose it is (What waits for a yes).

A model can ask for several tool calls in one step. An agent runs calls to read-only tools that come one after another at the same time, up to 8 of them, and a call to any other tool on its own, in the order the model asked (runtime 0.3.0 or later; earlier ones run every call on its own). A call to a read-only tool that may wait, for another system's answer or for the person who started the run to connect an app, runs on its own too. A call that a before-tool guardrail judges doesn't start until the calls before it have finished, so its check sees them as calls that ran. The read-only calls after it can run alongside it. Once a call needs an approval, the step's calls that haven't started run one at a time. Either way, the results reach the conversation and the model in the order the model asked for them, and each call's events carry its own duration.

To run every call on its own, set parallelToolCalls: false on the agent (parallel_tool_calls=False in Python, set("parallelToolCalls", false) in Java and Scala). Do that when its read-only tools mustn't overlap, such as two lookups against an API that allows one request at a time. An earlier runtime accepts the setting and runs every call on its own anyway.

A tool whose results are text from outside that nobody checked, such as a mail someone sent or a page a search found, says so: resultIsOutsideData: true (result_is_outside_data=True in Python, .resultIsOutsideData(true) in Java and Scala). An agent then reads each result of the tool as data, not instructions, in that turn and in every later turn's history. Absent means false. The ready-made mail reads set it (Ready-made tools).

It changes what the model is told, not what the tool may do: pair it with approvals for anything an agent could do with what it read.

  • Declare mutating: false on every tool that only reads. Otherwise a dry run stops at it, an approval gate asks before it, and an agent runs each call to it on its own.
  • Never declare it on a tool that writes, sends or deletes. A dry run would then run it for real, and an agent could run it at the same time as other calls.
  • A tool from an MCP server always counts as one that changes things: it doesn't run in a dry run.