Writing Starlark

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

A tool 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 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.

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:

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:

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:
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 is a Starlark global, so a program does money math exactly without a separate call. Amounts are decimal and money values, never floats.

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:

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

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

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

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

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.

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:

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:

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:

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

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:

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.

Common mistakes

MistakeWhat happensInstead
Changing event["data"] or a dict from itcannot insert into frozen hash tableCopy it: d = dict(event["data"]).
decimal(0.1), amount * 0.5a float never becomes a decimalUse strings: decimal("0.1"), amount * decimal("0.5").
amount / 3 on a decimal or moneydecimal / int is not defineddecimal.Quo(amount, decimal("3"), 34), then decimal.Round.
decimal("2") > 1, uuid(s) == sThe comparison fails; the equality is silently False.Convert both sides to the same type.
money(150, "USD") meaning 150 dollarsIt is 1.50 USD: an integer counts minor units.money("150", "USD").
A large integer through GraphQLIt comes back rounded.Send and read it as a string.
print(x) to debugNothing: print output is discarded.Return the value, or fail("x: %s" % x) while you test.
while, recursion, set, loadSaving fails, or recursion fails when it runs.for over a range; a dict's keys; one file.
A top-level graphql() callgraphql may only be called from run(event)Call it inside run or a helper.
Keyword arguments to a CEL functionCEL functions take no keyword argumentsPositional arguments.
uuid.New() for a record's IDA retry creates a second record.uuid.NewSHA1(...) from the input or event.
Returning money where the schema says numberaction_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 schemaaction_output_invalid, unless the schema allows null.Return a dict the schema accepts.

Testing and debugging

Test a program in the local environment 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:

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.

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
        }
      }
    }
  }
}
{
  "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 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:

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, or from your MCP host, and call invoke. These are the tools/call arguments that preview the stored tool:

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

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

{
  "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."
  }
}
{
  "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 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.

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.

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. A tool's own execution cannot be replayed with replayExecution; replays are for agent turns and MCP requests.

Further reading

Previous
Tools