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 with type: object. When present, the model must answer with matching JSON.
  • Output: { text, data } where data is the parsed JSON when schema is set.

agent — a model that can call tools in a loop

  • Everything llm accepts, plus tools (list of <connection>.<tool> or builtin.<tool> names, or <connection>.* for all of one connection's tools) and max_iterations (default 10, max 50).
  • Output: { text, data, tool_calls }.
  • Prefer tool and llm steps when the sequence of calls is known in advance; use agent when the model must decide.

javascript — run code in a sandbox

  • code (required): JavaScript that defines function main(inputs) { ... } and returns a plain object.
  • inputs: mapping passed to main; 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.log output 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_file with start_line and detect_project rather than exec with grep or cat. They return structured results and change nothing. log, blame and diff with base need depth: 0 on workspace.create, because a shallow clone has one commit and one branch.
  • workspace.agent runs 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 use exec, diff and read_file afterwards 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; codex speaks 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 create once and use workspace: main afterwards.
  - 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 section is 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 append and edit_section to 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 an artifact.read step (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
  • hosts captures 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 from title.
  • 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.
  • text is a markdown table linking to each capture in the gallery, ready for github.create_comment or slack.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 payload
  • inputs.* — resolved inputs
  • steps.<key>.output.*, steps.<key>.status, steps.<key>.error
  • run.id, run.trigger_kind, workflow.name, workflow.slug
  • artifacts.<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 in depends_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.