Previewing and Replaying a Change

Preview an agent that charges a fee, run it for real, then replay its recorded turn against a new version of the tool before you switch to it.

A tool that writes to the ledger is risky to change: once a caller uses the new version, every call commits. This tutorial changes such a tool safely. You build a tool that charges a monthly fee by posting a transaction, and an agent that calls it. You preview the agent to see the transaction it would post without posting it, run it for real, and then change the fee. Before the agent uses the new fee, you replay its recorded turn against the new version, compare the two runs, and only then pin the new version in the agent.

You need a running local environment. Open GraphiQL in the local console and run each step as its own request.

The agent uses the fixture model, so every run is deterministic and calls no model. The UUIDs below are examples; if you have run this tutorial before, choose fresh ones, and fresh account and tran code codes.

1. Set up the accounts and tran code

Create a customer account, a fee income account, and a tran code that moves a fee from one to the other:

mutation SetUpFees {
  customer: createAccount(
    input: {
      accountId: "457b3aa9-015a-4424-a689-b5d75861dde8"
      code: "PREVIEW-REPLAY-CUSTOMER"
      name: "Preview and replay customer"
      normalBalanceType: CREDIT
    }
  ) {
    accountId
  }
  feeIncome: createAccount(
    input: {
      accountId: "bc39941f-2b87-4370-bcba-fa535bc34ade"
      code: "PREVIEW-REPLAY-FEE-INCOME"
      name: "Preview and replay fee income"
      normalBalanceType: CREDIT
    }
  ) {
    accountId
  }
  feeCode: createTranCode(
    input: {
      tranCodeId: "16d4024c-8f31-479e-a1d7-6af863dcbb10"
      code: "PREVIEW_REPLAY_FEE"
      description: "Charge a customer a fee."
      params: [{ name: "account", type: UUID }, { name: "amount", type: DECIMAL }]
      transaction: { description: "'Account fee'" }
      entries: [
        {
          accountId: "params.account"
          units: "params.amount"
          currency: "'USD'"
          entryType: "'FEE_DR'"
          direction: "DEBIT"
          layer: "SETTLED"
        }
        {
          accountId: "uuid('bc39941f-2b87-4370-bcba-fa535bc34ade')"
          units: "params.amount"
          currency: "'USD'"
          entryType: "'FEE_CR'"
          direction: "CREDIT"
          layer: "SETTLED"
        }
      ]
    }
  ) {
    tranCodeId
  }
}

2. Create the fee tool

The tool's program posts one PREVIEW_REPLAY_FEE transaction for the account it is given, with a fee of 2.00, and returns the transaction ID and the fee.

mutation CreateChargeFee {
  actions {
    createAction(
      id: "db3f21d6-d7a4-459e-aa87-33f27529f40d"
      input: {
        name: "preview-and-replay-charge-fee"
        description: "Charge the monthly account fee"
        kind: "tool"
        tool: {
          name: "charge_fee"
          description: "Charge the monthly account fee to one customer account."
          inputSchema: {
            type: "object"
            properties: { account_id: { type: "string", format: "uuid" } }
            required: ["account_id"]
            additionalProperties: false
          }
          outputSchema: { type: "object" }
        }
        policies: [{ effect: ALLOW, actions: [SELECT, INSERT, UPDATE], resources: ["financial.*"] }]
        config: {
          runtime: "starlark"
          src: """
          FEE = "2.00"

          def run(event):
              transaction_id = str(uuid.New())
              graphql(
                  "mutation Charge($id: UUID!, $params: JSON!) { postTransaction(input: {transactionId: $id, tranCode: \"PREVIEW_REPLAY_FEE\", params: $params}) { transactionId } }",
                  {"id": transaction_id, "params": {"account": event["data"]["account_id"], "amount": FEE}},
                  operation_name="Charge",
              )
              return {"transaction_id": transaction_id, "fee": FEE}
          """
        }
      }
    ) {
      actionId
      version
      status
      pending
    }
  }
}

