Building Transactional Actions
Build an action with GraphQL calls, conditional execution, atomic writes, and safe reruns. Observe rollback, choose retry boundaries deliberately, and call the action as a tool.
An action can run a sequence of GraphQL operations in response to a committed change. In this tutorial, an account update requests a dedicated journal. The action checks whether that journal exists, creates it if needed, and reads it back before committing. You will deliberately fail that sequence to observe rollback, then complete it and rerun it without creating another journal.
This is a provisioning example, not a funds movement: creating a journal does not post entries or move balances.
Start with Running Actions Locally. Run each operation separately in GraphiQL. Use the local Twisp tenant header X-Twisp-Account-Id: 000000000000. In another environment, use that tenant's header and credentials. Actions always execute in the tenant where they are defined.
Use fresh UUIDs and a fresh account code if you repeat the tutorial in the same database. The two UUIDs below identify the ledger account and the action. We also use the ledger account's UUID as its dedicated journal's UUID: they are different entity types, and this gives every delivery a stable destination ID.
1. Create the source account
mutation CreateProvisioningAccount {
createAccount(
input: {
accountId: "70eec1a6-eac1-47aa-a507-e18f2d486425"
code: "ADVANCED-ACTIONS"
name: "Advanced actions account"
description: "demo:idle"
normalBalanceType: DEBIT
}
) {
accountId
code
}
}
The account already exists when you publish the action. Its creation will not be replayed. We will trigger the action with an update after publication.
2. Create a Starlark action
The trigger requires an update to this account, with a description of demo:rollback or demo:provision. The description is a convenient command marker for this exercise. A production application could use a dedicated record or metadata field instead.
The trigger reads record fields, so it uses account_id and a typed uuid(...) literal. GraphQL queries use schema fields such as accountId and journalId. Keep these two naming conventions separate.
mutation CreateProvisioningAction {
actions {
createAction(
id: "1ac86b64-1d65-4232-8556-e8063f849b04"
input: {
name: "provision_account_journal"
description: "Provision one journal per account"
kind: "tool"
tool: {
name: "provision_account_journal"
description: "Create the dedicated journal for an account if it does not exist."
inputSchema: { type: "object" }
outputSchema: { type: "object" }
}
triggers: [
{
event: """
event.type == "account.updated" &&
event.data.account_id == uuid("70eec1a6-eac1-47aa-a507-e18f2d486425") &&
event.data.description in ["demo:rollback", "demo:provision"]
"""
}
]
policies: [{ effect: ALLOW, actions: [SELECT, INSERT], resources: ["financial.Journal.*"] }]
execution: { timeout: "5m", responseTimeout: "30s", maxInvocations: 1 }
config: {
runtime: "starlark"
src: """
# Transactional provisioning for the advanced actions tutorial.
# Journal identity is stable across action retries and manual reruns.
def run(event):
account = event["data"]
variables = {"id": account["account_id"]}
found = graphql(
"query Find($id: UUID!) { journal(id: $id) { journalId code name } }",
variables,
operation_name="Find",
)
if found["journal"] != None:
return {"outcome": "already-provisioned", "journal": found["journal"]}
graphql(
"mutation Create($id: UUID!, $code: String!) { createJournal(input: {journalId: $id, code: $code, name: \"Action-provisioned journal\"}) { journalId } }",
{"id": account["account_id"], "code": account["code"]},
operation_name="Create",
)
verified = graphql(
"query Read($id: UUID!) { journal(id: $id) { journalId code name } }",
variables,
operation_name="Read",
)
if account["description"] == "demo:rollback":
fail("Deliberately fail after creating and reading the journal")
return {"outcome": "provisioned", "journal": verified["journal"]}
"""
}
}
) {
actionId
principal
version
status
pending
}
}
}
Actions that run programs have kind tool: the tool block describes the interface, and the config holds the runtime and its program in src. The program defines run(event) and calls graphql() with a query, variables, and an optional operation name. The returned value becomes invocation.output.
3. Wait for publication
query ProvisioningAction {
actions {
action(id: "1ac86b64-1d65-4232-8556-e8063f849b04") {
version
status
pending
published
principal
}
}
}
Wait for status: ACTIVE and pending: null. Publication is asynchronous and advances the record version. A successful create response alone does not mean the action is matching events yet.
4. Observe an atomic rollback
mutation RequestRollback {
updateAccount(
id: "70eec1a6-eac1-47aa-a507-e18f2d486425"
input: { description: "demo:rollback" }
) {
accountId
description
version
}
}
The action finds no journal, creates one, reads its uncommitted write, then calls fail(). Poll the following query until the execution is terminal:
query InspectProvisioning {
journal(id: "70eec1a6-eac1-47aa-a507-e18f2d486425") {
journalId
code
name
}
actions {
executions(where: { actionId: "1ac86b64-1d65-4232-8556-e8063f849b04" }, sort: DESC, first: 10) {
nodes {
executionId
actionVersion
status
overdue
deadline
nextInvocationAt
event {
eventId
eventType
recordVersion
data
previous
}
invocation {
number
responseDeadline
output
error {
code
message
retryable
}
}
}
pageInfo {
endCursor
hasNextPage
}
}
}
}
Expect the journal to remain null, the execution to end FAILED, and the invocation to report action_failure with no output. fail() reports an error that retrying the same event cannot fix, so the execution is not retried.
The account update remains committed: it happened before the action started. Inside the action, the journal write and successful action outcome would have committed together. Instead, the failed transaction rolls back its business writes and the failure is recorded in a separate transaction. The failure remains inspectable even though the journal never became visible.
5. Provision successfully
mutation RequestProvisioning {
updateAccount(
id: "70eec1a6-eac1-47aa-a507-e18f2d486425"
input: { description: "demo:provision" }
) {
accountId
description
version
}
}
Run InspectProvisioning again. This update creates a new event and execution. The new execution reaches SUCCEEDED, the journal exists, and invocation.output contains:
{
"outcome": "provisioned",
"journal": {
"journalId": "70eec1a6-eac1-47aa-a507-e18f2d486425",
"code": "ADVANCED-ACTIONS",
"name": "Action-provisioned journal"
}
}
The second query sees the journal before the action commits. That is useful when later calls must validate or use earlier writes in the same invocation.
6. Rerun without duplicating the effect
Copy the successful execution ID into the Variables panel:
{ "executionId": "REPLACE_WITH_SUCCESSFUL_EXECUTION_UUID" }
mutation RerunProvisioning($executionId: UUID!) {
actions {
rerunExecution(
id: $executionId
input: { executionId: "d3d616a1-17e1-47fd-91d9-1a66ceefecba" }
) {
executionId
status
rerunOf {
executionId
}
}
}
}
The rerun gets a new execution ID but receives the same event. It reads the existing journal and finishes with outcome: "already-provisioned". It does not attempt another insert. Repeating this exact mutation returns the same rerun; choose another input.executionId to request a separate one.
The stable journal ID and existence check make this operation safe to repeat after completion. They do not serialize concurrent executions: two invocations may both initially see no journal. Uniqueness constraints and transaction conflict handling remain necessary, and a retry must start with the lookup again. In production, also check that an existing journal has the expected ownership and configuration rather than assuming any record at that ID is acceptable.
A rerun pins the source execution's action version by default. Updating the action does not silently upgrade old executions or reruns. To test a corrected definition against an old event, publish it, read its exact version, and pass that integer as input.actionVersion alongside a fresh rerun ID. Changing the source account later does not change the event already pinned on an execution. Action authorization still uses the action client's current policies.
7. Choose the transaction and retry boundaries
Starlark actions always execute GraphQL calls in one transaction. Later calls can read earlier writes. A script or GraphQL error rolls back all database writes from the invocation; partial GraphQL response data is not returned. Retrying starts the program in a fresh transaction. There is no config option to change this behavior.
Action retry settings apply to the whole program. Retryable failures restart it with the pinned event and action version. responseTimeout bounds the synchronous invocation; timeout also includes queue time and retry delays. A Starlark action has no durable waits or external completion ID.
Do not put @tx, @dryRun, or @retry inside transactional graphql() calls: the action owns the transaction boundary. Atomicity covers writes in that database transaction. External effects and independently committed operations are outside that boundary.
8. Inspect failures and stop future work
Read the execution's retained history to distinguish record versions from invocation numbers:
query ProvisioningHistory($executionId: UUID!) {
actions {
execution(id: $executionId) {
status
actionVersion
history(first: 20) {
nodes {
version
status
invocation {
number
startedAt
error {
code
message
retryable
}
}
}
pageInfo {
endCursor
hasNextPage
}
}
}
}
}
History is newest first. invocation.number counts invocations; version tracks changes to the execution record. overdue: true is an observation that a deadline has passed, not proof that the worker stopped at that instant.
To stop an execution, call actions.cancelExecution(id: ...). A QUEUED or RETRY_SCHEDULED execution is CANCELLED at once. A RUNNING one may be returned still RUNNING with its cancellation requested: its invocation's writes roll back, and it becomes CANCELLED when the invocation ends. Read it again to see the result.
Disable this tutorial's action when you finish:
mutation DisableProvisioning {
actions {
updateAction(id: "1ac86b64-1d65-4232-8556-e8063f849b04", input: { enabled: false }) {
status
pending
}
}
}
Wait for DISABLED with pending: null. Disabling stops future matching after publication; it does not cancel existing executions or undo the provisioned journal.
Use CEL for typed calculations
Events retain their typed UUIDs, integers, money, decimals, and other domain values inside Starlark. cel(expression, bindings) exposes the shared CEL registry for domain arithmetic and comparisons:
def run(event):
amount = cel('money("12.50", "USD")')
total = cel('amount + money("2.50", "USD")', {"amount": amount})
return {"total": total, "account_id": event["data"]["account_id"]}
Pass bindings explicitly. Typed values returned by CEL can be passed to another CEL call or returned as action output without converting through JSON.
GraphQL remains a JSON boundary. Domain variables use their wire representation, and responses do not recover the original record's protobuf kinds. Keep exact financial calculations in CEL or ledger operations and check conversions at that boundary.
Call the action as a tool
A tool block makes a Starlark action callable. The caller supplies input, validated against the tool's input schema, and receives the output synchronously. The call runs inside the caller's transaction and as the caller, so its writes commit or roll back with the caller's other operations. Give the tool a registry name so agents can reference it.
mutation CreateProvisioningTool {
actions {
createAction(
id: "8a51f0d3-2f63-4a7b-9a56-44f3f1a6d0c2"
input: {
name: "provision_journal"
description: "Provision one journal for an account"
kind: "tool"
tool: {
name: "provision_journal"
description: "Create the dedicated journal for an account if it does not exist."
inputSchema: {
type: "object"
properties: { account_id: { type: "string", format: "uuid" }, code: { type: "string" } }
required: ["account_id", "code"]
additionalProperties: false
}
outputSchema: { type: "object" }
}
config: {
runtime: "starlark"
src: """
def run(event):
account = event["data"]
found = graphql(
"query Find($id: UUID!) { journal(id: $id) { journalId code } }",
{"id": account["account_id"]},
)
if found["journal"] != None:
return {"outcome": "already-provisioned"}
graphql(
"mutation Create($id: UUID!, $code: String!) { createJournal(input: {journalId: $id, code: $code, name: $code}) { journalId } }",
{"id": account["account_id"], "code": account["code"]},
)
return {"outcome": "provisioned"}
"""
}
}
) {
actionId
version
status
pending
}
}
}
The action has no triggers, so it never runs for events. Wait for ACTIVE, then call version 1 with a fresh executionId:
mutation CallProvisioningTool {
actions {
invokeAction(
id: "8a51f0d3-2f63-4a7b-9a56-44f3f1a6d0c2"
input: {
executionId: "4c0f1d5e-4b4b-4f53-9c1e-1f6f9d2b7a31"
actionVersion: 1
input: { account_id: "b8e1f7a2-6d3c-4b8e-9f1a-2c3d4e5f6a7b", code: "TOOL-JOURNAL" }
}
) {
executionId
status
event {
eventType
data
}
invocation {
output
}
}
}
}
The response is the tool's SUCCEEDED execution with eventType: "action.invoked" and the output. Repeating the same mutation returns that execution without running the tool again; changing the input under the same executionId is an error. Input that fails the schema fails before the program runs. A call that fails returns a FAILED execution whose invocation.error has a code, a message, and a hint saying what to change; none of the call's writes remain, and the request's other operations can still commit. A write conflict or an interrupted transaction instead fails the whole request with a retryable error.