# Writing Starlark

Write, test, and debug Starlark tool programs: GraphQL calls, exact money math, nested tools, failures, idempotency, and output.

A [tool](https://www.twisp.com/docs/actions/tools.md) of kind `tool` is a short Starlark program. Starlark is a small, Python-like language: if you can read Python, you can read Starlark. This page shows how to write a tool's program well and how to test it before anything depends on it. The [Starlark reference](https://www.twisp.com/docs/reference/actions/starlark.md) has the complete rules: every global, every value conversion, and every limit.

## The shape of a program

A program defines `run(event)`. Twisp calls it once per invocation, and its return value is the tool's output.

```python
FEES_ACCOUNT = "f43b9441-b96b-446b-b91c-3cfe8c634aaf"

BALANCE = """
query Balance($id: UUID!) {
  account(id: $id) {
    balance(journalId: "00000000-0000-0000-0000-000000000000", currency: "USD") {
      settled { normalBalance { units currency } }
    }
  }
}
"""

def settled_balance(account_id):
    data = graphql(BALANCE, {"id": account_id})
    ...

def run(event):
    data = event["data"]
    ...
    return {"charged": True, "fee": "1.87"}
```

- **Top level: constants and functions.** Put GraphQL documents and fixed IDs in top-level constants, and helpers in functions. Each name is assigned once, and `if` and `for` belong inside functions. Top-level code runs at the start of every invocation, and nothing carries over between invocations.
- **`run` does the work.** `graphql`, `cel`, and `tools` can be called only while `run` runs, from `run` or from a helper it calls.
- **Loops are `for` loops.** There is no `while`, and a function cannot call itself. Iterate over a `range`, or over a page of results.

## Reading input

For a call, `event["data"]` is the input, already validated against the tool's `inputSchema`. Validation does not coerce or fill in defaults, so read optional properties with `.get`:

```python
def run(event):
    data = event["data"]
    account_id = data["account_id"]
    note = data.get("note", "")
```

Input is JSON. An ID arrives as a string and an amount as whatever your schema says, usually a string: declare amounts as strings, such as `{ type: "string", pattern: "^[0-9]+(\\.[0-9]{1,2})?$" }`, and convert them with `decimal(...)` or `money(...)`. A number in JSON input becomes a Starlark `float` when it has a fraction, and a float never becomes a decimal.

When the same tool also runs from a trigger, `event["data"]` is the changed record instead, with typed values: an ID is a UUID, an amount is money. `uuid(x)` and `decimal(x)` accept either a string or a typed value, and `money(x)` a typed money value or, as `money(units, currency)`, strings; normalize at the top of `run`, and the rest of the program does not care how it was started.

`event` and everything in it are frozen. To build on the input, copy it first: `params = dict(event["data"])`. The copy is shallow, so copy a nested dict or list too before changing it.

## Calling GraphQL

`graphql(query, variables)` runs one operation and returns its `data`. Pass values as variables, never by formatting them into the document:

```python
data = graphql(BALANCE, {"id": account_id})
if data["account"] == None:
    fail("account %s does not exist" % account_id, hint="Pass the accountId of an existing account.")
```

- **Keep documents constant.** A document the program passes as a literal or a top-level constant is checked against the schema when you save the tool, with the variables its operation requires, so a typo fails the save rather than a call. A document built with `%` or `.format()` is checked only when it runs.
- **One transaction.** Every call in an invocation runs in the same transaction, so a query after a mutation reads what the mutation wrote. A listing on an index may not show the program's own writes yet; read them back by ID.
- **No directives.** Do not add `@tx`, `@dryRun`, or `@retry`: the tool's caller owns the transaction.
- **JSON both ways.** Variables are sent as JSON: a money value becomes `{"units": "1.87", "currency": "USD"}`, a decimal `"1.87"`, a UUID its string. Responses come back as plain JSON: a `Decimal` field is a string, such as `"1248.12"`, and a `Money` object is a dict. Rebuild typed values from them:

```python
amount = data["account"]["balance"]["settled"]["normalBalance"]
balance = money(amount["units"], amount["currency"])
```

Integers in a response lose precision beyond 2^53. Read and send identifiers and large counts as strings.

## Exact money and decimal math

Every [CEL function](https://www.twisp.com/docs/reference/cel/functions.md) is a Starlark global, so a program does money math exactly without a separate call. Amounts are `decimal` and `money` values, never floats.

```python
rate = decimal(data["rate"])                                  # "0.0015"
fee = money(decimal.Round(balance.decimal() * rate, "half_even", 2), "USD")
total = fee + money("0.25", "USD")                           # same currency only
```

- **`+`, `-`, and `*` are exact.** `balance.decimal() * rate` keeps every digit; round once, at the end, with `decimal.Round(x, mode, places)`, using a mode such as `half_even` or `half_up`.
- **There is no `/`.** Division rounds, so it is explicit: `decimal.Quo(x, y, precision)` divides to `precision` significant digits. Divide with plenty of digits, then round to cents:

  ```python
  third = decimal.Round(decimal.Quo(fee.decimal(), decimal("3"), 34), "down", 2)
  ```

- **Money in major units from strings.** `money("1.50", "USD")` is 1.50 USD, while `money(150, "USD")` counts minor units and is also 1.50 USD. Use strings or decimals for amounts.
- **Compare like with like.** `money("1", "USD") < money("2", "USD")` works; `decimal("2") > 1` fails, and `decimal("1") == 1` is `False`. Convert first: `decimal("2") > decimal("1")`.
- **Zero is false.** `if not fee:` is true for a zero amount.
- **CEL functions take positional arguments only.** `decimal.Round(x, "half_even", 2)`, not `places=2`.

## Using CEL functions

The [CEL function library](https://www.twisp.com/docs/reference/cel/functions.md), the one tran codes, indexes, and triggers use, is also built into Starlark. Each function is a global under its CEL name, so a program hashes, formats, parses, and does date and money math without a separate call:

```python
def run(event):
    m = money("1250.00", "USD")
    t = timestamp("2026-10-01T12:00:00Z")
    return {
        "upper": strings.ToUpper("acme"),                              # "ACME"
        "prefix": strings.HasPrefix("ACH-123", "ACH-"),                # True
        "hash": hex.EncodeToString(sha256.Sum256(b"abc")),             # "ba7816bf..."
        "quarter": cal.Quarter(t, 10),                                 # 1: a fiscal year starting in October
        "year": t.getFullYear(),                                       # 2026
        "month": t.getMonth(),                                         # 9: CEL months count from zero
        "currency": m.currency(),                                      # "USD"
        "tomorrow": str(date(t + duration("24h"))),                    # "2026-10-02"
        "escaped": url.QueryEscape("a b&c"),                           # "a+b%26c"
    }
```

A CEL call reads the same in Starlark, with three rules:

| In CEL                             | In Starlark                        | Rule                                                       |
| ---------------------------------- | ---------------------------------- | ---------------------------------------------------------- |
| `decimal.Round(x, "half_even", 2)` | `decimal.Round(x, "half_even", 2)` | A dotted name is an attribute of its namespace.            |
| `m.currency()`, `t.getFullYear()`  | `m.currency()`, `t.getFullYear()`  | A function CEL calls on a value is a method of that value. |
| `uuid.NewSHA1(ns, b"invoice-42")`  | the same, never `name=...`         | Arguments are positional; a keyword argument fails.        |

The functions keep CEL's behavior exactly, including its quirks, such as zero-based `getMonth()`. They run inside the program and are not recorded, except the ones that read the clock or randomness, such as `time.Now`, `date.Today`, `uuid.New`, and the `rand` functions: each of those is a recorded `cel` call, so a rerun can see what it returned.

Use `cel(expression, bindings)` instead when the logic is an expression you already have as text: the same rule a trigger or tran code uses, or one passed in a tool's input. It evaluates the whole expression, keeps domain types in and out, and is a recorded call that counts toward the [`cel` call limit](https://www.twisp.com/docs/reference/actions/starlark.md#limits):

```python
eligible = cel("m.currency() == 'USD' && t.getMonth() == 9", {"m": m, "t": t})  # True
```

See [CEL functions](https://www.twisp.com/docs/reference/actions/starlark.md#cel-functions) in the Starlark reference for how arguments and values convert.

## Calling other tools

Declare each tool the program calls in the definition's `tools`, then call it with `tools.<name>(input)`, where `<name>` is the called tool's `tool.name`:

```python
case = tools.open_case({"account_id": account_id, "note": reason})
```

The call is validated against the called tool's input schema, runs with the called tool's policies narrowing yours, and returns its output. Input and output cross as JSON, as they do for any caller: pass amounts as strings, such as `str(fee.decimal())`, and rebuild typed values from the output. Pin the called tool's `version` in the reference when a change to it must be a deliberate edit of yours. See [Tool references](https://www.twisp.com/docs/actions/tools.md#tool-references).

## Handling failures

**Fail on purpose with a hint.** When the input cannot be served, call `fail(message, hint)`. The call fails with code `action_failure`, nothing the program wrote remains, and the hint tells the caller, often an agent's model, what to change:

```python
if rate > decimal("0.01"):
    fail("rate %s is over the 1%% cap" % rate, hint="Pass a rate of at most 0.01.")
```

**Recover from a call with `check=False`.** By default, a failed `graphql`, `cel`, or `tools` call fails the whole program. Pass `check=False` when the program can do something sensible instead. The call returns a result with `ok`, `output`, and `error`; when it failed, its writes are already rolled back, and `error` has `code`, `message`, and `hint`:

```python
posted = graphql(POST, variables, check=False)
if not posted.ok:
    if graphql(FIND, {"id": transaction_id})["transaction"] == None:
        fail(posted.error.message, hint=posted.error.hint)
    return {"charged": False, "transaction_id": str(transaction_id)}
```

Re-raise what you do not expect, as above, so a real failure is never swallowed. Starlark has no `try`: an error in the program itself, such as a type error, cannot be caught, and neither can a write conflict, a passed deadline, or a call past a limit.

## Idempotency and stable identifiers

A tool can run more than once for the same request: a client retries a call, a triggered execution is retried after a write conflict, an operator reruns a failed execution. Each run starts the program from the beginning. Make every record the program creates have an identifier that is the same on every run, so a repeat finds the record instead of creating a second one.

- **Take the ID from the caller** when the caller knows it, such as a `transaction_id` input property.
- **Derive it** from what identifies the work. `uuid.NewSHA1(namespace, name)` returns the same UUID for the same namespace and name:

  ```python
  # One fee per account and period.
  transaction_id = uuid.NewSHA1(uuid(data["account_id"]), bytes("fee:" + data["period"]))
  ```

  For an execution started by a trigger, `event["id"]` identifies the change, and stays the same across retries and reruns: `uuid.NewSHA1(event["id"], b"fee")`.

- **Never use `uuid.New()` or `time.Now()` for identity.** They differ on every run.

A unique identifier turns a repeat into a write conflict or a unique constraint error, which the program can recognize, as the example below does. Checking for a record first is a convenience, not a guarantee: two concurrent runs can both find nothing.

A client that calls a tool can also retry safely on its own: repeating `invokeAction` with the same `executionId` and input returns the recorded execution without running the tool again.

## Returning output

Return a dict that matches the tool's `outputSchema`. The output is validated before the call succeeds; a mismatch fails the call with `action_output_invalid`, and the program's writes roll back.

Output is validated in its JSON form: money becomes `"1.87 USD"` and a decimal `"1.87"`. Choose the form deliberately. An agent reads `"1.87 USD"` well; a client that parses amounts may prefer `str(fee.decimal())` with the currency in its own property. When a property has no value, return `None` for it and allow `null` in the schema, or leave it out and do not list it in `required`.

## A complete example

This tool charges an account's monthly fee: a rate times its settled USD balance, rounded to cents, posted through a `TRANSFER` tran code that debits `from` and credits `to`. It charges at most once per account and period, however often it runs.

```python
FEES_ACCOUNT = "f43b9441-b96b-446b-b91c-3cfe8c634aaf"

BALANCE = """
query Balance($id: UUID!) {
  account(id: $id) {
    balance(journalId: "00000000-0000-0000-0000-000000000000", currency: "USD") {
      settled { normalBalance { units currency } }
    }
  }
}
"""

POST = """
mutation Post($id: UUID!, $params: JSON!) {
  postTransaction(input: { transactionId: $id, tranCode: "TRANSFER", params: $params }) {
    transactionId
  }
}
"""

FIND = "query Find($id: UUID!) { transaction(id: $id) { transactionId } }"

def settled_balance(account_id):
    data = graphql(BALANCE, {"id": account_id})
    if data["account"] == None:
        fail("account %s does not exist" % account_id, hint="Pass the accountId of an existing account.")
    balance = data["account"]["balance"]
    if balance == None:
        return money("0", "USD")
    amount = balance["settled"]["normalBalance"]
    return money(amount["units"], amount["currency"])

def run(event):
    data = event["data"]
    rate = decimal(data["rate"])
    fee = money(decimal.Round(settled_balance(data["account_id"]).decimal() * rate, "half_even", 2), "USD")
    if not fee:
        return {"charged": False, "fee": "0.00", "transaction_id": None}

    # One fee per account and period: the same period always gives the same ID.
    transaction_id = uuid.NewSHA1(uuid(data["account_id"]), bytes("fee:" + data["period"]))
    posted = graphql(POST, {
        "id": transaction_id,
        "params": {
            "from": data["account_id"],
            "to": FEES_ACCOUNT,
            "amount": fee.decimal(),
            "memo": "Monthly fee " + data["period"],
        },
    }, check=False)
    if not posted.ok:
        if graphql(FIND, {"id": transaction_id})["transaction"] == None:
            fail(posted.error.message, hint=posted.error.hint)
        return {"charged": False, "transaction_id": str(transaction_id)}
    return {"charged": True, "fee": str(fee.decimal()), "transaction_id": str(transaction_id)}
```

Create it with the program as the `$src` variable:

```graphql
mutation CreateMonthlyFee($src: String!) {
  actions {
    createAction(
      id: "bd6701f7-0827-4413-b4b7-bd8f1d3d1ceb"
      input: {
        name: "monthly-fee"
        description: "Charge an account's monthly fee."
        kind: "tool"
        config: { runtime: "starlark", src: $src }
        tool: {
          name: "monthly_fee"
          description: "Charge an account its monthly fee: a rate times its settled USD balance, rounded to cents. Charges at most once per account and period."
          inputSchema: {
            type: "object"
            properties: {
              account_id: { type: "string", format: "uuid" }
              rate: { type: "string", pattern: "^0\\.[0-9]+$" }
              period: { type: "string", pattern: "^[0-9]{4}-[0-9]{2}$" }
            }
            required: ["account_id", "rate", "period"]
            additionalProperties: false
          }
          outputSchema: {
            type: "object"
            properties: {
              charged: { type: "boolean" }
              fee: { type: "string" }
              transaction_id: { type: ["string", "null"] }
            }
            required: ["charged", "transaction_id"]
            additionalProperties: false
          }
        }
        policies: [
          { effect: ALLOW, actions: [SELECT, INSERT], resources: ["financial.*"] }
          {
            effect: ALLOW
            actions: [UPDATE]
            resources: ["financial.Account.*", "financial.Balance.*"]
          }
        ]
      }
    ) {
      actionId
      version
      status
      pending
    }
  }
}
```

The policies allow what the program does and nothing more: read, insert the transaction and its entries, and update the accounts' balances. A tool with no policies can do nothing. See [Authority](https://www.twisp.com/docs/actions/tools.md#authority).

## Common mistakes

| Mistake                                            | What happens                                              | Instead                                                        |
| -------------------------------------------------- | --------------------------------------------------------- | -------------------------------------------------------------- |
| Changing `event["data"]` or a dict from it         | `cannot insert into frozen hash table`                    | Copy it: `d = dict(event["data"])`.                            |
| `decimal(0.1)`, `amount * 0.5`                     | `a float never becomes a decimal`                         | Use strings: `decimal("0.1")`, `amount * decimal("0.5")`.      |
| `amount / 3` on a decimal or money                 | `decimal / int is not defined`                            | `decimal.Quo(amount, decimal("3"), 34)`, then `decimal.Round`. |
| `decimal("2") > 1`, `uuid(s) == s`                 | The comparison fails; the equality is silently `False`.   | Convert both sides to the same type.                           |
| `money(150, "USD")` meaning 150 dollars            | It is 1.50 USD: an integer counts minor units.            | `money("150", "USD")`.                                         |
| A large integer through GraphQL                    | It comes back rounded.                                    | Send and read it as a string.                                  |
| `print(x)` to debug                                | Nothing: `print` output is discarded.                     | Return the value, or `fail("x: %s" % x)` while you test.       |
| `while`, recursion, `set`, `load`                  | Saving fails, or recursion fails when it runs.            | `for` over a `range`; a dict's keys; one file.                 |
| A top-level `graphql()` call                       | `graphql may only be called from run(event)`              | Call it inside `run` or a helper.                              |
| Keyword arguments to a CEL function                | `CEL functions take no keyword arguments`                 | Positional arguments.                                          |
| `uuid.New()` for a record's ID                     | A retry creates a second record.                          | `uuid.NewSHA1(...)` from the input or event.                   |
| Returning money where the schema says `number`     | `action_output_invalid`: money is `"1.87 USD"` in output. | Return `str(m.decimal())` and declare a string.                |
| Returning `None` from a tool with an output schema | `action_output_invalid`, unless the schema allows `null`. | Return a dict the schema accepts.                              |

## Testing and debugging

Test a program in the [local environment](https://www.twisp.com/docs/infrastructure/local-environment.md) or a sandbox tenant, in this order: check it without running it, run it without committing, then run it for real.

### 1. Check it with `validateAction`

`validateAction` compiles the program and checks every GraphQL document it passes as a constant, the variables each operation requires, and each `tools.<name>`, without storing or running anything:

```graphql
mutation ValidateMonthlyFee($src: String!) {
  actions {
    validateAction(
      input: {
        name: "monthly-fee"
        kind: "tool"
        config: { runtime: "starlark", src: $src }
        tool: {
          name: "monthly_fee"
          description: "Charge an account its monthly fee."
          inputSchema: { type: "object" }
        }
      }
    ) {
      definition {
        kind
      }
    }
  }
}
```

A problem comes back as an error naming the line and column, and, for a document, how the document reaches the call:

```
src: BALANCE (program.star:7:17) reaches graphql() at program.star:24:12: Cannot query field "normalBalanc" on type "BalanceAmount". Did you mean "normalBalance"?
```

`createAction` and `updateAction` run the same checks. Recursion and type errors show only when the program runs.

### 2. Run it without committing: `@dryRun`

Once the tool is `ACTIVE`, call it inside a mutation marked `@dryRun`. The program runs completely, with real reads, and the response carries its output or error, but nothing commits: not its writes, and not the execution record. A call that succeeds this way reports `invocation.dryRun: true`.

```graphql
mutation TryMonthlyFee @dryRun {
  actions {
    invokeAction(
      id: "bd6701f7-0827-4413-b4b7-bd8f1d3d1ceb"
      input: {
        executionId: "86cc6224-8adc-41e3-849f-205b4c768e03"
        actionVersion: 1
        input: {
          account_id: "1567c942-2748-4baa-83ec-cbbc485e6661"
          rate: "0.0015"
          period: "2026-10"
        }
      }
    ) {
      status
      invocation {
        output
        error {
          code
          message
          hint
        }
      }
    }
  }
}
```

```json
{
  "status": "SUCCEEDED",
  "invocation": {
    "output": {
      "charged": true,
      "fee": "1.87",
      "transaction_id": "dc083b8a-4a61-5e77-85aa-6a9e59c0931a"
    },
    "error": null
  }
}
```

Because nothing is recorded, there is no execution to read afterward, and you can reuse the `executionId`. Use a dry run to see output and errors; use a preview, below, to see what the program would write.

### 3. Preview it, and drafts of it, through an MCP server

An [MCP server](https://www.twisp.com/docs/actions/mcp-servers.md) that lists the native `invoke` tool runs a tool with `commit: false`: every call runs, nothing commits, and the result lists the program's calls and the rows it would have written. It also runs a **draft**, an unsaved definition, so you can try a change before `updateAction` publishes it to every caller, and a tool created with `enabled: false`, which nothing else can call yet.

Create a server for development that lists `invoke`, and `graphql` and `cel` so drafts can use them:

```graphql
mutation CreateToolDevServer {
  actions {
    createAction(
      id: "aef6d8b2-dcdc-430b-aa62-6c9641f45804"
      input: {
        name: "tool-dev"
        description: "Preview tools and drafts of them while they are written."
        kind: "mcp"
        config: {}
        mcp: {
          name: "tool-dev"
          instructions: "Preview tools and drafts of tools with invoke."
          session: { start: { input: { principal: "execution.input.principal" } } }
        }
        session: {
          inputSchema: {
            type: "object"
            properties: { principal: { type: "string" } }
            required: ["principal"]
          }
          id: "session.input.principal"
        }
        tools: [{ name: "invoke" }, { name: "graphql" }, { name: "cel" }]
        policies: [
          { effect: ALLOW, actions: [SELECT, INSERT, UPDATE, INVOKE], resources: ["financial.*"] }
        ]
      }
    ) {
      actionId
      version
      status
    }
  }
}
```

Connect to it as in [Building an MCP Server](https://www.twisp.com/docs/tutorials/actions/mcp-server.md), or from your MCP host, and call `invoke`. These are the `tools/call` arguments that preview the stored tool:

```json
{
  "name": "invoke",
  "arguments": {
    "action_id": "bd6701f7-0827-4413-b4b7-bd8f1d3d1ceb",
    "input": {
      "account_id": "1567c942-2748-4baa-83ec-cbbc485e6661",
      "rate": "0.0015",
      "period": "2026-11"
    },
    "intent": "Preview the monthly fee for November."
  }
}
```

The result's `structuredContent`, trimmed:

```json
{
  "status": "SUCCEEDED",
  "output": { "charged": true, "fee": "1.87", "transaction_id": "a75be203-45d5-5923-9b2b-1d8912853f4c" },
  "calls": [
    { "name": "graphql", "status": "SUCCEEDED", "execution_id": "8201b1e2-…" },
    { "name": "graphql", "status": "SUCCEEDED", "execution_id": "e4be07e3-…" }
  ],
  "writes": [
    { "table": "Transaction", "key": "…", "value": { "description": "Monthly fee 2026-11", "transaction_id": "a75be203-…", ... } },
    { "table": "Balance", "key": "…", "value": { ... } },
    { "table": "Entry", "key": "…", "value": { "amount": "1.87 USD", ... } },
    ...
  ],
  "unreadable_writes": 0,
  "omitted_writes": 0
}
```

To try a change, pass `definition`: the whole definition, in the shape `createAction`'s `input` takes, with your new `src`. A draft that adds a rate cap, called with a rate over it:

```json
{
  "name": "invoke",
  "arguments": {
    "action_id": "bd6701f7-0827-4413-b4b7-bd8f1d3d1ceb",
    "definition": {
      "name": "monthly-fee",
      "description": "Charge an account's monthly fee.",
      "kind": "tool",
      "config": { "runtime": "starlark", "src": "..." },
      "tool": { "name": "monthly_fee", "description": "...", "inputSchema": { ... } },
      "policies": [ ... ]
    },
    "input": { "account_id": "1567c942-2748-4baa-83ec-cbbc485e6661", "rate": "0.02", "period": "2026-11" },
    "intent": "Check the rate cap in the draft."
  }
}
```

```json
{
  "status": "FAILED",
  "error": {
    "code": "action_failure",
    "message": "rate 0.02 is over the 1% cap",
    "hint": "Pass a rate of at most 0.01."
  },
  "calls": [],
  "writes": []
}
```

When the program itself errs, the preview's error message is the traceback of your source, such as `program.star:4:18: in run` and `program.star:2:14: in helper`, as `invokeAction`'s is. When the draft works, publish it with `updateAction`. See [Invoking an action](https://www.twisp.com/docs/reference/actions/mcp.md#invoke) for every argument and rule.

### 4. Call it for real, and read its calls

Remove `@dryRun` from the mutation in step 2 and run it again. The call commits with your request. Repeating the request with the same `executionId` and input returns the recorded result instead of charging again; the same `executionId` with different input is an error.

Every `graphql`, `cel`, and `tools` call the program made is a child of its execution, with the input it ran with and its output or error. The children can take a few seconds to appear after the call commits.

```graphql
query MonthlyFeeCalls {
  actions {
    execution(id: "86cc6224-8adc-41e3-849f-205b4c768e03") {
      status
      children(first: 20) {
        nodes {
          callSequence
          status
          event {
            data
          }
          invocation {
            output
            error {
              code
              message
            }
          }
        }
      }
    }
  }
}
```

Each child's `event.data` holds the query and variables the program sent, and its `invocation.output` the data it got back. A failed execution's `invocation.error.details.cause` names the call that failed it, when that was a call the program did not handle. See [Lineage](https://www.twisp.com/docs/reference/actions/executions.md#lineage).

### 5. Re-run triggered work

A tool started by a trigger cannot be called with a past event, but a failed or finished execution can be rerun with `rerunExecution`, which receives the same event. Pass a newer `actionVersion` to test a fix against the event that failed. See [Manual reruns](https://www.twisp.com/docs/reference/actions/executions.md#manual-reruns). A tool's own execution cannot be replayed with `replayExecution`; replays are for [agent turns and MCP requests](https://www.twisp.com/docs/actions/preview-and-replay.md).

## Further reading

- [Starlark reference](https://www.twisp.com/docs/reference/actions/starlark.md): the dialect, globals, values, failures, transactions, and limits.
- [The Starlark language specification](https://github.com/bazelbuild/starlark/blob/master/spec.md): the language itself. Twisp's dialect leaves out `while`, recursion, `set`, and `load`.
- [CEL functions](https://www.twisp.com/docs/reference/cel/functions.md) and [CEL Function Examples](https://www.twisp.com/docs/reference/cel/examples.md): every function a program can call by name.
- [Building Transactional Actions](https://www.twisp.com/docs/tutorials/advanced-actions.md): a triggered Starlark tool with rollback, reruns, and a direct call.
- [Building an MCP Server](https://www.twisp.com/docs/tutorials/actions/mcp-server.md): tools behind an MCP server, previews, and an atomic `program`.
- [Tools](https://www.twisp.com/docs/actions/tools.md): calling and starting tools, nested calls, and tool references.
