Reference
Workflow reference
Triggers, step kinds, templates and conditions — the complete definition language.
A workflow is a YAML document with a trigger, optional inputs, and a list of steps.
Steps run one after another in dependency order. A step runs only when every step it depends on succeeded,
and only when its optional if condition is met; otherwise it is skipped, and so are the steps that depend on it.
trigger:
kind: manual
inputs:
repo: { type: string, required: true, description: "owner/name" }
number: { type: integer, required: true }
steps:
- key: pr
kind: tool
tool: github.get_pull_request
args: { repo: "{{ inputs.repo }}", number: "{{ inputs.number }}", include_diff: true }
- key: review
kind: llm
system: You are a meticulous senior reviewer.
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]
- key: comment
kind: tool
if: { path: steps.review.output.data.risk, in: [medium, high] }
tool: github.create_comment
args:
repo: "{{ inputs.repo }}"
number: "{{ inputs.number }}"
body: "**Risk: {{ steps.review.output.data.risk }}**\n\n{{ steps.review.output.data.summary }}"
Triggers
| kind | keys | payload available as trigger |
|---|---|---|
manual |
— | { inputs }. Fired from the UI, MCP run_workflow, or another client. |
schedule |
cron (5-field cron, required), timezone (IANA, e.g. America/Toronto) |
{ scheduled_at, inputs } (inputs use their defaults) |
webhook |
— | { body, query, headers }. POST JSON to the workflow's webhook URL. |
github |
connection (required), events (e.g. [pull_request, issue_comment]), actions (e.g. [opened, synchronize]), repos ([owner/name]) |
The GitHub webhook payload plus event (the X-GitHub-Event header). |
linear |
connection (required), events ([Issue, Comment, Project, ...]), actions ([create, update, remove]), teams ([team keys]), states ([state names]; on update, only when the state changed), include_self (default false) |
Linear's webhook body: type, action, data (the issue or comment, e.g. data.identifier, data.state.name, data.team.key), updatedFrom, url, actor. |
slack |
connection (required), events (default [app_mention]; also message, reaction_added), channels ([channel IDs]), include_bots (default false) |
The Slack event object (type, user, text, channel, ts, thread_ts, ...) plus team_id. |
any other connection kind with trigger events (stripe, shopify, zendesk, gitlab, pagerduty, ...) |
connection (required), events (names from the connection's trigger_events), plus the connection's other trigger_keys from list_connections, each a list of values matched case-insensitively (e.g. projects: [acme/api]) |
The provider's webhook JSON body plus event, the event name as listed in trigger_events. |
Inputs
inputs maps a name to { type, description, required, default }. Types: string, number, integer, boolean, object, array.
Inputs are shown as a form when a person runs the workflow and are available to templates as inputs.<name>.
Steps
Every step has key (lowercase, underscores, unique), kind, and optionally name (display label), depends_on (list of keys) and if (condition).
Dependencies are also inferred from {{ steps.<key>... }} references and condition paths, so depends_on is only needed for ordering without data flow.
tool — call one tool
tool:<connection slug>.<tool name>, e.g.slack.post_message, or one of the built-ins below. Use list_connections to see what exists and each tool's argument schema.args: mapping of arguments; values may be templates.- Output: whatever the tool returns (usually an object).
llm — one model call
prompt(required),system,model(an account model name; omit for the account default),temperature,max_tokens.schema: a JSON Schema withtype: object. When present, the model must answer with matching JSON.- Output:
{ text, data }wheredatais the parsed JSON whenschemais set.
agent — a model that can call tools in a loop
- Everything
llmaccepts, plustools(list of<connection>.<tool>orbuiltin.<tool>names, or<connection>.*for all of one connection's tools) andmax_iterations(default 10, max 50). - Output:
{ text, data, tool_calls }. - Prefer
toolandllmsteps when the sequence of calls is known in advance; useagentwhen the model must decide.
javascript — run code in a sandbox
code(required): JavaScript that definesfunction main(inputs) { ... }and returns a plain object.inputs: mapping passed tomain; values may be templates (whole-value templates keep their type, so"{{ steps.pr.output }}"passes an object).outputs: optional mapping of name → type (string, number, integer, boolean, object, array, any). The returned object must contain these keys with these types or the step fails.timeout_ms: default 2000, max 10000. Memory is capped at 64 MB.- The sandbox is pure V8: no network, filesystem,
require, timers or async/await.console.logoutput appears in the step's logs. - Output: the object returned by
main.
- key: tally
kind: javascript
inputs: { rows: "{{ steps.orders.output.rows }}" }
outputs: { total: number, count: integer }
code: |
function main({ rows }) {
const total = rows.reduce((sum, r) => sum + Number(r.amount), 0);
console.log(`summed ${rows.length} rows`);
return { total, count: rows.length };
}
Built-in tools
Every account has these without setting up a connection. Use them in tool steps and in an agent's tools list, or builtin.* for all of them.
| tool | arguments | output |
|---|---|---|
builtin.fetch_url |
url (required), params |
Parsed JSON, or the page as plain text with the markup removed. GET only, and it sends no credentials, so use a connection's tools for anything needing a key or a sign-in — an http connection covers any HTTP API with a key. |
builtin.extract |
url or html, selectors (name → CSS selector), links (boolean), limit (per selector, default 50) |
{ title, matches: { name: [{ text, href, src }] }, links: [{ text, href }] }, with links made absolute |
builtin.parse_feed |
url or content (RSS, Atom or JSON Feed), since (ISO 8601), limit (default 20) |
{ title, link, items: [{ id, title, link, published, summary }] } |
builtin.json_query |
query (required, JMESPath), data or url with params |
Only what the query selects |
builtin.check_url |
url (required), method (GET or HEAD), contains |
{ ok, status, final_url, latency_ms, redirects, headers, contains }. An error or an unreachable address gives ok: false and error, not a step failure. |
builtin.run_javascript |
code (required, defines function main(inputs)), inputs |
{ output, logs }, where output is what main returned. The same sandbox as a javascript step. |
builtin.now |
timezone (IANA name, default UTC) |
{ iso8601, date, weekday, formatted, timezone } |
builtin.date_math |
date (ISO 8601 or Unix time, default now), timezone, start_of (day, week starting Monday, month, year), add ({ years, months, weeks, days, hours, minutes, seconds }, negative to go back), compare_to |
Applied in that order. The result as now returns it, plus difference: { seconds, hours, days } from it to compare_to |
builtin.wait |
seconds (required, at most 300), reason |
{ waited_seconds, now } |
builtin.remember |
key (required), value (required, any JSON up to 100 KB) |
{ key, bytes, updated_at } |
builtin.recall |
key (omit to list keys) |
{ key, found, value, updated_at }, or { keys } |
builtin.forget |
key (required) |
{ key, forgotten } |
Prefer a javascript step to an agent calling builtin.run_javascript when you already know the transformation. Give an agent extract or json_query with a url rather than fetch_url when it needs only part of a large page or response, so that less of it reaches the model.
remember, recall and forget keep values per workflow across runs: up to 200 keys, the last write winning. Use them so a scheduled workflow only acts on what is new, for example by recalling last_checked_at, passing it as parse_feed's since, then remembering {{ run.started_at }}. They work only inside a run, so you cannot call them while authoring.
Workspace tools
Also there for every account, and also needing no connection, but only inside a run: workspace.* provisions a folder for the run, isolated in a Docker sandbox, and deletes it — with everything in it — as soon as the run finishes. Use them for work on a repository or on files: clone, change, run the tests, commit, push.
| tool | arguments | output |
|---|---|---|
workspace.create |
name (default main), repo (owner/name or an https URL), branch, connection (a github connection's slug, needed for a private repository and for pushing), depth (default 1, 0 for full history), agent (opencode or codex, default opencode) |
{ workspace, agent, repo, branch, head } |
workspace.read_file |
workspace, path (required), max_bytes, start_line, line_count (default 200) |
The file as text, truncated; with start_line or line_count, { path, content, start_line, end_line, total_lines } |
workspace.write_file |
workspace, path (required), content (required) |
{ path, bytes } |
workspace.list_files |
workspace, path, depth (default 2) |
A list of { path, kind, bytes }, ignoring .git |
workspace.find_files |
workspace, pattern (file-name glob, e.g. *_test.rb), path, limit (default 200) |
{ files, truncated }, paths from the workspace root, skipping ignored files |
workspace.search |
workspace, pattern (required, extended regex), path, glob (e.g. *.rb), ignore_case, fixed (plain text), limit (default 100) |
{ matches: [{ path, line, text }], truncated }, skipping .git, binary and ignored files |
workspace.query_file |
workspace, path (required, JSON or YAML), query (required, JMESPath) |
Only what the query selects |
workspace.detect_project |
workspace, path |
{ path, stacks: [{ language, manifest, manager, test, lint, build }], make_targets, ci }. The commands are guesses from manifest files. |
workspace.exec |
workspace, command (required), timeout |
{ exit_code, output }. A failing command comes back as a non-zero exit_code, not a step failure. |
workspace.agent |
workspace, prompt (required), model, continue, timeout |
{ text, session_id, files_changed, exit_code } |
workspace.diff |
workspace, path, stat, base (e.g. origin/main) |
The uncommitted changes as a patch, new files included; with base, everything since the branch left it |
workspace.log |
workspace, ref, path, since (a date or "2 weeks ago"), limit (default 20, max 100) |
[{ sha, author, date, subject }], newest first |
workspace.show |
workspace, ref (default HEAD), path, stat |
The commit's message and patch, or with path the file's contents at ref |
workspace.blame |
workspace, path (required), start_line (required), end_line (at most 100 lines) |
[{ line, text, sha, author, date, summary }] |
workspace.commit |
workspace, message (required), paths |
{ sha, branch, files } |
workspace.push |
workspace, branch, connection |
{ branch, repo } |
workspace.destroy |
workspace |
{ destroyed } |
- To find your way around a repository, use
search,find_files,read_filewithstart_lineanddetect_projectrather thanexecwith grep or cat. They return structured results and change nothing.log,blameanddiffwithbaseneeddepth: 0onworkspace.create, because a shallow clone has one commit and one branch. workspace.agentruns the workspace's coding agent over the folder: it reads and edits files itself and runs commands in the sandbox. Give it work you would describe to a developer ("make the failing test in test/orders_test.rb pass"), and useexec,diffandread_fileafterwards to check what it did. It needs one of the account's own models, including a self-hosted Ollama one — a Murmurator AI model is refused, because the agent calls the provider directly and Murmurator cannot meter it.- The agent is chosen on
workspace.create:opencode(the default) works with every provider an account can add;codexspeaks only the OpenAI responses API, so it takes an OpenAI or Ollama model. - Paths are relative to the workspace root, and nothing outside it can be read or written.
- Cloning and pushing use a github connection's token and obey that connection's allowed repositories.
- A run gets at most four workspaces. Steps in the same run share them by name, so
createonce and useworkspace: mainafterwards.
- key: fix
kind: tool
tool: workspace.create
args: { repo: acme/api, branch: main, connection: github, agent: opencode }
- key: work
kind: tool
tool: workspace.agent
args:
prompt: "The test in test/orders_test.rb fails. Find out why and fix it without changing the test."
- key: verify
kind: tool
tool: workspace.exec
args: { command: "bin/rails test test/orders_test.rb" }
Artifact tools
Also there for every account with no connection: artifact.* keeps markdown documents that belong to the account and outlive the run that wrote them — a weekly report, a running log, a brief. Each has a slug (lowercase letters, numbers and dashes) and every change is saved as a new version, which people can read, with its history, on the Artifacts page. Use them for anything a person or a later run should read after this run is over; use builtin.remember for small values only the workflow itself needs.
| tool | arguments | output |
|---|---|---|
artifact.create |
title (required), slug (default made from the title, so a new one each run), body |
{ slug, title, version, bytes, url, updated_at }. Fails if the slug is taken. |
artifact.write |
slug (required), body (required, the full markdown), title |
The same. Creates the artifact if the slug is new, otherwise replaces its body. |
artifact.read |
slug (required), section (a heading), version |
The same plus headings: [{ level, text }] and body, only that section's when section is given |
artifact.append |
slug (required), content (required), section, title |
The same as write. Adds to the end of the artifact, or of section. Creates the artifact if the slug is new. |
artifact.edit_section |
slug (required), section (required), content (required) |
The same. Replaces what is under the heading, up to the next heading of the same or a higher level. |
artifact.list |
query (text in the title or slug), limit (default 50) |
{ artifacts: [...] }, most recently changed first |
artifact.import |
slug (required), path (required), workspace, title |
The same as write, with the workspace file's text as the new body. Only inside a run. |
- A
sectionis matched by its heading text, ignoring case and the leading#s. One that does not exist is added at the end, as##unless you write the#s yourself ("### Notes"). - Prefer
appendandedit_sectionto reading a long artifact and writing it all back. An item appended under a list or a table joins it rather than starting a new paragraph. - A step repeating the same call with nothing changed in between is treated as a retry, and returns the version it already saved.
- To give a step an artifact's text, use a template such as
{{ artifacts.ops-weekly.body }}instead of anartifact.readstep (see Templates).
- key: log
kind: tool
tool: artifact.append
args:
slug: deploy-log
section: "{{ trigger.repository.name }}"
content: "- {{ trigger.head_commit.id }} by {{ trigger.pusher.name }}"
Screenshot tools
Also there for every account with no connection: screenshot.* loads web pages in a headless browser and keeps a PNG of each in the account's Screenshots gallery, where people can see them after the run. Pages load signed out, from the public internet: addresses on a private network are refused, and so is every request a page makes to one, including a redirect.
| tool | arguments | output |
|---|---|---|
screenshot.capture |
urls (list of pages), or hosts (variant name → base URL) with paths (each starting with /); devices (desktop (default), laptop, tablet, mobile, android, or a size such as 1024x768), full_page, wait_until (load, domcontentloaded, networkidle), delay_ms (up to 10000), timeout_ms (up to 90000), title |
{ slug, title, url, captured, failed, created_at, captures: [...], text }. Each capture has page, variant, device, url, final_url, status, error, http_status, refused_hosts, link and image_url. |
screenshot.read |
slug (required) |
The same |
screenshot.list |
query (text in the title or slug), limit (default 50) |
{ screenshots: [...] }, newest first |
hostscaptures every path on every host, and the gallery shows them side by side in the order written, so{ before: ..., after: ... }reads left to right. Each call makes a new set, with a slug made fromtitle.- One call takes at most 12 captures, counted as pages × hosts × devices.
- A page that fails to load is recorded with its error and the rest carry on; the call fails only when every capture failed. A page answering 404 or 500 is still captured, with its
http_status. textis a markdown table linking to each capture in the gallery, ready forgithub.create_commentorslack.post_message. The gallery and image links need a signed-in member of the account, so post the links rather than embedding the images.
- key: shots
kind: tool
tool: screenshot.capture
args:
title: "PR {{ trigger.pull_request.number }}"
hosts: { before: "https://example.com", after: "https://pr-{{ trigger.pull_request.number }}.preview.example.com" }
paths: [ /, /pricing ]
devices: [ desktop, mobile ]
Templates
{{ path }} inserts a value from the run context:
trigger.*— the trigger payloadinputs.*— resolved inputssteps.<key>.output.*,steps.<key>.status,steps.<key>.errorrun.id,run.trigger_kind,workflow.name,workflow.slugartifacts.<slug>.body,.title,.version,.url,.updated_at— an artifact as it is when the step starts. Only artifacts a step names are loaded, a missing one resolves to nothing (so{ path: artifacts.<slug>, exists: false }checks for it), and a step reading one that an earlier step writes needs that step independs_on.
Array elements are addressed by index: steps.search.output.0.title.
When a string is exactly one template, the value keeps its type (object, number, ...). Inside a longer string, objects and arrays are inserted as JSON.
There are no filters or expressions: use a javascript step to reshape data.
Conditions (if)
- Comparison:
{ path: <template path without braces>, <operator>: <value> }
Operators:equals,not_equals,in(list),exists(true/false),matches(regex),gt,gte,lt,lte,truthy(true/false). - Combinators:
{ all: [...] },{ any: [...] },{ not: {...} }.
if:
all:
- { path: trigger.action, equals: opened }
- { not: { path: trigger.pull_request.draft, equals: true } }
Guidance
- Never put secrets in a definition. Credentials live on connections; refer to connections by slug.
- Slack channels and GitHub repositories should be explicit values or come from the trigger/inputs.
- Keep prompts specific and ask for structured output (
schema) whenever a later step or condition reads the result. - Validate a definition before saving it, then describe the change you made.