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.
  • A program an external agent sends to an MCP server's native program tool. See 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.

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), 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:

KeyFor a callFor an execution started by a trigger
idThe call's execution ID, a UUID.The committed change's identifier, a UUID.
type"action.invoked".The event type, such as "account.updated".
occurred_atThe start of the caller's transaction, a timestamp.The change's commit time, a timestamp.
dataThe call's effective input, with fixed input applied.The record after the change, with typed values.
previousNone.The record before the change, or None for an insert.
record_typeNone.The record name, such as "Account".
record_idNone.The changed record's ID.
record_versionNone.The changed record's version.
sessionFor a call to a tool with a session: 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 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, with these choices:

FeatureAvailableNotes
def, lambda, if, for, comprehensionsYesif and for only inside functions, as top-level statements are declarations.
whileNoSaving fails with this Starlark dialect does not support while loops. Use for over a range.
RecursionNoA function that calls itself, directly or through others, fails the invocation with function f called recursively. Saving does not detect it.
loadNoSaving fails with load is not supported in Starlark programs. A program is one file.
setNoSaving fails with this Starlark dialect does not support sets. Use a dict's keys.
Reassigning a top-level nameNoSaving fails with cannot reassign global. Assign each top-level name once.
IntegersYesArbitrary precision inside the program. See Values for what crosses a boundary.
FloatsYesNever use them for amounts: a float never becomes a decimal. See Domain values.
bytes, b"..." literalsYes
try/except, classes, **, sum, structNoThese are not part of Starlark. Handle a failed call with check=False.

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

Globals

NameWhat it is
graphql(query, variables=None, operation_name="", check=True)Runs a GraphQL operation and returns its data. See graphql.
cel(expression, bindings={}, check=True)Evaluates a CEL expression and returns its value. See cel.
toolsThe tools the program may call: tools.<name>(input, check=True). See tools.
fail(message, hint=None)Fails the invocation. See fail.
CEL functionsEvery function of the shared CEL library, such as money, decimal.Round, and uuid.NewSHA1. See CEL functions.
Starlark's built-insNone, 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 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.
  • 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. 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.

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.

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, 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.

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.

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

ValueIn the programIn graphql variables and tool inputIn the output
Objectdict (frozen when it comes from event)ObjectObject. Keys must be strings.
Arraylist; a tuple also convertsArrayArray
String, booleanstr, boolString, booleanString, boolean
Integerint, exactNumberInteger, within the signed or unsigned 64-bit range
Number with a fractionfloatNumberNumber. NaN and infinities are rejected.
NullNonenullnull inside a container; no output at the top level
BytesbytesString, base64Bytes, shown as a base64 string
DecimaldecimalString, "10.50"Decimal, shown as "10.50"
MoneymoneyObject, {"units": "10.50", "currency": "USD"}Money, shown as "10.50 USD"
UUIDuuidStringUUID, shown as a string
DatedateString, "2026-10-01"Date, shown as "2026-10-01"
TimestamptimestampString, RFC 3339 in UTCTimestamp, shown as "2026-10-01T12:00:00Z"
DurationdurationString, "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.
  • 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.
  • 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.

OperationWorks 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").
TruthA zero decimal, zero money, and a zero duration are false; every other value is true.
hash, dict keysEqual 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.
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:

FieldOn successOn failure
okTrueFalse
outputThe call's result.None
errorNoneA 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.

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, 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.

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 runsTransactionPrincipalWhen it fails
Called with invokeAction, by an agent, or by a toolThe 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 startActionThe 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.

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.

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. 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:

LimitValue
Source size64 KiB, and within the definition's configuration size
Execution steps1,000,000. CEL functions count their evaluation cost as steps.
graphql calls64
cel calls, including clock and randomness functions64, each expression at most 16 KiB, with a total CEL cost of 100,000
tools calls64
Value conversion100,000 values, 64 levels deep
Nesting8 tool levels below the first call, and 1,024 calls beneath one call. See Nested calls.
TimeThe 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 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:

AspectA toolAn MCP program
SourceSaved 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.
eventAs in 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.
toolsThe 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, celAlways 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.
CommitWrites 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.
ResultThe return value, validated against outputSchema.{value}, with writes, unreadable_writes, and omitted_writes for a preview. There is no output schema.
Errorsinvocation.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.
AuthorityThe caller, narrowed by the tool's policies.The principal, narrowed by the server's policies and each called tool's.
TimeThe 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 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.

Previous
Tools