The response shows version: 1, status: UNPUBLISHED, and pending: ACTIVE. Poll until the tool is published:

query ReadChargeFee {
  actions {
    action(id: "db3f21d6-d7a4-459e-aa87-33f27529f40d") {
      version
      status
      pending
    }
  }
}
{ "version": 2, "status": "ACTIVE", "pending": null }

Publication advances the version. Version 2 is the published fee tool, and it is the version the agent will call.

3. Create the agent in preview mode

The agent is callable: its tool block lets you call it directly with an account ID. Its tool reference pins the fee tool at version: 2, so later changes to the tool never reach it until you change the pin, and fixes account_id to the agent's own input. config.mode: "preview" makes the agent run every call as it would live and discard the writes.

The fixture script answers the agent's first model call with a call to charge_fee and its second with a reply.

mutation CreateFeeAgent {
  actions {
    createAction(
      id: "a9cdf240-dfef-4c50-8a27-e4f6d04291f3"
      input: {
        name: "preview-and-replay-fee-agent"
        description: "Charges the monthly fee for one account"
        kind: "agent"
        agent: {
          prompt: "Charge the monthly account fee for the account you are given, then confirm."
        }
        tool: {
          name: "fee_agent"
          description: "Charge the monthly fee for one account."
          inputSchema: {
            type: "object"
            properties: { account_id: { type: "string", format: "uuid" } }
            required: ["account_id"]
            additionalProperties: false
          }
        }
        config: {
          runtime: "strands"
          mode: "preview"
          model: {
            provider: "fixture"
            script: [
              {
                responses: [
                  [{ toolUse: { name: "charge_fee", input: {} } }]
                  [{ text: "Charged the monthly fee." }]
                ]
              }
            ]
          }
        }
        tools: [
          {
            name: "preview-and-replay-charge-fee"
            version: 2
            input: { account_id: "tool.input.account_id" }
          }
        ]
        policies: [{ effect: ALLOW, actions: [SELECT, INSERT, UPDATE], resources: ["financial.*"] }]
      }
    ) {
      actionId
      version
      status
      pending
    }
  }
}

Poll it as in step 2, with the agent's ID, until it is ACTIVE with pending: null. It publishes as version 2.

4. Preview a charge

Call the agent with invokeAction. The call runs the agent's whole turn synchronously:

mutation PreviewFee {
  actions {
    invokeAction(
      id: "a9cdf240-dfef-4c50-8a27-e4f6d04291f3"
      input: {
        executionId: "7a0c0b54-8122-4e61-a712-580a8967c07f"
        actionVersion: 2
        input: { account_id: "457b3aa9-015a-4424-a689-b5d75861dde8" }
      }
    ) {
      executionId
      status
      preview
      invocation {
        output
      }
    }
  }
}
{
  "executionId": "7a0c0b54-8122-4e61-a712-580a8967c07f",
  "status": "SUCCEEDED",
  "preview": true,
  "invocation": { "output": "Charged the monthly fee." }
}

preview: true marks an execution whose writes were discarded. The agent's execution and its transcript are kept; the transaction is not.

5. Read what the preview would have written

The agent's charge_fee call is a child of its execution. A preview call lists in writeSet every record version it would have written:

query FeeCalls($id: UUID!) {
  actions {
    execution(id: $id) {
      status
      preview
      children(first: 10) {
        nodes {
          actionVersion
          preview
          invocation {
            output
          }
          writeSet {
            recordType
            recordVersion
            data
          }
        }
      }
    }
  }
}

With the variables { "id": "7a0c0b54-8122-4e61-a712-580a8967c07f" }:

