Mark a tool read-only
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.
Before you start
Section titled “Before you start”- A tool, in the quickstarts' pack,
my-pack, withkindgi devrunning and Kindgi 0.2's CLI.
Mark it read-only
Section titled “Mark it read-only”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.tsconst defined = defineTool({ id: 'my-pack.lookup-order' as ToolId, // … effects: [], mutating: false, // …});# in tools/lookup_order.py@tool(id="my-pack.lookup-order", mutating=False)def lookup_order(ref: OrderRef, ctx: ToolContext) -> Order: ...// in src/main/java/mypack/tools/LookupOrder.java public static final Tool<Input, Output> TOOL = Tool.define("my-pack.lookup-order") // … .mutating(false) // …// in src/main/scala/mypack/tools/LookupOrder.scala val tool: Tool[Input, Output] = Tool[Input, Output]("my-pack.lookup-order") // … .readOnly // …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 runs
Section titled “Dry runs”--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:
pnpm exec kindgi runs start --flow=my-pack.check-order --input='{"orderId":"ord_1001"}' --dry-runuv run kindgi runs start --flow=my-pack.check-order --input='{"orderId":"ord_1001"}' --dry-run./kindgiw runs start --flow=my-pack.check-order --input='{"orderId":"ord_1001"}' --dry-run./kindgiw 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 },The first tool that isn't stops the run, before the tool is called:
pnpm exec kindgi runs start --flow=my-pack.note-order --input='{"orderId":"ord_1001","text":"Customer called"}' --dry-runuv run kindgi runs start --flow=my-pack.note-order --input='{"orderId":"ord_1001","text":"Customer called"}' --dry-run./kindgiw runs start --flow=my-pack.note-order --input='{"orderId":"ord_1001","text":"Customer called"}' --dry-run./kindgiw 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",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:
pnpm exec kindgi runs start --flow=my-pack.echo-flow --input='{"name":"Ada"}' --dry-runuv run kindgi runs start --flow=my-pack.echo-flow --input='{"name":"Ada"}' --dry-run./kindgiw runs start --flow=my-pack.echo-flow --input='{"name":"Ada"}' --dry-run./kindgiw 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!" },Use a dry run to check a flow's wiring before it touches real data.
Effects
Section titled “Effects”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.
Approval gates
Section titled “Approval gates”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).
Calls that run together
Section titled “Calls that run together”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.
Results from outside
Section titled “Results from outside”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.
Getting it right
Section titled “Getting it right”- Declare
mutating: falseon 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.