Tools

Typed, versioned programs that clients, agents, and other tools call inside their own transaction or start to run on their own.

A tool is an action that can be called. Its tool block gives it a name, a description, and an input schema; its handler does the work. Clients call tools through the API, agents call the tools they declare, MCP servers offer tools to agents outside Twisp, and tools call other tools. Every call is an execution with its input, output, and caller.

Most tools you write have kind tool: a short Starlark program that reads and writes the ledger through GraphQL. A built-in model and a callable agent are tools too, with handlers of their own.

Schemas

A tool's inputSchema is a JSON Schema for the call's input, and its optional outputSchema describes what it returns. Input is validated before the handler runs, and output before the call succeeds, so a caller can rely on both. The name and description are what an agent's model sees, so write the description as when and why to call the tool.

Prefer narrow tools with specific inputs, such as lock_account(account_id, reason), to tools that accept anything. The schema is part of the contract: it decides what a caller, including a model, can ask for. See Tools for every field.

The Starlark runtime

A tool of kind tool holds a program in config.src, with runtime: "starlark". The program defines run(event); for a call, event["data"] is the input, and the return value is the output.

The program has a small, fixed set of capabilities:

  • graphql(query, variables) runs a GraphQL operation and returns its data. Later calls read earlier writes.
  • cel(expression, bindings) evaluates a CEL expression.
  • tools.<name>(input) calls a tool the action declares.
  • fail(message, hint=None) fails the call with a message and a hint for the caller.

Every CEL function is also available directly in Starlark, so a program computes exactly with money and decimals: money("100", "USD") * 2, decimal.Round(x, "half_even", 2). The program has no filesystem, network, or process access, and its steps and calls are bounded. Writing Starlark shows how to write, test, and debug a program, and the Starlark reference has the runtime's rules and limits.

Calling and starting

There are two ways to run a tool, and they differ in which transaction the work belongs to.

Call: invokeActionStart: startAction
When it runsNow, synchronously, inside the request transaction.Later, on its own, after the request commits.
What you get backThe finished execution, with its output or error.The execution, QUEUED.
Its writesCommit or roll back with the rest of your request.Commit with the execution's own outcome, invocation by invocation.
RetriesNone. Repeat the call with the same executionId to read the result.The action's execution settings.
VersionactionVersion is required.actionVersion is optional; omitted, it runs the current version.
AuthorityYours, narrowed by the tool's policies.Yours, narrowed by the tool's policies, checked again at each invocation.

Call a tool when its result is part of your request: you want the output now, or its writes must commit together with your own. A call that fails returns a FAILED execution with a code, a message, and a hint; none of its writes remain, and the rest of your request can still commit. See Calling a tool.

Start a tool, or a callable agent, when the work should not hold your request open, such as an agent's whole run. The start commits with your request, so a request that rolls back started nothing, and the execution then runs with retries as a triggered execution does. It runs under your authority, never the action's own client. A tool with a session cannot be started, and an execution cannot start one; call it instead. See Starting a tool.

Both take an executionId you choose. Repeating a request with the same identifier and input returns the execution it made instead of running the tool again, so a client can retry safely.

Nested calls

A tool declares the tools it calls in its tools list and calls them with tools.<name>(input). Each call, including each graphql and cel call, is a child execution of the calling tool, so the call tree is recorded exactly as it ran.

A call and everything beneath it is one atomic unit. When a nested call fails, its writes and those of every call beneath it roll back, and by default the calling tool fails too. Pass check=False to handle the failure instead: the call returns a result with ok, output, and error, the failed call's writes are already gone, and the program decides what to do next. A write conflict or a passed deadline cannot be handled this way; it fails the whole request.

Nesting is limited to 8 tool levels below the first call and 1,024 calls beneath one call. See Nested calls.

Authority

A tool never grants its caller anything. Each operation a tool performs must be allowed by the caller and by the policies of every tool on the call stack, so each layer can only narrow what the layers above it allow. A tool with no policies can perform no operation: list the access its program needs. See Policy chain and Authority and Audit.

Native tools

