# How to add a dry-run mode to an agent's write tools

> Give every write tool a dry-run flag the runner owns, answer the dry run from the server, a fake transport or a recorded plan, and diff it against the audit log.

Source: https://voxgig.com/howto/add-dry-run-to-agent-write-tools

- Audience: api-consumer
- Level: intermediate
- Languages: typescript, python
- Verified: 2026-10-15
- Published: 2026-10-15

## Short answer

Give every tool that creates, updates or deletes a dry-run flag that the runner sets and the model never sees, and default it to on. In a dry run the tool records its call and answers from the API's own dry-run parameter, an in-memory fake, or a placeholder. Run the task dry, review the recorded calls, run it for real, and diff the two against the API's audit log.

---
## You will need

Node 22 or later, Python 3.7 or later for the Python twin, and an agent with at least one tool
that writes to an API. Verified 2026-10-15 against Node 22.22.0, Python 3.13.16,
@voxgig/create-sdkgen 0.30.7 and @voxgig/sdkgen 4.34.7. A scripted model stub drives every run,
so no provider is called and no key is set. The ticket API is a local server each command starts.

Voxgig maintains sdkgen. This page compares its test feature with a server-side dry run, nock or
MSW, and a recorded plan.

## Approaches compared

| Approach | When it fits | What it costs you | When to pick something else |
| --- | --- | --- | --- |
| [nock](https://github.com/nock/nock) or [MSW](https://mswjs.io/docs) around any client | A hand-written or vendor client, where a fake at the transport is enough | Handlers per endpoint, and a server rule the fake never runs passes the dry run | The API documents a dry-run parameter of its own |
| [Plan, then execute](https://developer.hashicorp.com/terraform/cli/commands/plan) | A person approves every write before any happens, from a file they can read | Two passes and twice the model calls, with placeholders wherever a write produces a value | The second pass may choose calls the approved plan never listed |
| [sdkgen test feature](https://voxgig.com/sdk/features) | A client sdkgen generated, on a task that chains writes such as create then update | Mints ids at random and skips the server's rules, so a refused delete reads as fine, even of an id the seed never held | You need to know the server will accept each call |
| [Server-side dry run](https://kubernetes.io/docs/reference/using-api/api-concepts/#dry-run) | APIs that have one: Kubernetes `dryRun=All`, Google Ads `validate_only` | Few APIs offer it, and chained writes fail because nothing earlier was kept | The task chains writes that depend on each other |

Only the server-side dry run knows the server's rules. It judges each call against the state
before the run, so a chain of writes fails where the real run succeeds. The test
feature carries state from one call to the next but runs none of the server's checks. nock and MSW
sit at the same layer for any client. A recorded plan validates nothing, but it is the easiest of
the four to review. [Stripe's sandbox keys](https://docs.stripe.com/keys) are a fifth shape: the
calls really run, against data kept apart from live mode.

## Keep the flag where the model cannot reach it

The flag belongs to the runner. It is an argument to `makeTools`, never a property in a tool's
schema, so neither the model nor a document the model reads can turn a dry run off.

```js title="tools.mjs"
// dryRun defaults to true, so a runner that forgets to set it records instead of writing.
// `via` picks what makes the dry run answer: 'server' sends the API's own dryRun parameter,
// 'fake' expects a client built with DesklineSDK.test(), 'record' answers with a placeholder.
export function makeTools(sdk, { dryRun = true, via = 'record' } = {}) {
  // A name matching none of the three would fall through to a real write, so a typo stops here.
  if (dryRun && !['server', 'fake', 'record'].includes(via)) {
    throw new Error(`no dry-run backend named ${via}`)
  }
  if (dryRun && via === 'fake' && sdk._mode !== 'test') {
    throw new Error('a fake dry run needs a test-mode client, and this one is live')
  }
  const calls = []

  async function write(tool, op, raw) {
    // Only what the schema declares reaches the client, so an argument the model adds, a
    // dryRun of its own included, is dropped here rather than sent in the body.
    const args = pick(raw, TOOL_SCHEMAS[tool].parameters.properties)
    const call = { tool, args }
    calls.push(call)
    if (dryRun && via === 'record') {
      call.outcome = 'recorded'
      // The value only a real write can produce, marked as such, so the plan shows the
      // dependency instead of hiding it.
      return op === 'create' ? { ...args, id: `<id from call ${calls.length}>` } : { recorded: true }
    }
    const payload = dryRun && via === 'server' ? { ...args, dry_run: 'All' } : args
    const explain = {}
    try {
      const entity = await sdk.Ticket()[op](payload, { explain })
      call.outcome = 'ok'
      call.result = entity.data()
      return call.result
    } catch (err) {
      // The SDK keeps the status and the parsed error body; the model needs the reason.
      call.outcome = `${err.status ?? 'error'} ${err.result?.body?.error ?? err.message}`
      return { error: call.outcome }
    } finally {
      call.request = requestOf(explain.fetchdef)
    }
  }
```

The default is a dry run, so the dangerous mode is the one you have to ask for, and a misspelled
backend name throws instead of writing. `pick` drops any
argument the schema does not declare, which stops a ticket body that tells the model to pass
`dryRun: 'None'` from reaching the client. And the fake path refuses a live client, because a
test-mode client and a live one look the same from outside.

The client is the TypeScript SDK sdkgen generated from `api.json`, which declares the
Kubernetes-style `dryRun` query parameter on its three writes. The generated client takes it as
`dry_run` and puts it in the query string, not the body. In sdkgen 4.34.7 the test feature
replaces the transport, not the request, so `ctrl.explain` hands back the request the client
built in test mode as well as live. The tool records that.

## Run the same task four ways

The scripted model opens one ticket, closes `t1` and `t2` into it, and deletes `t3`, which is
still open. The server refuses to delete an open ticket and refuses a merge into a ticket that
does not exist.

```bash
node demo.mjs
```

```text output
call              server dryRun     sdkgen test mode  record only       real run
create            ok, id tk-4       ok, id minted     recorded          ok, id tk-4
update t1         422               ok                recorded          ok
update t2         422               ok                recorded          ok
delete t3         409               ok                recorded          409
write requests    4                 0                 0                 4
writes kept       0                 0                 0                 3

422 mergedInto names no ticket: tk-4
409 close the ticket before deleting it
```

All three dry runs left the server as it was, as the last row shows.

Only the server-side dry run saw the 409, which the real run then hit. The test feature and the
plan both passed the delete of an open ticket.

The server-side dry run also reported two 422 responses that the real run never hit, because the
dry-run create was not stored and the updates merged into a `tk-4` that did not exist.
Kubernetes documents the same behavior: a dry run is never persisted, and generated values such
as a name will likely differ from the real request's.[^1] The test feature carried the created
ticket into both updates, under a 16-character id it minted. The plan carried `<id from call 1>`, which
shows the dependency without inventing a value.[^2]

## Hold the flag per run in Python

The Python twin keeps the flag in a `ContextVar`, which `asyncio` copies into every task it
creates, so two runs in one process each see their own value.

```python title="dry_run.py"
DRY_RUN = contextvars.ContextVar("dry_run", default=True)  # forget to set it, get a dry run
PLAN = contextvars.ContextVar("plan")


def write_tool(fn):
    """Wrap a tool that changes data. In a dry run it records the call and returns a placeholder."""

    @functools.wraps(fn)
    async def wrapper(api, **args):
        if DRY_RUN.get():
            PLAN.get().append({"tool": fn.__name__, "args": args})
            return {"recorded": True}
        return await fn(api, **args)

    return wrapper
```

The script runs a dry run and a real run at once, then the same pair with a module-level flag.

```bash
python3 dry_run.py
```

```text output
ContextVar flag, a dry run and a real run at once
  plan    [{"tool": "close_ticket", "args": {"id": "t1"}}, {"tool": "close_ticket", "args": {"id": "t2"}}]
  dry api {"t1": "open", "t2": "open"}
  real    {"t1": "closed", "t2": "closed"}
module-level flag, the same two runs
  dry api {"t1": "closed", "t2": "closed"}
  real    {"t1": "closed", "t2": "closed"}
```

With the module-level flag, the real run set it to false while the dry run was between calls,
and the dry run closed both of its tickets. The
[`asyncio` documentation](https://docs.python.org/3/library/asyncio-task.html) states the rule the
first half relies on: a task runs in a copy of the current context.[^3]

A run awaited directly gets no copy, so `run_agent` puts the old values back when it ends.

```python title="dry_run.py"
async def run_agent(api, dry_run):
    """The scripted model: close two tickets, yielding to the event loop between calls."""
    # Put the old values back when the run ends, so a real run awaited directly does not leave
    # the flag off for whatever this task runs next.
    flag, plan = DRY_RUN.set(dry_run), PLAN.set([])
    try:
        for ticket in ("t1", "t2"):
            await asyncio.sleep(0)
            await close_ticket(api, id=ticket)
        return PLAN.get()
    finally:
        DRY_RUN.reset(flag)
        PLAN.reset(plan)
```

Three Python tests pin both halves and the reset.

```bash
python3 test_dry_run.py
```

```text output
ok a_dry_run_and_a_real_run_at_once_keep_their_own_flags
ok a_real_run_leaves_the_default_dry_run_behind_it
ok a_module_level_flag_lets_the_dry_run_write
```

## Check it worked

Run the task dry with the test feature, run it for real, and line the recorded requests up
against the server's audit log of the real run.

```bash
node verify.mjs
```

```text output
POST /v1/tickets        same
PATCH /v1/tickets/t1    mergedInto differs
PATCH /v1/tickets/t2    mergedInto differs
DELETE /v1/tickets/t3   dry run ok, real 409

1 of 4 calls match the audit log exactly
```

Same calls, same order, same paths. The two body differences are the id the create returned,
which the dry run could not know. The fourth line is the one to read before you trust a
client-side dry run again: the server refused a delete that the dry run had passed.

Eleven tests pin that behavior, including that a dry run keeps nothing and that neither an
argument the model adds nor a misspelled backend reaches the API.

```bash
node --test dryrun.test.mjs
```

```text output
1..11
# tests 11
# suites 0
# pass 11
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 377.899612
```

## When it goes wrong

The dry run kept three writes. The API ignores `dryRun`, as many HTTP servers ignore a query
parameter they do not know. Before trusting the flag, run a dry-run create against a sandbox and
list the collection.

Every chained write fails in the server-side dry run. Each call is checked against stored state,
which the earlier dry-run calls never changed. Use the fake for chains, seeded with the records the plan
expects, or accept the 422 responses as noise and read only the single-call rules from that run.

A dry run closed tickets. Two runs shared a module-level flag. Keep it in a `ContextVar` in
Python or in the tool factory's closure in TypeScript, never in a global.

The real run made a call the approved plan did not list. The second pass is a fresh sample from
the model. Diff each real call against the plan before sending it, and stop at the first
mismatch.

## When not to do this

Do not read a clean pass from sdkgen's test feature as proof the real run will succeed. The mock
answered `ok` to deleting an open ticket the server refuses, because a client-side fake knows the
request's shape and none of the server's rules. In 4.34.7 it also passes a delete of an id it
never held. Use it to review intent, and the server-side flag
or a sandbox to test validity.

Do not send a dry-run parameter to an API that does not document one. An unknown query
parameter is usually dropped, and the write goes through for real.

Do not give a dry-run agent credentials you would not give the real one, and do not expect a
server-side dry run to let you narrow them. Kubernetes requires the same authorization for a dry
run as for the real request,[^4] so the dry-run agent holds keys that can write.

Do not wrap read tools. Running a read for real changes nothing on the server, and it is how the
model learns the ids it plans with.

## Related how-tos

- [Run an in-memory mock of your API inside SDK tests](/howto/run-an-in-memory-mock-inside-sdk-tests)

- [Stub outbound HTTP calls in Node tests with nock](/howto/stub-outbound-node-http-calls-with-nock)

- [Wire an API client into the OpenAI Agents SDK](/howto/wire-an-api-client-into-openai-agents-sdk)

- [Require human approval before an agent calls a delete tool](/howto/require-approval-before-a-delete-tool-call)

- [Cache tool results across the steps of one agent run](/howto/cache-tool-results-across-agent-steps)

## Last verified

Verified 2026-10-15 against Node 22.22.0, Python 3.13.16 and 3.12.3, @voxgig/create-sdkgen 0.30.7
and @voxgig/sdkgen 4.34.7. Every output block is what the command preceding it printed. The model is
a scripted stub and the API is a local server, so no provider and no hosted API was called. The
generated SDK's compiled JavaScript is committed under `generated/`, and `README.md` beside it
names the commands that produced it.

[^1]: The [Kubernetes API concepts page](https://kubernetes.io/docs/reference/using-api/api-concepts/#dry-run)
lists some of the fields a dry run fills in differently from the real request. They include a
name derived from `generateName`, the timestamps, the UID, the resource version, and anything a
mutating admission controller sets. The page asks the reader not to rely on any of them. The rehearsal runs every stage but storage, and the values
it cannot rehearse are the ones that say which object was made.

[^2]: Terraform's machine-readable plan faces the same gap and answers it with a second object.
Its [JSON output format](https://developer.hashicorp.com/terraform/internals/json-format) pairs
each planned value with `after_unknown`, which marks the leaves that will only be known after
apply, and the example the documentation chose is `{ "id": true }`. Of all the attributes a
resource can carry, the one no plan can predict is the one everything else refers to.

[^3]: The module arrived in Python 3.7 through [PEP 567](https://peps.python.org/pep-0567/),
which describes itself as a simplified version of [PEP 550](https://peps.python.org/pep-0550/).
The earlier proposal, from the same author and a second, also tried to keep values straight
inside generators, and it is filed as withdrawn. The half of the idea that keeps a dry run apart
from a real one is the half that survived review.

[^4]: The [Kubernetes page](https://kubernetes.io/docs/reference/using-api/api-concepts/#dry-run-authorization)
says that authorization for dry-run and non-dry-run requests is identical, so a dry-run patch
to a Deployment needs permission to patch Deployments. Google's
[`validate_only`](https://developers.google.com/google-ads/api/reference/rpc/v26/MutateCampaignsRequest)
takes the other shortcut: the request is validated but not executed, and only errors are
returned, not results. A successful dry run of a create there returns no id at all, which avoids
a wrong id by supplying none.