Running Actions Locally

Create an action that reacts to journal changes, watch it run, read its results and history, then rerun, cancel, and retire it.

This tutorial covers a CDC-triggered action: an action invoked by database change data capture (CDC), rather than a tool, skill, or agent invoked without a database event. See Actions for the other kinds, and Triggers and Executions for the concepts this tutorial puts to work.

You create an action that runs whenever a specific journal is inserted or updated, produce a matching event, inspect the execution and its typed payloads, and then manage the action's lifecycle.

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

The action uses the built-in agent.echo handler, which waits briefly and returns the event it received. If you have run this tutorial before, choose fresh UUIDs for the action and journal.

1. Validate the trigger

validateAction resolves defaults and evaluates the action's triggers against sample events without saving anything. This action has one trigger, which matches journal inserts and updates for one journal ID. Record fields keep their types, so the journal identifier is compared with uuid(...), and each sample is a CEL map: the first is a journal event with a typed identifier, the second an account event.

mutation ValidateJournalEcho {
  actions {
    validateAction(
      input: {
        description: "Local journal echo"
        triggers: [
          {
            event: """
            event.type in ["journal.inserted", "journal.updated"] &&
            event.data.journal_id == uuid("e3a91846-cd8a-4b2c-9a73-e159498f0571")
            """
          }
        ]
        kind: "agent.echo"
        config: { agent_id: "local-journal-echo", sleep_ms: 1000 }
      }
      samples: [
        """
        {"type": "journal.inserted", "data": {"journal_id": uuid("e3a91846-cd8a-4b2c-9a73-e159498f0571")}}
        """
        """
        {"type": "account.inserted", "data": {"account_id": uuid("e3a91846-cd8a-4b2c-9a73-e159498f0571")}}
        """
      ]
    ) {
      samples {
        matched
        error
      }
      definition {
        execution {
          timeout
          maxInvocations
          retryDelay
          retryBackoff
          responseTimeout
        }
      }
    }
  }
}

You should see one result per sample, plus the execution settings the action would get:

{
  "samples": [
    { "matched": true, "error": null },
    { "matched": false, "error": null }
  ],
  "definition": {
    "execution": {
      "timeout": "24h0m0s",
      "maxInvocations": 24,
      "retryDelay": "1m0s",
      "retryBackoff": 2,
      "responseTimeout": "1m0s"
    }
  }
}

2. Create the action

mutation CreateJournalEcho {
  actions {
    createAction(
      id: "06df247a-af36-45d0-8318-43b0f3b12080"
      input: {
        description: "Local journal echo"
        triggers: [
          {
            event: """
            event.type in ["journal.inserted", "journal.updated"] &&
            event.data.journal_id == uuid("e3a91846-cd8a-4b2c-9a73-e159498f0571")
            """
          }
        ]
        kind: "agent.echo"
        config: { agent_id: "local-journal-echo", sleep_ms: 1000 }
      }
    ) {
      actionId
      version
      status
      pending
    }
  }
}

The response reports status: UNPUBLISHED and pending: ACTIVE: the definition is stored and waiting to publish. Poll until it is live:

query ReadJournalEcho {
  actions {
    action(id: "06df247a-af36-45d0-8318-43b0f3b12080") {
      version
      status
      pending
      published
      definition {
        enabled
        triggers {
          event
        }
      }
    }
  }
}

Wait for status: ACTIVE with pending: null before creating the journal. Actions respond to new events only; creating one does not replay earlier changes. Publication also advances version.

3. Produce a matching event

mutation CreateEchoJournal {
  createJournal(
    input: {
      journalId: "e3a91846-cd8a-4b2c-9a73-e159498f0571"
      name: "Actions tutorial"
      description: "First matching event"
    }
  ) {
    journalId
    version
  }
}

Each later update to the journal produces another matching event. Change the description whenever you want a fresh one:

mutation UpdateEchoJournal {
  updateJournal(
    id: "e3a91846-cd8a-4b2c-9a73-e159498f0571"
    input: { description: "Another matching event" }
  ) {
    journalId
    version
  }
}

4. Find the execution

