Skip to content
Orbital

Step kinds

The kinds of step a workflow can hold, what each does and the keys it takes

Pick the kind of step you need, and set its kind. A step is one table under steps in a workflow, and its kind decides what it does. A step with no kind is an agent.

review.toml
[steps.review]
prompt = "Review the change."
outputs.verdict = { kind = "choice", options = ["pass", "reject"] }
[steps.pause]
kind = "wait"
duration = "10m"
[steps.done]
kind = "terminal"
Kind kind What it does
Agent none Sends a prompt to a harness and waits for the turn to finish.
Probe probe Reads built-in observations, such as the pull request’s state.
Action action Does a built-in change, such as pushing a branch or opening a pull request.
Command command Runs a script of your own and records the facts it prints.
Decide decide Asks a decision model a question and follows its answer.
Wait wait Pauses for a set duration.
Gate gate Stops the run until a person lets it continue.
Fan-out fan_out Runs its child steps as parallel branches.
Fan-in fan_in Joins the parallel branches.
Terminal terminal Ends the run with an outcome.

Two more tables under steps are not kinds of their own: an import step puts another workflow in its place, and a group gathers steps under one name. Every step takes kind, label, max_visits, next and routes, as Keys every step takes describes. Unknown keys are errors.

The editor labels each step on the canvas with its kind.

The workflow editor canvas shows steps labelled by kind: a Probes step, Agent steps, a Wait of five minutes, a Worktree create step and an Ending with success.
Each step shows its kind above its id.

Sends a prompt to a harness and waits for the turn to finish.

[steps.<id>]
prompt = "<template>"
outputs.<name> = "<kind>"
next = "steps.<id>"
Key Type Required Default Description
prompt template One of prompt or prompt_file None The prompt, in the file.
prompt_file path One of prompt or prompt_file None A prompt file, relative to the workflow file.
outputs table No None Values the agent hands to later steps.
needs table No What the prompt reads Values the step needs, with defaults.
thread name No None A session shared with other agent steps.
timeout duration No 1h How long the turn waits for work its agent left running.
auto_merge squash, merge or rebase No None Merges the run’s pull request after the turn.
style name or list No Inherited The step’s styles.
harness, model, effort, connection, provider, persona, permissions and the harness-specific tables see the reference No From styles Settings for this step alone.

The agent renders its prompt, calls the harness and waits for a turn to finish. It is the step that changes files or talks to a service on your behalf through the agent’s own tools. Declare outputs when a later route or prompt needs a value from it, because a bare turn leaves nothing behind but prose. Context gives each output kind’s contract.

Orbital reads a prompt file again before each later visit. If an edit makes it invalid, Orbital reports the problem and falls back to the prompt it last checked.

agent.toml
version = 3
entry = "steps.plan"
[steps.plan]
label = "Plan the change"
prompt = "Read the code the goal touches and write a short plan. Do not change files."
outputs.plan = "text"
next = "steps.implement"
[steps.implement]
label = "Make the change"
prompt = "Carry out this plan: {{ context.plan }}"
next = "steps.done"
[steps.done]
kind = "terminal"

plan reads the code and writes its plan as the plan output; implement gets that plan in its prompt.

Related: Write your first workflow, Write prompts.

Reads built-in observations and records them as facts.

[steps.<id>]
kind = "probe"
observe = ["<probe>", "<probe>"]
Key Type Required Default Description
observe probe name, or a list Yes None The observations to read.

A probe only reads, so running it again is always safe and it takes no repeat_safety. It spends no agent turn. The facts it records start with its name, so github.pr records github.pr.state and git.diff records <folder>.git.diff.changed_lines. Each probe answers once for every repository folder of the run, except git.change and tracker.tickets, which answer once for the whole run.

Probe What it observes
github.pr The current branch’s pull request: its checks, review state and merge state, rolled up into github.pr.state.
github.threads The review threads on that pull request. <folder>.github.threads.open holds the unresolved ones as readable text.
git.diff Changes in the working branch.
git.change The branch’s diff against its base branch at git.change.diff, and what the checkout holds beyond the copy already pushed at git.change.unpublished, each with uncommitted and untracked files and capped at 4,000 lines. A run with several repositories heads each with its folder name.
git.worktree Whether each checkout could be removed without losing work.
tracker.tickets The title, state and text of every ticket the goal and work scope name, at tracker.tickets.text, with tracker.tickets.total and tracker.tickets.unreachable. It reads GitHub links, owner/repository#N and bare #N through GitHub, and Linear links and identifiers through Linear. A ticket it cannot read is listed with the reason.