{
  "status": "SUCCEEDED",
  "preview": true,
  "children": {
    "nodes": [
      {
        "actionVersion": 2,
        "preview": true,
        "invocation": {
          "output": { "fee": "2.00", "transaction_id": "11dfd354-d8ad-43de-b5a3-a80027da565c" }
        },
        "writeSet": [
          {
            "recordType": "Transaction",
            "recordVersion": 1,
            "data": {
              "description": "Account fee",
              "tran_code_id": "16d4024c-8f31-479e-a1d7-6af863dcbb10",
              "…": "…"
            }
          },
          {
            "recordType": "Balance",
            "recordVersion": 1,
            "data": {
              "account_id": "457b3aa9-015a-4424-a689-b5d75861dde8",
              "settled": { "dr_balance": "2.00 USD", "…": "…" },
              "…": "…"
            }
          },
          {
            "recordType": "Balance",
            "recordVersion": 1,
            "data": {
              "account_id": "bc39941f-2b87-4370-bcba-fa535bc34ade",
              "settled": { "cr_balance": "2.00 USD", "…": "…" },
              "…": "…"
            }
          },
          {
            "recordType": "Entry",
            "recordVersion": 1,
            "data": {
              "account_id": "457b3aa9-015a-4424-a689-b5d75861dde8",
              "amount": "2.00 USD",
              "entry_type": "FEE_DR",
              "…": "…"
            }
          },
          {
            "recordType": "Entry",
            "recordVersion": 1,
            "data": {
              "account_id": "bc39941f-2b87-4370-bcba-fa535bc34ade",
              "amount": "2.00 USD",
              "entry_type": "FEE_CR",
              "…": "…"
            }
          }
        ]
      }
    ]
  }
}

The write set is the transaction, its two entries, and the two balances they would have changed, each in the form an event's data takes. Confirm that none of it committed:

query CustomerFees {
  account(id: "457b3aa9-015a-4424-a689-b5d75861dde8") {
    balance {
      settled {
        drBalance {
          formatted(as: { locale: "en-US" })
        }
      }
    }
  }
}

balance is null: the account has never been posted to.

6. Go live and charge for real

Satisfied with the preview, switch the agent to live. A supplied config replaces the whole config, so send all of it:

mutation GoLive {
  actions {
    updateAction(
      id: "a9cdf240-dfef-4c50-8a27-e4f6d04291f3"
      input: {
        config: {
          runtime: "strands"
          mode: "live"
          model: {
            provider: "fixture"
            script: [
              {
                responses: [
                  [{ toolUse: { name: "charge_fee", input: {} } }]
                  [{ text: "Charged the monthly fee." }]
                ]
              }
            ]
          }
        }
      }
    ) {
      version
      status
      pending
    }
  }
}

Poll until the agent is published again, at version 4, then call it:

mutation ChargeFee {
  actions {
    invokeAction(
      id: "a9cdf240-dfef-4c50-8a27-e4f6d04291f3"
      input: {
        executionId: "b5238447-33fb-4157-a822-be9a419d42fb"
        actionVersion: 4
        input: { account_id: "457b3aa9-015a-4424-a689-b5d75861dde8" }
      }
    ) {
      executionId
      status
      preview
      invocation {
        output
      }
    }
  }
}

This time preview is false, and CustomerFees returns "formatted": "$2.00". Running FeeCalls with this execution's ID shows the charge_fee call with preview: false and an empty writeSet, which is recorded for preview calls only. This execution is the recorded turn you replay in step 8.

7. Change the fee

Raise the fee to 2.50. Updating the tool writes a new version:

mutation RaiseFee {
  actions {
    updateAction(
      id: "db3f21d6-d7a4-459e-aa87-33f27529f40d"
      input: {
        config: {
          runtime: "starlark"
          src: """
          FEE = "2.50"

          def run(event):
              transaction_id = str(uuid.New())
              graphql(
                  "mutation Charge($id: UUID!, $params: JSON!) { postTransaction(input: {transactionId: $id, tranCode: \"PREVIEW_REPLAY_FEE\", params: $params}) { transactionId } }",
                  {"id": transaction_id, "params": {"account": event["data"]["account_id"], "amount": FEE}},
                  operation_name="Charge",
              )
              return {"transaction_id": transaction_id, "fee": FEE}
          """
        }
      }
    ) {
      version
      status
      pending
    }
  }
}

