How-to › Use AI to do the integration

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.

Audience
API consumer
Level
intermediate
Topic
Guard agents and add approvals
Languages
TypeScript and Python
Verified

Your agent merges and deletes support tickets, and you learn what it decided by reading the tickets afterwards. A delete is not undone by asking the model again. You want every write it intends, with its arguments, in front of you before any of them happens, but the tools have no switch for that.

What you get

You will end up with write tools that take a dry-run flag from the runner, three backends for the dry run, and a diff against the audit log. This is for you if an agent writes to an API you care about.

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

ApproachWhen it fitsWhat it costs youWhen to pick something else
nock or MSW around any clientA hand-written or vendor client, where a fake at the transport is enoughHandlers per endpoint, and a server rule the fake never runs passes the dry runThe API documents a dry-run parameter of its own
Plan, then executeA person approves every write before any happens, from a file they can readTwo passes and twice the model calls, with placeholders wherever a write produces a valueThe second pass may choose calls the approved plan never listed
sdkgen test featureA client sdkgen generated, on a task that chains writes such as create then updateMints ids at random and skips the server’s rules, so a refused delete reads as fine, even of an id the seed never heldYou need to know the server will accept each call
Server-side dry runAPIs that have one: Kubernetes dryRun=All, Google Ads validate_onlyFew APIs offer it, and chained writes fail because nothing earlier was keptThe 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 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.

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

node demo.mjs
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.

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.

python3 dry_run.py
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 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.

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.

python3 test_dry_run.py
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.

node verify.mjs
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.

node --test dryrun.test.mjs
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.

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.

Footnotes

  1. The Kubernetes API concepts page 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. ↩︎ Back to text

  2. Terraform’s machine-readable plan faces the same gap and answers it with a second object. Its JSON output 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. ↩︎ Back to text

  3. The module arrived in Python 3.7 through PEP 567, which describes itself as a simplified version of PEP 550. 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. ↩︎ Back to text

  4. The Kubernetes page 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 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. ↩︎ Back to text

Read this page as markdown · All how-to guides

Generate the client instead of writing it#

Retries, timeouts, pagination and auth are the same problems in every client. Voxgig generates them from your OpenAPI description, in 23 languages, from one model.

Listen to Fireside, the Voxgig podcast

Since 2018, Richard Rodger has talked with developer advocates, event organizers, and the people who build developer-facing companies about the specifics of the work.