# Starlark

The Starlark runtime for tools and MCP programs: language, event, globals, values, failures, transactions, and limits.

Starlark is the language of two kinds of program in Twisp:

- A **tool** of kind `tool`, whose `config` holds `runtime: "starlark"` and the program in `src`. See [The `tool` handler](https://www.twisp.com/docs/reference/actions/tools.md#the-tool-handler).
- A **program** an external agent sends to an MCP server's native [`program`](https://www.twisp.com/docs/reference/actions/mcp.md#programs) tool. See [MCP programs](https://www.twisp.com/docs/reference/actions/starlark.md#mcp-programs) for how it differs.

Both run the same language, with the same globals, values, and limits, unless this page says otherwise. For how to write and test a program, see [Writing Starlark](https://www.twisp.com/docs/actions/starlark.md).

```python
def run(event):
    data = event["data"]
    found = graphql(
        "query Read($id: UUID!) { account(id: $id) { accountId name } }",
        {"id": data["account_id"]},
    )
    if found["account"] == None:
        fail("account not found", hint="Pass the accountId of an existing account.")
    return found["account"]
```

## Programs

A program is one Starlark file of 1 byte to 64 KiB. It must define `run`, a function of exactly one parameter, `event`. Each invocation runs the program afresh: its top-level statements run first, then `run(event)` is called. Top-level names are frozen once the top-level statements finish, so `run` can read them but not change them, and nothing carries over from one invocation to the next. Use top-level constants for GraphQL documents and fixed values, and functions for helpers.

The value `run` returns becomes the invocation's output. Returning `None`, or nothing, produces no output. The output must be a value of a supported type (see [Values](https://www.twisp.com/docs/reference/actions/starlark.md#values)), and for a tool it is validated against the tool's `outputSchema` before the call succeeds: with an output schema that does not allow `null`, returning `None` fails with `action_output_invalid`.

`graphql`, `cel`, `tools`, and the CEL functions that read the clock or randomness can be called only while `run` runs, from `run` or from functions it calls. Calling one from a top-level statement fails the invocation with `graphql may only be called from run(event)`, or the like.

## The event

`event` is a frozen dict. Its keys are those triggers bind to `event`:

| Key              | For a call                                                                                                        | For an execution started by a trigger                  |
| ---------------- | ----------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------ |
| `id`             | The call's execution ID, a UUID.                                                                                  | The committed change's identifier, a UUID.             |
| `type`           | `"action.invoked"`.                                                                                               | The event type, such as `"account.updated"`.           |
| `occurred_at`    | The start of the caller's transaction, a timestamp.                                                               | The change's commit time, a timestamp.                 |
| `data`           | The call's effective input, with [fixed input](https://www.twisp.com/docs/reference/actions/tools.md#tool-references) applied.                 | The record after the change, with typed values.        |
| `previous`       | `None`.                                                                                                           | The record before the change, or `None` for an insert. |
| `record_type`    | `None`.                                                                                                           | The record name, such as `"Account"`.                  |
| `record_id`      | `None`.                                                                                                           | The changed record's ID.                               |
| `record_version` | `None`.                                                                                                           | The changed record's version.                          |
| `session`        | For a call to a [tool with a session](https://www.twisp.com/docs/reference/actions/sessions.md#tool-sessions): the session's `id` and `input`. | Absent.                                                |

A call's input is JSON, so in `event["data"]` an ID is a string and an amount is whatever the input schema says, usually a string. A trigger's record keeps its typed values: an ID is a UUID, an amount is money. Constructors accept both forms, so `uuid(x)` and `decimal(x)` normalize a value that may arrive either way. See [The event](https://www.twisp.com/docs/reference/actions/executions.md#the-event) for the fields of each record type.

Every dict and list in `event` is frozen, at every depth. Assigning into one fails with `cannot insert into frozen hash table`. Copy what you need to change: `dict(event["data"])`, `list(items)`. A copy is shallow: the dicts and lists inside it are still frozen, so copy each level you change.

## Language

The dialect is Starlark as specified by the [Starlark language specification](https://github.com/bazelbuild/starlark/blob/master/spec.md), with these choices:

| Feature                                        | Available | Notes                                                                                                                                           |
| ---------------------------------------------- | --------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
| `def`, `lambda`, `if`, `for`, comprehensions   | Yes       | `if` and `for` only inside functions, as top-level statements are declarations.                                                                 |
| `while`                                        | No        | Saving fails with `this Starlark dialect does not support while loops`. Use `for` over a `range`.                                               |
| Recursion                                      | No        | A function that calls itself, directly or through others, fails the invocation with `function f called recursively`. Saving does not detect it. |
| `load`                                         | No        | Saving fails with `load is not supported in Starlark programs`. A program is one file.                                                          |
| `set`                                          | No        | Saving fails with `this Starlark dialect does not support sets`. Use a dict's keys.                                                             |
| Reassigning a top-level name                   | No        | Saving fails with `cannot reassign global`. Assign each top-level name once.                                                                    |
| Integers                                       | Yes       | Arbitrary precision inside the program. See [Values](https://www.twisp.com/docs/reference/actions/starlark.md#values) for what crosses a boundary.                                                      |
| Floats                                         | Yes       | Never use them for amounts: a float never becomes a decimal. See [Domain values](https://www.twisp.com/docs/reference/actions/starlark.md#domain-values).                                               |
| `bytes`, `b"..."` literals                     | Yes       |                                                                                                                                                 |
| `try`/`except`, classes, `**`, `sum`, `struct` | No        | These are not part of Starlark. Handle a failed call with [`check=False`](https://www.twisp.com/docs/reference/actions/starlark.md#handling-failed-calls).                                              |

There is no filesystem, network, process, or environment access. A program reaches the ledger only through `graphql`, `cel`, and `tools`.

## Globals

| Name                                                            | What it is                                                                                                                                                                                                                                                             |
| --------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `graphql(query, variables=None, operation_name="", check=True)` | Runs a GraphQL operation and returns its `data`. See [`graphql`](https://www.twisp.com/docs/reference/actions/starlark.md#graphql).                                                                                                                                                                                            |
| `cel(expression, bindings={}, check=True)`                      | Evaluates a CEL expression and returns its value. See [`cel`](https://www.twisp.com/docs/reference/actions/starlark.md#cel).                                                                                                                                                                                                   |
| `tools`                                                         | The tools the program may call: `tools.<name>(input, check=True)`. See [`tools`](https://www.twisp.com/docs/reference/actions/starlark.md#tools).                                                                                                                                                                              |
| `fail(message, hint=None)`                                      | Fails the invocation. See [`fail`](https://www.twisp.com/docs/reference/actions/starlark.md#fail).                                                                                                                                                                                                                             |
| CEL functions                                                   | Every function of the shared CEL library, such as `money`, `decimal.Round`, and `uuid.NewSHA1`. See [CEL functions](https://www.twisp.com/docs/reference/actions/starlark.md#cel-functions).                                                                                                                                   |
| Starlark's built-ins                                            | `None`, `True`, `False`, `abs`, `all`, `any`, `bool`, `bytes`, `chr`, `dict`, `dir`, `enumerate`, `float`, `getattr`, `hasattr`, `hash`, `int`, `len`, `list`, `max`, `min`, `ord`, `print`, `range`, `repr`, `reversed`, `sorted`, `str`, `tuple`, `type`, and `zip`. |

`print` writes nowhere: its output is discarded. To see a value while you develop a program, return it, or fail with it: `fail("balance: %s" % balance)`.

Where a CEL function and a Starlark built-in share a name, Starlark's wins: `bool`, `bytes`, `int`, `type`, and `print` are Starlark's. CEL's `string`, `size`, `uint`, `double`, and every other function keep their CEL names. Twisp's `fail` replaces Starlark's.

### `graphql`

`graphql(query, variables=None, operation_name="", check=True)` calls the native [`graphql` tool](https://www.twisp.com/docs/reference/actions/tools.md#native-tools) with the query, its variables, and the operation to run, and returns the response's `data` as dicts, lists, and scalars. `variables` must be a dict. A response with errors fails the call, with code `action_graphql_error`, or `action_access_denied` for a permission error; partial data is never returned.

- **Same transaction.** Each call runs in the program's transaction, so a later call reads what an earlier one wrote. See [Transactions](https://www.twisp.com/docs/reference/actions/starlark.md#transactions).
- **No transaction directives.** `@tx`, `@dryRun`, and `@retry` are rejected inside a call: the program owns the transaction.
- **No starts.** An operation that invokes or starts an action, such as `startAction`, is rejected; a tool calls another tool through `tools`.
- **Authority.** The call runs as the program's principal, narrowed by every policy on its [policy chain](https://www.twisp.com/docs/reference/actions/actions.md#policy-chain). A program cannot change the endpoint, headers, or identity.
- **JSON both ways.** Variables are sent as JSON, and the response comes back as JSON. See [Values](https://www.twisp.com/docs/reference/actions/starlark.md#values).

### `cel`

`cel(expression, bindings={}, check=True)` calls the native `cel` tool and returns the expression's value. The expression is at most 16 KiB. `bindings` is a dict of the names the expression reads; nothing else is bound, so pass every value it needs. Money, decimals, UUIDs, dates, timestamps, durations, and bytes pass in and out with their types. A compile or evaluation error fails the call with code `action_cel_error`.

```python
eligible = cel("balance >= minimum", {"balance": balance, "minimum": money("100", "USD")})
```

A Starlark int is bound as a CEL `int`, as an integer literal is, so `cel("n + 1", {"n": 5})` is `6`. Only an int beyond the signed 64-bit range is bound as a `uint`; compare it with uint literals, such as `n > 1u`.

The CEL functions are also [globals](https://www.twisp.com/docs/reference/actions/starlark.md#cel-functions), which run inside the program and are not recorded. Use `cel` for a whole expression you want recorded as its own call.

### `tools`

`tools.<name>(input, check=True)` calls a tool by its `tool.name`, and `tools["<name>"]` looks up one whose name is not a Starlark identifier, such as a name with a hyphen, to call the same way. `input` is a dict. The call returns the tool's output. `dir(tools)` lists the names the program may call.

For a tool, these are the tools its definition declares in `tools`; naming any other fails when the action is saved. The native `graphql` and `cel` tools are called with the globals of those names, without declaring them. A nested call runs under the program's principal, its operations narrowed by the called tool's own policies too, and needs no separate `INVOKE` grant. See [Nested calls](https://www.twisp.com/docs/reference/actions/tools.md#nested-calls).

A tool call crosses a JSON boundary in both directions, as a client's call does: input is converted to JSON before the called tool's input schema validates it, and its output arrives as plain JSON values, without domain types.

### `fail`

`fail(message, hint=None)` stops the program and fails the invocation with code `action_failure`, the `message`, and the `hint`, which tells the caller what to change. Both are strings. Every write the invocation made rolls back.

```python
if amount > limit:
    fail("amount exceeds the limit of %s" % limit, hint="Split the payment, or ask a supervisor to raise the limit.")
```

For a call, the failed execution's `invocation.error` carries both. An agent's model, or an external agent through an MCP server, reads the hint as the way to correct its call.

Any other error in the program itself, such as a type error, a missing dict key, or a division by zero, also fails with code `action_failure`. Its message is the traceback of the program's own source, which says where it failed:

```
Traceback (most recent call last):
  program.star:9:22: in run
  program.star:3:15: in ratio
Error: floored division by zero
```

### CEL functions

Every function of the [CEL library](https://www.twisp.com/docs/reference/cel/functions.md) is a global of the same name, so a program computes exactly with decimals and money, makes UUIDs, and handles dates and times without a `cel` call. See [CEL Function Examples](https://www.twisp.com/docs/reference/cel/examples.md) for each function.

- **Dotted names are attributes.** `decimal.Round` is the attribute `Round` of the namespace `decimal`, and `decimal` itself is also the constructor: `decimal("10.50")`.
- **Member functions are methods.** A function CEL calls on a value is a method of that value: `m.currency()`, `m.decimal()`, `t.getFullYear()`. `dir(value)` lists a value's methods.
- **Positional arguments only.** `decimal.Round(x, "half_even", 2)`. A keyword argument fails with `CEL functions take no keyword arguments`.
- **Integers convert, floats do not.** A Starlark int passes where a function takes a CEL int, uint, double, or decimal. A float passes only where a function takes a double, and never becomes a decimal: `decimal(0.1)` fails with `a float never becomes a decimal: pass a string, as in decimal("0.1")`.
- **Pure functions are not recorded.** They run inside the program, as Starlark arithmetic does, and cost execution steps.
- **Clock and randomness are recorded.** `time.Now`, `date.Today`, `uuid.New`, and the `rand` functions are each a recorded `cel` call: they appear among the invocation's calls and count toward its `cel` call limit.

## Values

A value crosses a boundary when it enters the program, when the program passes it to `graphql` or `tools`, and when it leaves as output. This table is the whole mapping:

| Value                  | In the program                             | In `graphql` variables and tool input           | In the output                                         |
| ---------------------- | ------------------------------------------ | ----------------------------------------------- | ----------------------------------------------------- |
| Object                 | `dict` (frozen when it comes from `event`) | Object                                          | Object. Keys must be strings.                         |
| Array                  | `list`; a `tuple` also converts            | Array                                           | Array                                                 |
| String, boolean        | `str`, `bool`                              | String, boolean                                 | String, boolean                                       |
| Integer                | `int`, exact                               | Number                                          | Integer, within the signed or unsigned 64-bit range   |
| Number with a fraction | `float`                                    | Number                                          | Number. NaN and infinities are rejected.              |
| Null                   | `None`                                     | `null`                                          | `null` inside a container; no output at the top level |
| Bytes                  | `bytes`                                    | String, base64                                  | Bytes, shown as a base64 string                       |
| Decimal                | `decimal`                                  | String, `"10.50"`                               | Decimal, shown as `"10.50"`                           |
| Money                  | `money`                                    | Object, `{"units": "10.50", "currency": "USD"}` | Money, shown as `"10.50 USD"`                         |
| UUID                   | `uuid`                                     | String                                          | UUID, shown as a string                               |
| Date                   | `date`                                     | String, `"2026-10-01"`                          | Date, shown as `"2026-10-01"`                         |
| Timestamp              | `timestamp`                                | String, RFC 3339 in UTC                         | Timestamp, shown as `"2026-10-01T12:00:00Z"`          |
| Duration               | `duration`                                 | String, `"1m30s"`                               | Duration, shown as `"1m30s"`                          |

- **Output is validated as JSON.** A tool's `outputSchema` sees each value as shown: money as the string `"10.50 USD"`, a decimal as `"10.50"`. To return an amount without its currency, return `str(m.decimal())`.
- **GraphQL responses are plain JSON.** A `Decimal` field arrives as a string, such as `"86.40"`, and a `Money` object as `{"units": "86.40", "currency": "USD"}`; rebuild typed values with `decimal(...)` and `money(units, currency)`. JSON numbers with a fraction become floats, and integers in a response beyond 2^53 lose precision: send and read large integers as strings.
- **Tool output is plain JSON.** A nested tool's output arrives without domain types, as its input did: a decimal as a string, money as `{"units", "currency"}`. Integers stay exact.
- **`cel` keeps types.** Values passed to `cel` and returned from it keep their domain types. A Starlark int passes to `cel` as a CEL int, or as a uint beyond the signed 64-bit range. See [`cel`](https://www.twisp.com/docs/reference/actions/starlark.md#cel).
- **Size.** Converting a value visits at most 100,000 values, at most 64 levels deep; a value with a cycle is rejected. An output is also bounded by the [output size limit](https://www.twisp.com/docs/reference/actions/limits-and-errors.md#limits).
- **Unsupported values.** A function, a call result struct, or a namespace cannot be output. The invocation fails with `invalid Starlark value: unsupported output type function`, or the like.

## Domain values

Decimals, money, UUIDs, dates, timestamps, and durations are immutable values with CEL's semantics.

| Operation            | Works on                                                                                                                                                                                                                                                                         |
| -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `+`, `-`             | Decimal with decimal or int; money with money of the same currency; timestamp with duration; timestamp minus timestamp, giving a duration; duration with duration; date plus or minus a duration of whole days, giving a date; date minus date, giving a duration of whole days. |
| `*`                  | Decimal with decimal or int; money with decimal or int. Exact: no rounding.                                                                                                                                                                                                      |
| Unary `-`            | Decimal, money, duration.                                                                                                                                                                                                                                                        |
| `/`, `%`             | Not defined for decimals and money, because division rounds. Use `decimal.Quo` and `money.Div`.                                                                                                                                                                                  |
| `==`, `!=`           | Any two values. Values of different types are never equal: `decimal("1") == 1` is `False`, and so is `uuid(s) == s`.                                                                                                                                                             |
| `<`, `<=`, `>`, `>=` | Two values of the same type, and money of the same currency. `decimal("2") > 1` fails; write `decimal("2") > decimal("1")`.                                                                                                                                                      |
| Truth                | A zero decimal, zero money, and a zero duration are false; every other value is true.                                                                                                                                                                                            |
| `hash`, dict keys    | Equal values hash alike: `decimal("1.0")` and `decimal("1.00")` are one key.                                                                                                                                                                                                     |
| `str(x)`             | The value's output form: `"10.50"`, `"10.50 USD"`, `"2026-10-01"`.                                                                                                                                                                                                               |

- **Money in minor units.** As in CEL, an integer amount counts minor units: `money(150, "USD")` is 1.50 USD. Pass a string or a decimal for major units: `money("150", "USD")`, `money(decimal("150"), "USD")`.
- **Mixed currencies fail.** `money("1", "USD") + money("1", "EUR")` fails with `mismatched currency codes`. Convert explicitly.
- **Division.** `decimal.Quo(x, y, precision)` divides to `precision` significant digits: `decimal.Quo(decimal("100"), decimal("3"), 4)` is `33.33`. Then round to places with `decimal.Round(x, mode, places)`. `money.Div(m, "3")` and `money.Mul(m, "0.015")` take the factor as a string.
- **Rounding modes.** `decimal.Round` takes `half_even`, `half_up`, `half_down`, `up`, `down`, `floor`, or `ceiling`. Any other fails.
- **No float arithmetic.** `decimal("1") * 0.5` fails with `decimal * float is not defined`. Write `decimal("0.5")`.
- **Dates.** A date is a calendar day: dates compare, are equal, and hash by their day, whatever timestamp or zone made them. A date steps by whole days: `date("2026-10-01") + duration("24h")` is `2026-10-02`, and `date("2026-10-03") - date("2026-10-01")` is `duration("48h")`. A duration that is not whole days fails with `duration must be whole days`. Put the date first: `duration("24h") + date("2026-10-01")` is not defined.

```python
balance = money("1250.00", "USD")
rate = decimal("0.0015")
fee = money(decimal.Round(balance.decimal() * rate, "half_even", 2), "USD")  # 1.88 USD
share = decimal.Round(decimal.Quo(fee.decimal(), decimal("3"), 34), "down", 2)  # 0.62
```

## Handling failed calls

By default, a `graphql`, `cel`, or `tools` call that fails fails the program: the invocation fails with the call's error, and every write rolls back. The error's `details.cause` names the call that failed: its `tool`, `callSequence`, `callPath`, and its error's `code`, `message`, and `hint`.

Pass `check=False` to handle a failure instead. The call then returns a struct, `type(result) == "struct"`, whether it succeeded or not:

| Field    | On success         | On failure                                                             |
| -------- | ------------------ | ---------------------------------------------------------------------- |
| `ok`     | `True`             | `False`                                                                |
| `output` | The call's result. | `None`                                                                 |
| `error`  | `None`             | A struct with `code`, `message`, and `hint` (`None` if there is none). |

The failed call's writes, and those of every call beneath it, are already rolled back when it returns, so the program can recover and continue. Its later writes commit normally.

```python
def run(event):
    result = tools.notify_human_ops(event["data"], check=False)
    if not result.ok and result.error.code != "duplicate":
        fail(result.error.message, hint=result.error.hint)
    return {"notified": result.ok}
```

Some failures cannot be handled, and fail the program whatever `check` says:

- a write conflict, a passed deadline, or an interrupted transaction, which fail the whole request or invocation;
- a call past a [limit](https://www.twisp.com/docs/reference/actions/starlark.md#limits), such as a 65th `cel` call;
- an error in the program itself, such as a type error, `fail()`, or a frozen value, since Starlark has no `try`.

Passing any `check` argument other than a literal `True` also defers a GraphQL document's validation from save time to the call. See [Validation](https://www.twisp.com/docs/reference/actions/starlark.md#validation).

## Transactions

A program's calls run in one transaction, each in its own nested unit within it. A later call reads the writes of every earlier call that succeeded. When the program succeeds, its writes commit together with its outcome; when it fails, for any reason, none of them remain, and the calls it made stay recorded.

| How it runs                                                                                      | Transaction                                                         | Principal                                                       | When it fails                                                                                                                                                                                                                                        |
| ------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------- | --------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Called with [`invokeAction`](https://www.twisp.com/docs/reference/actions/tools.md#calling-a-tool), by an agent, or by a tool | The caller's. Writes commit or roll back with the caller's request. | The caller.                                                     | The call returns `FAILED` with its error, and the caller continues. No retry.                                                                                                                                                                        |
| Started by a trigger or with [`startAction`](https://www.twisp.com/docs/reference/actions/tools.md#starting-a-tool)           | The invocation's own.                                               | The action's own client for a trigger; the starter for a start. | `fail()`, an unhandled call error, and any other program error fail the execution without a retry. A write conflict or an interrupted transaction is retried under the action's [execution settings](https://www.twisp.com/docs/reference/actions/actions.md#execution-settings). |

A retry runs the program again from the start, in a fresh transaction, with the same event. Anything the program creates must therefore have an identifier it derives from its input or event, so a retry or a manual rerun finds the record instead of creating another. A deterministic UUID serves: `uuid.NewSHA1(uuid(event["data"]["account_id"]), b"fee:2026-09")`. Checking first does not serialize concurrent executions; a unique identifier and the write conflict it causes do.

Index listings are eventually consistent, so a listing such as `accounts` by `NAME` does not include records the program wrote earlier in its transaction. Read them by ID or unique key. See [Consistency of Reads](https://www.twisp.com/docs/reference/api/indexes.md#consistency-of-reads).

## Recorded calls

Each `graphql`, `cel`, and `tools` call, and each call of a clock or randomness function, is a child execution of the program's execution, with its input, output or error, and `callSequence`, its position among the invocation's calls. Read them through the execution's `children` and the [call tree](https://www.twisp.com/docs/reference/actions/executions.md#lineage). A call's identity derives from the calling execution, its invocation, and the call's sequence, so the same program with the same input makes the same calls.

Pure CEL functions and Starlark's own computation are not recorded. A program is deterministic except for what it reads: the ledger through `graphql`, other tools' outputs, and the recorded clock and randomness calls. Dicts iterate in insertion order, and a map returned by CEL or GraphQL iterates in key order.

## Validation

When an action is saved, or validated with `validateAction`, its program is compiled and checked. A program that fails is rejected with its position, as `program.star:<line>:<column>: <problem>`:

- syntax errors, undefined names, and the dialect's restrictions, such as `while` or `load`;
- a missing `run`, or a `run` that does not take exactly one parameter;
- each GraphQL document the program passes to `graphql()` as a literal or a constant, directly or through its own functions, against the schema, with its `operation_name` and the variables its operation requires, and whether it starts an action;
- each `tools.<name>` and `tools["<name>"]` against the declared tools.

A document the program builds or chooses at run time, such as one built with `%` or `.format()`, one alternative of a conditional, one element of a loop, or one passed to a call with a `check` argument other than `True`, is validated when the call runs. Recursion, limits, and type errors are found only when the program runs.

## Limits

Each invocation of a program allows:

| Limit                                                 | Value                                                                                                                            |
| ----------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
| Source size                                           | 64 KiB, and within the definition's [configuration size](https://www.twisp.com/docs/reference/actions/limits-and-errors.md#limits)                            |
| Execution steps                                       | 1,000,000. CEL functions count their evaluation cost as steps.                                                                   |
| `graphql` calls                                       | 64                                                                                                                               |
| `cel` calls, including clock and randomness functions | 64, each expression at most 16 KiB, with a total CEL cost of 100,000                                                             |
| `tools` calls                                         | 64                                                                                                                               |
| Value conversion                                      | 100,000 values, 64 levels deep                                                                                                   |
| Nesting                                               | 8 tool levels below the first call, and 1,024 calls beneath one call. See [Nested calls](https://www.twisp.com/docs/reference/actions/tools.md#nested-calls). |
| Time                                                  | The tool's `responseTimeout`, and the caller's deadline.                                                                         |

A program past a step or call limit fails with code `action_failure` and a message that ends with the limit, such as `Starlark computation cancelled: too many steps` or `script exceeds 64 CEL calls`, after the traceback of where the program was.

## MCP programs

The native [`program`](https://www.twisp.com/docs/reference/actions/mcp.md#programs) tool runs a program an external agent sends to an MCP server. It is the same language, with the same entry point, values, operators, and step limit, and these differences:

| Aspect           | A tool                                                 | An MCP program                                                                                                                                                                                                                            |
| ---------------- | ------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Source           | Saved in the definition, and checked when saved.       | Sent as the `src` argument of each call, and checked before it runs: a program that fails the checks is refused with `action_input_invalid` and makes no call.                                                                            |
| `event`          | As in [The event](https://www.twisp.com/docs/reference/actions/starlark.md#the-event).                         | `event["data"]` is the call's `input` argument, or `{}`. `event["id"]` is the program's execution ID, and the record fields are `None`.                                                                                                   |
| `tools`          | The tools the definition declares.                     | The tools the server lists, by the names the external agent sees, including `graphql` and `cel` when the server lists them. Not `program`, `invoke`, `get_task`, `cancel_task`, or a callable agent.                                      |
| `graphql`, `cel` | Always available.                                      | Available only when the server lists the native tool; otherwise the call fails with `tool_not_declared`. Clock and randomness functions, such as `uuid.New()`, are `cel` calls and need `cel` listed too. Pure CEL functions always work. |
| Commit           | Writes commit with the call.                           | `commit: true` commits every write together; `commit: false` runs every call and commits nothing, and lists the rows it would write.                                                                                                      |
| Result           | The return value, validated against `outputSchema`.    | `{value}`, with `writes`, `unreadable_writes`, and `omitted_writes` for a preview. There is no output schema.                                                                                                                             |
| Errors           | `invocation.error` with `code`, `message`, and `hint`; a script error's message is its traceback. | A tool error whose message starts with the traceback of the program's own source, such as `program.star:4:18: in run`.                                                                                                                    |
| Authority        | The caller, narrowed by the tool's policies.           | The principal, narrowed by the server's policies and each called tool's.                                                                                                                                                                  |
| Time             | The tool's `responseTimeout`.                          | The server's `responseTimeout`. A program that passes it fails the request with `mcp_request_deadline`, and nothing it did is recorded.                                                                                                   |

See [Programs](https://www.twisp.com/docs/reference/actions/mcp.md#programs) for the arguments, previews, annotations, and rate limits.

## Programmatic tool calling

An agent's `programmatic_tool_caller` tool also runs a short program over the agent's tools, but it is not Starlark: the model writes the program in a subset of Python and calls each tool with one keyword argument per input property. Its calls commit together as a Starlark program's do. See [Agent tools](https://www.twisp.com/docs/reference/actions/agents.md#agent-tools).