github.pr.state is one of MERGED, CLOSED, CONFLICTS, CI_FAILED, CHANGES_REQUIRED and WAITING. Plain folders supply no repository observations. A failed probe attempt retries inside the same step, up to six attempts with a wait of 5 seconds growing to a minute between them, and records its progress; it spends no visit and takes no failure route per attempt. Context lists every fact each probe writes.

probe.toml
version = 3
entry = "steps.check"
[steps.check]
kind = "probe"
observe = ["github.pr", "github.threads"]
routes = [
{ if = "github.pr.state == 'MERGED'", to = "steps.merged" },
{ if = "github.pr.state == 'CHANGES_REQUIRED'", to = "steps.address" },
]
next = "steps.done"
[steps.address]
prompt = """
Answer the open review threads, one repository at a time:
{% for name, folder in run.folders %}
### {{ name }}
{{ context[name].github.threads.open }}
{% endfor %}
"""
next = "steps.done"
[steps.merged]
kind = "terminal"
[steps.done]
kind = "terminal"

check reads the pull request and its threads. A merged pull request ends the run; one with changes requested goes to address, which quotes the open threads of each repository.

Related: Add branches, Context.

Does a built-in change, without an agent turn.

[steps.<id>]
kind = "action"
do = "<action>"
Key Type Required Default Description
do action name Yes None The change to make.
The action’s own keys see below Depends on the action
Action What it does Keys Records
worktree.create Makes one isolated checkout per repository folder on one generated branch, orbital/<id>, and moves the run into it. None
worktree.remove Puts the checkouts away when nothing would be lost. None git.worktree.clean, git.worktree.status
git.commit Commits every change in each repository’s checkout. message
git.push Pushes each repository’s branch. None
github.pr.open Opens a pull request for each repository, or reuses the open one for the branch. title, body, draft, base, auto_merge github.pr.url, github.pr.number, github.pr.state

An action knows whether repeating it is safe, so it takes no repeat_safety. Committing with nothing to commit and pushing an unchanged branch do nothing. git.commit and git.push skip the repository’s Git hooks, and a push is never forced. github.pr.open writes the same paths the github.pr probe does, so a later condition reads one path whichever step wrote it. git.commit needs a message. On github.pr.open, title is required and body defaults to empty; draft is true or false and defaults to false; base defaults to the branch the run’s checkout was made from, which is the repository’s default branch unless the run chose another; a repository with no work of the run’s is skipped; auto_merge works as it does on an agent.

worktree.create leaves plain folders where they are, and the run’s folder paths follow the checkouts. If creation fails partway, the checkouts already made are kept for the retry. worktree.remove measures every checkout first and removes none if any would lose uncommitted files or unpushed work. Commits GitHub has already seen on a merged or closed pull request are not unpushed, even when the remote branch was deleted after the merge, and nor is a local commit the pull request head or the base holds rebased. git.worktree.status names any commits that remain. Removal deletes no branch. A run that publishes usually starts with worktree.create and ends with worktree.remove; a workflow without them works in your own folders.

A text key, message, title or body, takes text or asks a model to write it:

title = { write = "A short title naming the change", style = "quick" }

A write is a short model session with no tools. It reads the run’s context, follows the instruction and returns only the text. Its settings come from its own style, then the styles of the groups holding the action, then the workflow’s style. It spends no agent turn, and the run’s history shows what it wrote. Every built-in harness can write a text. A plugin harness that cannot fails the step with a message naming it.

action.toml
version = 3
entry = "steps.checkout"
[styles.quick]
effort = "low"
[steps.checkout]
kind = "action"
do = "worktree.create"
next = "steps.implement"
[steps.implement]
prompt = "Make the change the goal describes. Do not commit."
outputs.change_summary = "text"
next = "steps.publish.commit"
[steps.publish.commit]
kind = "action"
do = "git.commit"
message = { write = "A conventional commit message for the change", style = "quick" }
next = "steps.publish.push"
[steps.publish.push]
kind = "action"
do = "git.push"
next = "steps.publish.open"
[steps.publish.open]
kind = "action"
do = "github.pr.open"
draft = true
title = { write = "A short title naming the change", style = "quick" }
body = { write = "Why the change exists, then what it changes", style = "quick" }
next = "steps.done"
[steps.done]
kind = "terminal"