List the action's executions. The one for your journal change appears within a moment and reaches SUCCEEDED after the handler's one second sleep.

query JournalEchoExecutions {
  actions {
    executions(where: { actionId: "06df247a-af36-45d0-8318-43b0f3b12080" }, first: 20) {
      nodes {
        executionId
        version
        status
        event {
          eventId
          recordId
          occurredAt
        }
        invocation {
          number
          startedAt
        }
      }
      pageInfo {
        endCursor
        hasNextPage
      }
    }
  }
}
{
  "nodes": [
    {
      "executionId": "3f0c1a2e-…",
      "version": 3,
      "status": "SUCCEEDED",
      "event": {
        "eventId": "9b7d…",
        "recordId": "c41e…",
        "occurredAt": "2026-09-19T17:40:12.118Z"
      },
      "invocation": { "number": 1, "startedAt": "2026-09-19T17:40:12.402Z" }
    }
  ],
  "pageInfo": { "endCursor": "…", "hasNextPage": false }
}

The execution moved through QUEUED, RUNNING, and SUCCEEDED, one record version per step. invocation is null until the first invocation starts.

Copy executionId into the Variables panel for the next steps:

{ "executionId": "REPLACE_WITH_EXECUTION_UUID" }

5. Read the event and output

event carries typed fields, and data, previous, and invocation.output are Value documents. For echo, the output is the event it received.

query ReadEchoPayloads($executionId: UUID!) {
  actions {
    execution(id: $executionId) {
      event {
        eventId
        eventType
        occurredAt
        recordType
        recordId
        recordVersion
        data
        previous
      }
      invocation {
        output
      }
    }
  }
}
{
  "event": {
    "eventId": "9b7d…",
    "eventType": "journal.inserted",
    "occurredAt": "2026-09-19T17:40:12.118Z",
    "recordType": "Journal",
    "recordId": "c41e…",
    "recordVersion": 1,
    "data": {
      "journal_id": "e3a91846-cd8a-4b2c-9a73-e159498f0571",
      "name": "Actions tutorial",
      "description": "First matching event",
      "status": 0,
      "created": "2026-09-19T17:40:12.118Z",
      "modified": "2026-09-19T17:40:12.118Z",
      "version": 1
    },
    "previous": null
  },
  "invocation": {
    "output": {
      "id": "9b7d…",
      "type": "journal.inserted",
      "occurred_at": "2026-09-19T17:40:12.118Z",
      "record_type": "Journal",
      "record_id": "c41e…",
      "record_version": 1,
      "data": { "journal_id": "e3a91846-cd8a-4b2c-9a73-e159498f0571", "…": "…" },
      "previous": null
    }
  }
}

The journal inside data uses its snake_case record fields, and every field keeps its type: journal_id is stored as a UUID and rendered as one, integers are exact, and money or decimal fields in other records arrive as "9.99 USD" or "0.125". The echo output is the event as your trigger sees it: id and type, then snake_case keys. An update event carries the earlier journal in previous.

6. Read the history

Every status change is a retained version. History lists them newest first, and each node can show the invocation as it was at that version.

query EchoHistory($executionId: UUID!) {
  actions {
    execution(id: $executionId) {
      status
      history(first: 20) {
        nodes {
          version
          status
          invocation {
            number
            startedAt
            error {
              code
              message
              retryable
            }
          }
        }
      }
    }
  }
}
{
  "status": "SUCCEEDED",
  "history": {
    "nodes": [
      {
        "version": 3,
        "status": "SUCCEEDED",
        "invocation": { "number": 1, "startedAt": "…", "error": null }
      },
      {
        "version": 2,
        "status": "RUNNING",
        "invocation": { "number": 1, "startedAt": "…", "error": null }
      },
      { "version": 1, "status": "QUEUED", "invocation": null }
    ]
  }
}

version is the execution's record version. actionVersion on the execution is the action version it was pinned to; the two are unrelated.

7. Rerun the execution

A rerun creates a new execution for the same event, keeping the original untouched. The source must be terminal. The id argument is the source execution from your Variables panel, and input.executionId is a UUID you choose for the new execution; repeating the request with the same value returns the same rerun.

