Build workflows
Steps
The four step kinds — tool, llm, agent and javascript — with their options, outputs and examples.
Steps are the actions a workflow takes. Each step has:
| Field | Required | Meaning |
|---|---|---|
key |
Yes | A unique identifier like review or post_summary, used to refer to the step's output |
kind |
Yes | tool, llm, agent or javascript |
name |
No | A readable label shown in the steps diagram and runs |
depends_on |
No | Keys of steps that must succeed first (usually inferred — see Data flow) |
if |
No | A condition; the step is skipped when it isn't met |
Everything else on a step is specific to its kind. Any value can be a template such as "{{ steps.pr.output.title }}".
tool — call a tool
Calls one tool on one of your connections, such as posting to Slack, querying Postgres or commenting on a pull request, or one of the built-in tools.
- key: comment
kind: tool
tool: github.create_comment
args:
repo: "{{ trigger.repository.full_name }}"
number: "{{ trigger.pull_request.number }}"
body: "Thanks! A reviewer will take a look soon."
tool—<connection slug>.<tool name>, orbuiltin.<tool name>. The tools each connection offers, and their arguments, are listed on the connection's page.args— the tool's arguments.- Output: whatever the tool returns — usually an object, like a pull request, a list of messages, or
{ columns, rows }for a query. - A tool error (a 404 from GitHub, a SQL error, a channel not allowed) fails the step with the provider's message.
Built-in tools
These tools are there for every account with nothing to set up. Use them in a tool step, or list them in an agent's tools alongside your connections' tools — builtin.* gives an agent all of them.
| Tool | Arguments | Returns |
|---|---|---|
builtin.fetch_url |
url, and optional params for the query string |
Parsed JSON, or the page as plain text with the markup, styles and scripts stripped out |
builtin.extract |
url or html, plus selectors (a name → CSS selector mapping), links: true, or both; optional limit per selector (default 50) |
{ title, matches, links }: each match's text, with its href or src made absolute |
builtin.parse_feed |
url or content of an RSS, Atom or JSON Feed; optional since (ISO 8601) and limit (default 20) |
{ title, link, items }, each item { id, title, link, published, summary } with a plain-text summary |
builtin.json_query |
query in JMESPath, and data or a url (with params) to fetch |
Just the part of the JSON the query picks out |
builtin.check_url |
url; optional method (GET or HEAD) and contains, text the body must include |
{ ok, status, final_url, latency_ms, redirects, headers, contains }, or { ok: false, error } when it can't be reached |
builtin.run_javascript |
code defining function main(inputs), and optional inputs |
{ output, logs }, where output is whatever main returned |
builtin.now |
Optional timezone, an IANA name like America/Toronto |
{ iso8601, date, weekday, formatted, timezone } |
builtin.date_math |
Optional date (default now), timezone, start_of (day, week, month or year), add like { days: -7 }, and compare_to |
The resulting moment, as now returns it, plus difference: { seconds, hours, days } when given compare_to |
builtin.wait |
seconds, up to 300, and an optional reason for the log |
{ waited_seconds, now } |
builtin.remember |
key and value, any JSON up to 100 KB |
{ key, bytes, updated_at } |
builtin.recall |
Optional key; leave it out to list the keys |
{ key, found, value, updated_at }, or { keys } |
builtin.forget |
key |
{ key, forgotten } |
fetch_url, extract, parse_feed, json_query and check_url make GET (or HEAD) requests and send no credentials of any kind, so they reach public pages and open APIs only. Anything behind a key or a sign-in belongs on a connection — an HTTP API connection reaches any API with a key — where the credential is encrypted and never appears in a definition. Private and loopback addresses are refused.
Most of these exist so an agent reads less. extract hands back the three headlines on a page instead of the whole page, and json_query with a url fetches a large API response and passes along only the fields the query picks — the rest never reaches the model. check_url doesn't fail on a 500 or a timeout: it tells you so with ok: false, which is what you want when the question is whether a site is up.
run_javascript is the same sandbox a javascript step uses, and it exists mainly so an agent can do arithmetic or reshape a result mid-loop. For dates, date_math is more reliable than asking a model to count. When you already know the transformation, write a javascript step instead — it's cheaper and you can see it in the definition.
Memory between runs
remember, recall and forget keep values for the workflow itself, so the next run can see what this one did. It's how a workflow that runs every hour avoids telling you about the same thing twice:
- key: seen
kind: tool
tool: builtin.recall
args: { key: last_checked_at }
- key: releases
kind: tool
tool: builtin.parse_feed
depends_on: [seen]
args:
url: https://github.com/rails/rails/releases.atom
since: "{{ steps.seen.output.value }}"
- key: checked
kind: tool
tool: builtin.remember
depends_on: [releases]
args: { key: last_checked_at, value: "{{ run.started_at }}" }
Each workflow has its own memory, and it only exists inside a run, so the assistant can't read it while it's editing a workflow. A workflow can keep up to 200 keys of 100 KB each. Two runs going at once can overwrite each other, and the last one to remember a key wins.
Workspace tools
Workspace tools give a run a folder of its own, in a Docker sandbox, where it can clone a repository, change files, run your tests and push a branch — and workspace.agent hands that work to a coding agent rather than spelling it out as edits.
- key: fix
kind: tool
tool: workspace.agent
args:
prompt: "test/orders_test.rb fails on currency rounding. Find the cause and fix it."
They need no connection either, but unlike the built-ins they only exist while a run is going: the folder and everything in it are deleted the moment the run finishes. See Workspaces for the tools, what the coding agent can do, and what a workspace can and can't reach.
llm — ask a model once
Sends one prompt to a model and records its answer.
- key: review
kind: llm
model: smart
system: You are a meticulous senior engineer.
prompt: |
Review this pull request and list concrete risks.
{{ steps.pr.output }}
schema:
type: object
properties:
risk: { type: string, enum: [low, medium, high] }
summary: { type: string }
required: [risk, summary]
| Option | Meaning |
|---|---|
prompt |
Required. The user message |
system |
Instructions that frame the model's behaviour |
model |
An account model name; omit to use the default. See AI models |
schema |
A JSON Schema (type: object). The model must answer with matching JSON |
temperature |
Lower is more deterministic |
max_tokens |
Caps the length of the answer |
Output: { text, data }. text is the model's answer; data is the parsed JSON when schema is set. Use schema whenever a later step or condition reads the result — {{ steps.review.output.data.risk }} is far more reliable than parsing prose.
While the step runs, the model's answer streams into the run page as it's generated.
agent — let a model use tools
An agent is a model that can call tools in a loop until it has an answer: explore a database schema, then query it; read an issue, then search for related ones.
- key: answer
kind: agent
model: smart
tools: [warehouse.*]
max_iterations: 12
system: You are a data analyst. Explore the schema before querying.
prompt: "Question from Slack: {{ trigger.text }}"
Agents accept everything llm does, plus:
| Option | Meaning |
|---|---|
tools |
Tools the agent may call: github.get_issue, or warehouse.* for all of a connection's tools |
max_iterations |
Maximum model turns (default 10, up to 50). The step fails if the agent hasn't finished by then |
- Each tool call appears in the step's logs with its arguments. Tool errors are passed back to the model so it can recover, rather than failing the step.
- Large tool results are truncated before being sent to the model.
- With
schema, the agent finishes with a final structured answer indata. - Output:
{ text, data, tool_calls }.
Prefer tool + llm steps when you know the sequence of calls in advance — they're faster, cheaper and easier to follow. Reach for agent when the model has to decide what to look up.
javascript — reshape data with code
Runs a small JavaScript function in a secure sandbox. Use it for filtering, grouping, counting, formatting and any logic that shouldn't need a model.
- key: tally
kind: javascript
inputs:
rows: "{{ steps.orders.output.rows }}"
threshold: "{{ inputs.threshold }}"
outputs: { total: number, large: array }
code: |
function main({ rows, threshold }) {
const large = rows.filter(r => r.amount > threshold)
console.log(`${large.length} of ${rows.length} orders are large`)
return { total: rows.reduce((sum, r) => sum + r.amount, 0), large }
}
| Option | Meaning |
|---|---|
code |
Required. Must define function main(inputs) that returns a plain object |
inputs |
The object passed to main. Whole-value templates keep their type, so objects and arrays arrive intact |
outputs |
Optional map of name → type (string, number, integer, boolean, object, array, any). The step fails if the result doesn't match |
timeout_ms |
Default 2000, maximum 10000 |
- The sandbox is a fresh V8 isolate for every run, with a 64 MB memory limit. There is no network, filesystem,
require, timers orasync— pure computation only. console.log,console.warnand friends write to the step's logs.- Output: the object returned by
main, e.g.{{ steps.tally.output.total }}.
The assistant tests JavaScript against sample inputs before saving it, and you can ask it to change the logic in plain language.