Every tenant has a set of native tools without creating them; Native Tools covers them all. Two are useful everywhere:

  • graphql runs a GraphQL operation. Its own policies allow everything, so a graphql call can do exactly what the layers above it allow.
  • cel evaluates a CEL expression and performs no operation.

A tool program can call both without declaring them. An agent calls them only when it lists them in tools.

To give an agent GraphQL access narrower than its own policies, create your own action of kind graphql with your own name, description, and policies, such as a query tool that allows only SELECT. It takes the native tool's schemas, and its policies narrow what the native tool's allow. See Native tools.

mutation CreateQueryTool($id: UUID!) {
  actions {
    createAction(
      id: $id
      input: {
        name: "query"
        kind: "graphql"
        config: { version: 1 }
        tool: { name: "query", description: "Read ledger records with a GraphQL query." }
        policies: [{ effect: ALLOW, actions: [SELECT], resources: ["financial.*"] }]
      }
    ) {
      actionId
      pending
    }
  }
}

Tool references

An action's tools list names each tool it may call, in order. A reference can do two more things:

  • Pin a version. A reference with a version always calls exactly that version: its schemas, program, references, and policies. Updating the tool never changes what the reference calls. A reference without a version calls the tool's latest version, resolved when each execution starts, and every call in that execution uses the version it resolved. Either way, each invocation records the version it used.
  • Fix part of the input. input maps top-level input properties to CEL expressions, evaluated before the tool runs. Fixed values replace whatever the caller supplies, so an agent's model cannot change them. An expression can read the triggering event, the calling tool's tool.input, the execution's execution.input, and, with a session, session.id and session.input.
tools: [
  {
    name: "get_account_activity"
    version: 4
    input: { account_id: "string(session.input.account_id)" }
  }
]

Here a session agent always calls version 4, and only ever for the account its session was started for. Pin versions where a change of behavior must be a deliberate edit of the caller; leave them unpinned where callers should pick up fixes automatically. A reference can also require a person's approval before each call, for an agent with a session. See Tool references and Versions.

Example

A lock_account tool locks an account and opens a case for operations. Locking is the point of the call, so a failure there fails it. Opening the case is secondary, so the program handles its failure with check=False.

mutation CreateLockAccount($id: UUID!, $src: String!) {
  actions {
    createAction(
      id: $id
      input: {
        name: "lock_account"
        kind: "tool"
        config: { runtime: "starlark", src: $src }
        tool: {
          name: "lock_account"
          description: "Lock an account so it blocks posting, and open a case for operations."
          inputSchema: {
            type: "object"
            properties: {
              account_id: { type: "string", format: "uuid" }
              reason: { type: "string" }
            }
            required: ["account_id", "reason"]
            additionalProperties: false
          }
        }
        tools: [{ name: "open_case", version: 2 }]
        policies: [
          { effect: ALLOW, actions: [SELECT, INSERT, UPDATE], resources: ["financial.Account.*"] }
        ]
      }
    ) {
      actionId
      version
      pending
    }
  }
}
def run(event):
    data = event["data"]
    graphql(
        "mutation Lock($id: UUID!) { updateAccount(id: $id, input: {status: LOCKED}) { accountId } }",
        {"id": data["account_id"]},
    )
    case = tools.open_case({"account_id": data["account_id"], "note": data["reason"]}, check=False)
    return {"locked": True, "case_opened": case.ok}

Once the tool is ACTIVE, call version 1:

mutation Lock($executionId: UUID!) {
  actions {
    invokeAction(
      id: "<lock_account actionId>"
      input: {
        executionId: $executionId
        actionVersion: 1
        input: {
          account_id: "70eec1a6-eac1-47aa-a507-e18f2d486425"
          reason: "Card reported stolen"
        }
      }
    ) {
      status
      invocation {
        output
        error {
          code
          message
          hint
        }
      }
    }
  }
}

If the update is denied or fails, the call returns FAILED and the account is unchanged. If open_case fails, its writes roll back, the lock stays, and the output says case_opened: false. The call's execution lists both nested calls as children, and the lock commits only when your request does.

Next steps

Previous
Triggers and Executions