checkout makes the isolated checkout. After implement changes the files, the three publish steps commit, push and open a draft pull request, with a model writing the commit message, title and body.

Related: Push and open pull requests, Worktrees and delivery.

Runs a script of your own and records the facts it prints.

[steps.<id>]
kind = "command"
script = "<shell command>"
facts.<name> = "<kind>"
Key Type Required Default Description
script shell command Yes None Runs through bash -c. Not a prompt template.
facts.<name> bool, number or text Yes, at least one None The facts the script prints. A fact’s name has at least two parts, such as facts.tests.pass, which declares tests.pass.
timeout duration No 10m A script that runs out of time fails the step.
repeat_safety idempotent, reconcilable or uncertain No uncertain What running the script again would do.

The script runs once, in the primary working folder, and inherits the server’s environment. Earlier lines of output are free-form, but the last line must be one JSON object holding exactly the declared facts with matching types. A timeout, a non-zero exit or a malformed last line fails the step and sets <id>.failed to true; a successful run sets it to false and saves the facts. A script cannot overwrite a probe’s facts, an output or a reserved failure path. A command spends no agent turn.

Use idempotent when running the script again adds no further effect, reconcilable when it checks what an earlier attempt did before acting again, and uncertain when neither holds. After a restart, Orbital runs an idempotent or reconcilable script again, but stops before an uncertain one that had started and waits for you, so a script that changes the world never runs twice by surprise.