mutation RerunEcho($executionId: UUID!) {
  actions {
    rerunExecution(
      id: $executionId
      input: { executionId: "7cbe5dd0-7b39-4099-8f60-afb97bd1ce90" }
    ) {
      executionId
      status
      rerunOf {
        executionId
        status
      }
    }
  }
}

The new execution starts QUEUED and rerunOf points back at the source. To rerun once more, choose a new input.executionId. By default the rerun pins the source's action version even if the action has changed since; pass actionVersion to choose another exact version.

8. Watch retries and cancel

To see retries, create a second action whose handler is slower than its response timeout. Use a fresh action ID and a fresh journal ID in the trigger, and add execution beside config:

mutation CreateSlowEcho {
  actions {
    createAction(
      id: "1d3b6ad0-3c9c-4f65-9a6d-4dcd2d3f0d5b"
      input: {
        description: "Slow echo to observe retries"
        triggers: [
          {
            event: """
            event.type in ["journal.inserted", "journal.updated"] &&
            event.data.journal_id == uuid("5b8e0b2a-9a67-4e5d-8f0a-2f2c7c1a9d11")
            """
          }
        ]
        kind: "agent.echo"
        config: { agent_id: "slow-echo", sleep_ms: 5000 }
        execution: {
          responseTimeout: "1s"
          retryDelay: "30s"
          retryBackoff: 1
          maxInvocations: 3
          timeout: "3m"
        }
      }
    ) {
      status
      pending
    }
  }
}

Wait for ACTIVE, then create a journal with that ID. Each invocation times out after one second, so the execution alternates between RUNNING and RETRY_SCHEDULED with a flat thirty second wait, and its history shows an action_response_timeout error on each invocation:

{
  "status": "RETRY_SCHEDULED",
  "nextInvocationAt": "2026-09-19T17:52:41.005Z",
  "invocation": { "number": 1, "error": { "code": "action_response_timeout", "retryable": true } }
}

Automatic retries keep the same executionId. Cancel it while it is between invocations:

mutation CancelEcho($executionId: UUID!) {
  actions {
    cancelExecution(id: $executionId) {
      executionId
      status
    }
  }
}

While the status is QUEUED or RETRY_SCHEDULED, the response is CANCELLED. While an invocation is RUNNING, the response may still be RUNNING, with the cancellation requested: the invocation's writes roll back, and the execution becomes CANCELLED when the invocation ends. Read it again to see CANCELLED. Left alone, the execution ends EXHAUSTED after its third invocation.

9. Disable and delete the action

Updates change only the fields you supply. Disable the first action while keeping everything else:

mutation DisableJournalEcho {
  actions {
    updateAction(id: "06df247a-af36-45d0-8318-43b0f3b12080", input: { enabled: false }) {
      version
      status
      pending
    }
  }
}

The response shows status: ACTIVE with pending: DISABLED. Poll ReadJournalEcho until status is DISABLED; journal updates from then on create no executions, while existing ones finish.

Deleting is a soft delete. The action and everything it produced stay readable:

mutation DeleteJournalEcho {
  actions {
    deleteAction(id: "06df247a-af36-45d0-8318-43b0f3b12080") {
      version
      status
      pending
    }
  }
}

Troubleshooting

SymptomCheck
No execution appearsThe action must be status: ACTIVE before the journal change. Create or update the journal again, then list executions.
Validation does not matchEvent and record keys are snake_case, and identifiers are UUIDs: compare with uuid("…") and write samples with uuid("…") too.
updateAction reports the action was deletedA deleted action is immutable. Create a new action with a new ID.
A rerun is rejectedThe source must be SUCCEEDED, FAILED, EXHAUSTED, or CANCELLED, and executionId must be new or identical to the earlier request.
Cancel returns RUNNINGThe invocation was running; it stops and the execution becomes CANCELLED when it ends. Read it again. A finished one is unchanged.

Next, run GraphQL calls in one transaction from a Starlark action in Building Transactional Actions. Read Triggers and Executions for design advice on triggers and execution settings, and the reference for every field, status, limit, and error.