Poll ReadChargeFee until it shows version: 4 and pending: null. The agent still calls version 2, because its reference pins it. A reference without a version would have called the new fee on the agent's next run.

8. Replay the charge against the new fee

replayExecution reruns the recorded turn from step 6 and records the result as a new execution beside it. Its definition is an unpublished definition of the agent to run instead of a stored version: here, the live agent with its reference pinned to the fee tool's version 4. model: RECORDED answers the model calls with the outputs the original turn recorded, so the only difference between the two runs is the tool.

mutation ReplayWithNewFee {
  actions {
    replayExecution(
      id: "b5238447-33fb-4157-a822-be9a419d42fb"
      input: {
        executionId: "cb3b9768-9f2c-46fc-b83b-a8e5beab8b19"
        model: RECORDED
        definition: {
          name: "preview-and-replay-fee-agent"
          description: "Charges the monthly fee for one account"
          kind: "agent"
          agent: {
            prompt: "Charge the monthly account fee for the account you are given, then confirm."
          }
          tool: {
            name: "fee_agent"
            description: "Charge the monthly fee for one account."
            inputSchema: {
              type: "object"
              properties: { account_id: { type: "string", format: "uuid" } }
              required: ["account_id"]
              additionalProperties: false
            }
          }
          config: {
            runtime: "strands"
            mode: "live"
            model: {
              provider: "fixture"
              script: [
                {
                  responses: [
                    [{ toolUse: { name: "charge_fee", input: {} } }]
                    [{ text: "Charged the monthly fee." }]
                  ]
                }
              ]
            }
          }
          tools: [
            {
              name: "preview-and-replay-charge-fee"
              version: 4
              input: { account_id: "tool.input.account_id" }
            }
          ]
          policies: [
            { effect: ALLOW, actions: [SELECT, INSERT, UPDATE], resources: ["financial.*"] }
          ]
        }
      }
    ) {
      executionId
      status
      replayOfId
    }
  }
}
{
  "executionId": "cb3b9768-9f2c-46fc-b83b-a8e5beab8b19",
  "status": "QUEUED",
  "replayOfId": "b5238447-33fb-4157-a822-be9a419d42fb"
}

The replay runs on its own and finishes within a moment. Whatever the agent's definition says about mode, a replay commits nothing.

To replay a stored version instead, pass actionVersion. A replay calls the tool versions the original turn used, unless the replayed definition pins others, so testing a new tool version needs a definition or version that pins it.

9. Compare the two runs

Read the replay, its calls and their write sets, and the original's calls through replayOf:

query CompareReplay {
  actions {
    execution(id: "cb3b9768-9f2c-46fc-b83b-a8e5beab8b19") {
      status
      preview
      replay {
        model
        data
      }
      invocation {
        output
      }
      children(first: 10) {
        nodes {
          actionVersion
          invocation {
            output
          }
          writeSet {
            recordType
            data
          }
        }
      }
      replayOf {
        children(first: 10) {
          nodes {
            actionVersion
            invocation {
              output
            }
          }
        }
      }
    }
  }
}
{
  "status": "SUCCEEDED",
  "preview": true,
  "replay": { "model": "RECORDED", "data": "CURRENT" },
  "invocation": { "output": "Charged the monthly fee." },
  "children": {
    "nodes": [
      {
        "actionVersion": 4,
        "invocation": {
          "output": { "fee": "2.50", "transaction_id": "8eff84e0-a72c-477b-b47d-8a8afb278327" }
        },
        "writeSet": [
          { "recordType": "Transaction", "data": { "…": "…" } },
          {
            "recordType": "Balance",
            "data": {
              "account_id": "bc39941f-2b87-4370-bcba-fa535bc34ade",
              "settled": { "cr_balance": "4.50 USD", "…": "…" },
              "…": "…"
            }
          },
          {
            "recordType": "Balance",
            "data": {
              "account_id": "457b3aa9-015a-4424-a689-b5d75861dde8",
              "settled": { "dr_balance": "4.50 USD", "…": "…" },
              "…": "…"
            }
          },
          {
            "recordType": "Entry",
            "data": {
              "account_id": "bc39941f-2b87-4370-bcba-fa535bc34ade",
              "amount": "2.50 USD",
              "entry_type": "FEE_CR",
              "…": "…"
            }
          },
          {
            "recordType": "Entry",
            "data": {
              "account_id": "457b3aa9-015a-4424-a689-b5d75861dde8",
              "amount": "2.50 USD",
              "entry_type": "FEE_DR",
              "…": "…"
            }
          }
        ]
      }
    ]
  },
  "replayOf": {
    "children": {
      "nodes": [
        {
          "actionVersion": 2,
          "invocation": {
            "output": { "fee": "2.00", "transaction_id": "4cfe0849-18e9-49cd-a026-1f91d367c2d1" }
          }
        }
      ]
    }
  }
}