Write a longer script between ''', which keeps quotes and backslashes as they are.

command.toml
version = 3
entry = "steps.test"
[steps.test]
kind = "command"
repeat_safety = "idempotent"
timeout = "10m"
facts.tests.pass = "bool"
script = '''
if npm test >/dev/null 2>&1; then
echo '{"tests.pass":true}'
else
echo '{"tests.pass":false}'
fi
'''
routes = [
{ if = "tests.pass", to = "steps.passed" },
{ if = "outcome == 'failed'", to = "steps.failing" },
]
next = "steps.failing"
[steps.passed]
kind = "terminal"
[steps.failing]
kind = "terminal"
outcome = "failed"

The script prints tests.pass as JSON, and the routes read it. A script that fails or times out takes the outcome == 'failed' route.

Related: Add branches, Durability after restarts.

Asks a decision model structured questions about the run and follows the route its answers select.

[steps.<id>]
kind = "decide"
state = "<template>"
routes = [{ if = "<question> == '<option>'", to = "steps.<id>" }]
default = "steps.<id>"
[steps.<id>.questions.<question>]
kind = "choice"
options.<option> = "<when it applies>"
Key Type Required Default Description
state or state_file template or path One of them None What the model is asked about. It reads {{ context.x }} like an agent prompt, and {{ diff }} for the run’s change against its base, cut to the model’s context with a [… diff truncated …] marker. A file is read again before each visit.
questions.<name> table At least one None A question, with kind and an optional description.
default step reference Yes None Where the run goes when no answer selects a route.
decider name No The default decision model The name of a decision model in Settings. A name no model answers to takes the default path.
threshold number from 0 to 1 No The model’s threshold, which starts at 0.5 The confidence every answer needs before the run follows it.
routes routes No None The routes the answers select.

A question’s kind is choice, with options as for a choice output; score, with levels written the same way, each valued with when it applies; or noul, which answers yes or no. The answers land in context as <name> and <name>_confidence, so routes test them like outputs: risk == 'high', or a bare ship for a noul. A noul question is tested only as true or false.

A decide step takes default instead of next. It tries its routes in order and follows the first that holds. The run follows default when no route holds, when any answer’s confidence is below threshold, when no decision model is configured, and when the model cannot be reached or answers with something unusable; no agent answers in its place. Each decision, with why it took its route, is recorded as a DecisionMade event and shown as a card in the run’s Session tab. A restart replays the recorded decision and does not ask again. Validation refuses a decide step without a question or without default, and a route testing a question or option it does not declare.

decide.toml
version = 3
entry = "steps.implement"
[steps.implement]
prompt = "Make the change the goal describes and summarise it."
outputs.change_summary = "text"
next = "steps.ready"
[steps.ready]
kind = "decide"
state = "The change: {{ context.change_summary }}"
threshold = 0.7
routes = [{ if = "ship", to = "steps.done" }]
default = "steps.revise"
[steps.ready.questions.ship]
kind = "noul"
description = "Is the change ready to ship as it stands?"
[steps.revise]
prompt = "Revise the change. Summary so far: {{ context.change_summary }}"
next = "steps.done"
[steps.done]
kind = "terminal"

ready asks whether the change can ship. A confident yes ends the run; anything else, including no decision model, takes default to revise.

Related: Decide with a model, Add branches.

Pauses the run for a set time.

[steps.<id>]
kind = "wait"
duration = "<duration>"
Key Type Required Default Description
duration duration, such as 15m, 30s or 1h Yes None A whole number followed by ms, s, m or h.

A loop that polls an outside system needs a wait; without one, a loop that watches a pull request spends agent turns. Waiting spends no agent turn, survives a server restart and keeps the interface responsive. You can skip the remaining time, and the run’s history records that you did.

wait.toml
version = 3
entry = "steps.check"
[steps.check]
kind = "probe"
observe = "github.pr"
routes = [
{ if = "github.pr.state == 'MERGED'", to = "steps.merged" },
{ if = "github.pr.state == 'CLOSED'", to = "steps.closed" },
]
next = "steps.pause"
[steps.pause]
kind = "wait"
duration = "10m"
next = "steps.check"
[steps.merged]
kind = "terminal"
[steps.closed]
kind = "terminal"
outcome = "failed"

The probe reads the pull request. Until it has merged or closed, the run waits ten minutes and looks again.

Related: Add gates and waits, Add a loop.

Stops the run until a person lets it continue.

[steps.<id>]
kind = "gate"
label = "<what the run waits on>"
next = "steps.<id>"
Key Type Required Default Description
label text No The step’s id Shown as what the run waits on.
resume pull_request or operator No pull_request Whether the run may open the gate by itself when the world changes.
next step reference Yes None Where the run goes when the gate opens.

The gate stops the run without recording a failure. The run shows “Waiting:” followed by the gate’s label, and it gives its run slot to other runs. Resume or Skip wait lets it through.

With resume = "pull_request", the run also watches the world once a minute and carries on by itself when it changes. When the run has read its tickets with the tracker.tickets probe and could read every one, it waits for the tickets’ text to change. Otherwise, when the context holds a github.pr.state, it waits for the pull request to leave WAITING. A gate with neither in play waits for you, and so does every gate with resume = "operator". Either way, the run joins the back of the queue and follows next. Waiting spends no agent turn and survives a server restart. A gate without next, or one inside a fan-out, fails validation.

gate.toml
version = 3
entry = "steps.plan"
[steps.plan]
prompt = "Write a plan for the goal. Do not change files."
outputs.plan = "text"
next = "steps.approve"
[steps.approve]
kind = "gate"
label = "Read the plan, then continue"
next = "steps.implement"
[steps.implement]
prompt = "Carry out this plan: {{ context.plan }}"
next = "steps.done"
[steps.done]
kind = "terminal"

The run stops after the plan. When you let it continue, it goes on to implement.

Related: Add gates and waits.

Runs its child steps as parallel branches.

[steps.<id>]
kind = "fan_out"
join = "steps.<fan-in>"
[steps.<id>.<branch>]
prompt = "<template>"
next = "steps.<fan-in>"
Key Type Required Default Description
join step reference Yes None The fan-in where the branches meet.
branches list of step references No None Steps elsewhere in the file that each start a branch.
max_parallel positive whole number No 4 How many branches run at once.
style name or list No None Applies to every branch step inside it.
routes routes No None Where a partial failure goes.
Child steps steps No None Each one starts a branch, in file order.
state or state_file template or path To decide None What a decision model is asked about, as on a decide step. Declaring it, or a question, makes the fan-out decide which branches start.
questions.<name> table To decide None One choice question, or several noul questions, as on a decide step.
choices.<answer> list of step references To decide, for every branch None The branches an answer starts: an option of the choice question, or the name of a noul question answered yes.
default list of step references To decide None The branches that start when no decision is made.
decider name No The default decision model The decision model to ask.
threshold number from 0 to 1 No The model’s threshold The confidence every answer needs.

Each child step starts a branch, and so does each step branches lists, up to max_parallel at a time. Write the branches nested, listed or both; a fan-out needs at least two. A branch runs from its child step until it reaches join. Branches cannot loop, hold a gate, join a thread, share a step with another branch or leave for a step outside the branch. A branch may hold a fan-out of its own. Neither a branch step nor the fan-in can be the workflow’s entry. They share the same files, so keep them reading unless they write to separate paths.

When every branch succeeds, the run enters the fan-in. When some fail, the fan-out takes the first of its routes that holds, such as outcome == 'failed'. With none, the run is Paused and names the failed branches. Each branch works on its own copy of the context; History and threads gives the rules.

parallel-reviews.toml
version = 3
entry = "steps.reviews"
[steps.reviews]
kind = "fan_out"
max_parallel = 2
join = "steps.collect"
[steps.reviews.clarity]
prompt = "Review the goal for clarity."
outputs.assessment = "text"
next = "steps.collect"
[steps.reviews.risks]
prompt = "Review the goal for risks."
outputs.assessment = "text"
next = "steps.collect"
[steps.collect]
kind = "fan_in"
prompt = "Summarise these reviews: {{ context.parallel.results['0'].context.assessment }} and {{ context.parallel.results['1'].context.assessment }}"
next = "steps.done"
[steps.done]
kind = "terminal"

reviews starts reviews.clarity and reviews.risks side by side. collect waits for both, then one turn summarises their answers.

The editor shows a fan-out step with a maximum of three branches, three parallel steps below it, and a prompted fan-in step that combines their findings.
The fan-out starts three branches; the prompted fan-in combines their results.

A fan-out can decide which branches start. It declares state, and either one choice question or several noul questions, and optionally decider and threshold, as a decide step does. choices names the branches each answer starts, and default the branches that start when no decision is made.

[steps.<id>]
kind = "fan_out"
join = "steps.<fan-in>"
state = "<template>"
default = ["steps.<id>.<branch>"]
choices.<option> = ["steps.<id>.<branch>"]
[steps.<id>.questions.<name>]
kind = "choice"
options.<option> = "<when it applies>"

The model’s option, or each noul question it answers yes, starts the branches listed under it. The fan-out starts its default branches instead when no decision model is configured, the model cannot answer, an answer is below the threshold, or the picks start no branch. The fan-in joins only the branches that started. The pick is recorded, so a restart starts the same branches and does not ask again. Validation refuses a deciding fan-out without default, a branch no answer starts, and an answer its questions do not offer.

Related: Run steps in parallel, Decide with a model.

Joins the branches of a fan-out.

[steps.<id>]
kind = "fan_in"
prompt = "<template>"
next = "steps.<id>"
Key Type Required Default Description
prompt or prompt_file template or path No None Adds one turn that combines the branch results.
style name or list No The workflow’s Settings for that turn.
persona persona name, or "" No From styles The persona for that turn.

With a prompt, the fan-in runs one turn that combines the branch results, which it reads through parallel.results. Without one, it is only the point where the branches meet. Each fan-in belongs to one fan-out.

Related: Run steps in parallel, Write prompts.

Ends the run with an outcome.

[steps.<id>]
kind = "terminal"
outcome = "<outcome>"
Key Type Required Default Description
outcome success, failed or aborted No success How the run ends.
label text No The step’s id The reason the run ended.
final true No Not set Ends the chain of a repeating workflow.
cause output name No None Works as a route’s cause for every route into the terminal. Each step that routes here must be an agent that declares the output.

Give a workflow a terminal for each way it can end. A terminal takes no next or routes. In an imported workflow, every terminal is an exit the importing workflow can continue from.

terminal.toml
version = 3
entry = "steps.review"
[steps.review]
prompt = "Review the change. Choose pass when it is ready, or reject when it is not."
outputs.verdict = { kind = "choice", options = ["pass", "reject"] }
routes = [
{ if = "verdict == 'pass'", to = "steps.approved" },
{ if = "verdict == 'reject'", to = "steps.rejected" },
]
[steps.approved]
kind = "terminal"
outcome = "success"
[steps.rejected]
kind = "terminal"
outcome = "failed"
label = "The review rejected the change"

A pass ends the run as Succeeded; a reject ends it as Failed, with the label as the reason.

Related: Error handling.

Puts another workflow file in this step’s place.

[steps.<id>]
import = "<path>"
exits.<terminal> = "steps.<id>"
Key Type Required Default Description
import path Yes None A complete workflow, relative to the file that names it.
inputs.<name> template For the imported workflow’s required inputs None The imported workflow’s inputs.
exits.<terminal> step reference, or { to, loop_restart } No Ends the run Where the run goes when the imported workflow ends there.
style name or list No None Applies to every imported step.
label text No The step’s id

Before validation, Orbital puts the imported workflow in the import step’s place, so the editor, the validator and the run all see one workflow:

  • The import step becomes a group, so imported ids take its id as a prefix: review.review is the imported review step.
  • Routes into the import step reach the imported workflow’s entry.
  • The imported workflow’s terminals become exits, and exits says where each one leads.
  • Outputs and facts keep their own names, so a condition outside can read a value the imported workflow produced.

Relative imports and prompt files resolve from the file that names them. Import cycles are errors. An import that cannot be found fails loading and names the step, rather than producing a partial workflow that runs. The import step’s style applies below each imported step’s own styles, and the imported file’s styles join the importing workflow’s.

import.toml
version = 3
entry = "steps.implement"
[steps.implement]
prompt = "Make the change the goal describes."
next = "steps.review"
[steps.review]
import = "imports/review.toml"
exits.approved = "steps.done"
exits.rejected = "steps.failed"
[steps.done]
kind = "terminal"
[steps.failed]
kind = "terminal"
outcome = "failed"
imports/review.toml
version = 3
entry = "steps.review"
[steps.review]
prompt = "Review the change in the working folder. Choose pass when it is ready, or reject when it is not. Do not change files."
outputs.verdict = { kind = "choice", options = ["pass", "reject"] }
routes = [
{ if = "verdict == 'pass'", to = "steps.approved" },
{ if = "verdict == 'reject'", to = "steps.rejected" },
]
[steps.approved]
kind = "terminal"
[steps.rejected]
kind = "terminal"
outcome = "failed"

review is replaced by imports/review.toml. Its approved and rejected terminals are the exits the two exits keys map.

Related: Reuse a workflow, Give steps a style.

Gathers steps that belong together under one name.

[steps.<group>]
style = "<style>"
[steps.<group>.<id>]
prompt = "<template>"
Key Type Required Default Description
style name or list No None Applies to every step inside, at any depth.
label text No The group’s name Shown around the group in the editor.

A table under steps with no kind, prompt, prompt_file or import is a group. It holds at least one step, takes no turn and is never a route’s target; route to one of its steps instead. Groups nest, and a step takes its innermost group’s style first.

group.toml
version = 3
entry = "steps.coder.plan"
[styles.careful]
effort = "high"
[steps.coder]
label = "The coder"
style = "careful"
[steps.coder.plan]
prompt = "Plan the change the goal describes. Do not change files."
outputs.plan = "text"
next = "steps.coder.implement"
[steps.coder.implement]
prompt = "Carry out this plan: {{ context.plan }}"
next = "steps.done"
[steps.done]
kind = "terminal"

Both coder.plan and coder.implement run with the careful style, because they sit in the coder group.

Related: Workflow file, Styles and personas.

Routes live on the step the run leaves, so a branch is an ordinary agent, probe or command whose routes carry conditions. A decide step is the one kind whose only job is to choose a route, and it asks a decision model rather than following a rule.

conditional.toml
version = 3
entry = "steps.classify"
[steps.classify]
prompt = "If the goal asks for a greeting, choose greet. Otherwise choose explain."
outputs.route = { kind = "choice", options = ["greet", "explain"] }
routes = [
{ if = "route == 'greet'", to = "steps.greet" },
{ if = "route == 'explain'", to = "steps.explain" },
]
[steps.greet]
prompt = "Write a friendly greeting."
next = "steps.done"
[steps.explain]
prompt = "Explain the goal in one sentence."
next = "steps.done"
[steps.done]
kind = "terminal"

classify is an ordinary agent that chooses a route. Its two routes carry the branch, and because they answer both options, it needs no next.