# 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](https://www.twisp.com/docs/actions.md) for the other kinds, and [Triggers and Executions](https://www.twisp.com/docs/actions/triggers-and-executions.md) 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.

> **Note:**
>
> You need a running [local environment](https://www.twisp.com/docs/infrastructure/local-environment.md). 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.

```graphql
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:

```json
{
  "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

```graphql
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:

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

> **Note:**
>
> 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

```graphql
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:

```graphql
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.

```graphql
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
      }
    }
  }
}
```

```json
{
  "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:

```json
{ "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.

```graphql
query ReadEchoPayloads($executionId: UUID!) {
  actions {
    execution(id: $executionId) {
      event {
        eventId
        eventType
        occurredAt
        recordType
        recordId
        recordVersion
        data
        previous
      }
      invocation {
        output
      }
    }
  }
}
```

```json
{
  "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.

```graphql
query EchoHistory($executionId: UUID!) {
  actions {
    execution(id: $executionId) {
      status
      history(first: 20) {
        nodes {
          version
          status
          invocation {
            number
            startedAt
            error {
              code
              message
              retryable
            }
          }
        }
      }
    }
  }
}
```

```json
{
  "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.

```graphql
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`:

```graphql
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:

```json
{
  "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:

```graphql
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:

```graphql
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:

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

## Troubleshooting

| Symptom                                       | Check                                                                                                                                     |
| --------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
| No execution appears                          | The action must be `status: ACTIVE` before the journal change. Create or update the journal again, then list executions.                  |
| Validation does not match                     | Event and record keys are snake_case, and identifiers are UUIDs: compare with `uuid("…")` and write samples with `uuid("…")` too.         |
| `updateAction` reports the action was deleted | A deleted action is immutable. Create a new action with a new ID.                                                                         |
| A rerun is rejected                           | The source must be `SUCCEEDED`, `FAILED`, `EXHAUSTED`, or `CANCELLED`, and `executionId` must be new or identical to the earlier request. |
| Cancel returns `RUNNING`                      | The 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](https://www.twisp.com/docs/tutorials/advanced-actions.md). Read [Triggers and Executions](https://www.twisp.com/docs/actions/triggers-and-executions.md) for design advice on triggers and execution settings, and the [reference](https://www.twisp.com/docs/reference/actions.md) for every field, status, limit, and error.