The agent's reply is the same, the original call ran tool version 2 and charged 2.00, and the replay ran version 4 and would have charged 2.50. The balances in the write set read 4.50 because data: CURRENT: the replay reads the ledger as it is now, which already holds the 2.00 posted in step 6. The transaction IDs differ because the program generates a new one on every run. Run CustomerFees again: the balance is still $2.00.

10. Contrast a rerun

rerunExecution is not a test. It runs the source again for real, at the source's pinned agent version, as a new start by you:

mutation RerunCharge {
  actions {
    rerunExecution(
      id: "b5238447-33fb-4157-a822-be9a419d42fb"
      input: { executionId: "cfb2f9cf-a1fb-41d9-a372-3f7c37bff2e7" }
    ) {
      executionId
      status
      actionVersion
      preview
      rerunOf {
        executionId
      }
    }
  }
}
{
  "executionId": "cfb2f9cf-a1fb-41d9-a372-3f7c37bff2e7",
  "status": "QUEUED",
  "actionVersion": 4,
  "preview": false,
  "rerunOf": { "executionId": "b5238447-33fb-4157-a822-be9a419d42fb" }
}

Once it succeeds, FeeCalls with its ID shows a call to tool version 2 that charged 2.00, and CustomerFees returns $4.00: the customer has been charged twice. Use a rerun to retry work that should happen, such as a charge that failed, and a replay to see what a change would do.

11. Pin the new version

The replay showed the new fee doing what you intended. Point the agent at it by updating its reference. A supplied tools list replaces the whole list:

mutation PinNewFee {
  actions {
    updateAction(
      id: "a9cdf240-dfef-4c50-8a27-e4f6d04291f3"
      input: {
        tools: [
          {
            name: "preview-and-replay-charge-fee"
            version: 4
            input: { account_id: "tool.input.account_id" }
          }
        ]
      }
    ) {
      version
      status
      pending
    }
  }
}

Poll until the agent is published at version 6, then run ChargeFee again with actionVersion: 6 and a fresh executionId, such as dc4937ff-b529-4c3b-b254-13c1e7918f8f. FeeCalls shows the call ran tool version 4 with "fee": "2.50", and CustomerFees returns $6.50.

Troubleshooting

SymptomCheck
children is empty in the invokeAction responseRead the execution again with FeeCalls once the request has returned.
Step 1 fails with a unique constraint violationYou have run the tutorial before. Choose fresh UUIDs and codes, and use the new tran code's code in the tool's program.
The replay ran the old feeA replay calls the tool versions the original turn used. Pin the new version in the replay's definition, as in step 8.
replayExecution reports missing kind or configdefinition is a complete definition, validated as createAction validates one. Send every field, not only the one you changed.
rerunExecution of the replay is rejectedA replay cannot be rerun. Replay the original again with a new executionId.

Read Preview and replay for when to use each, and the reference for preview calls, replays, manual reruns, and tool versions. An MCP server has its own preview mode, and its requests replay the same way.

Previous
Building an MCP Server