> For the index of every page in Orbital's docs, read https://docs.beta.runorbital.dev/llms.txt.

# 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](/workflows/), and its kind decides what it does. A step with no `kind` is an agent.

```toml title="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](#agent) | none | Sends a prompt to a harness and waits for the turn to finish. |
| [Probe](#probe) | `probe` | Reads built-in observations, such as the pull request's state. |
| [Action](#action) | `action` | Does a built-in change, such as pushing a branch or opening a pull request. |
| [Command](#command) | `command` | Runs a script of your own and records the facts it prints. |
| [Decide](#decide) | `decide` | Asks a decision model a question and follows its answer. |
| [Wait](#wait) | `wait` | Pauses for a set duration. |
| [Gate](#gate) | `gate` | Stops the run until a person lets it continue. |
| [Fan-out](#fan-out) | `fan_out` | Runs its child steps as parallel branches. |
| [Fan-in](#fan-in) | `fan_in` | Joins the parallel branches. |
| [Terminal](#terminal) | `terminal` | Ends the run with an outcome. |

Two more tables under `steps` are not kinds of their own: an [import step](#import) puts another workflow in its place, and a [group](#group) gathers steps under one name. Every step takes `kind`, `label`, `max_visits`, `next` and `routes`, as [Keys every step takes](/reference/workflow-file/#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.](/screenshots/workflow-editor.webp?v=85a9c6275a)

*Each step shows its kind above its id.*

## Steps that do work

### Agent

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

```toml
[steps.<id>]
prompt = "<template>"
outputs.<name> = "<kind>"
next = "steps.<id>"
```

| Key | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| [`prompt`](/reference/workflow-file/#agent-keys) | template | One of `prompt` or `prompt_file` | None | The prompt, in the file. |
| [`prompt_file`](/reference/workflow-file/#agent-keys) | path | One of `prompt` or `prompt_file` | None | A prompt file, relative to the workflow file. |
| [`outputs`](/reference/workflow-file/#outputs) | table | No | None | Values the agent hands to later steps. |
| [`needs`](/reference/workflow-file/#needs) | table | No | What the prompt reads | Values the step needs, with defaults. |
| [`thread`](/reference/workflow-file/#thread) | name | No | None | A session shared with other agent steps. |
| [`timeout`](/reference/workflow-file/#timeout) | duration | No | `1h` | How long the turn waits for work its agent left running. |
| [`auto_merge`](/reference/workflow-file/#auto_merge) | `squash`, `merge` or `rebase` | No | None | Merges the run's pull request after the turn. |
| [`style`](/reference/workflow-file/#styles) | name or list | No | Inherited | The step's styles. |
| [`harness`, `model`, `effort`, `connection`, `provider`, `persona`, `permissions`](/reference/workflow-file/#style-keys) and the [harness-specific tables](/reference/workflow-file/#harness-specific-keys) | 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](/reference/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.

```toml title="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](/workflows/write-a-workflow/), [Write prompts](/workflows/write-prompts/).

### Probe

Reads built-in observations and records them as facts.

```toml
[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](/reference/context/#command-facts) lists every fact each probe writes.

```toml title="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](/workflows/add-branches/), [Context](/reference/context/).

### Action

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

```toml
[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](#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`](/reference/workflow-file/#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:

```toml
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.

```toml title="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](/workflows/use-actions/), [Worktrees and delivery](/projects/worktrees-and-delivery/).

### Command

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

```toml
[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.

```toml title="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](/workflows/add-branches/), [Durability after restarts](/runs/durability/).

### Decide

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

```toml
[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](/settings/decision-models/) 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.

```toml title="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](/workflows/decide-with-a-model/), [Add branches](/workflows/add-branches/).

## Steps that wait

### Wait

Pauses the run for a set time.

```toml
[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.

```toml title="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](/workflows/gates-and-waits/), [Add a loop](/workflows/add-a-loop/).

### Gate

Stops the run until a person lets it continue.

```toml
[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.

```toml title="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](/workflows/gates-and-waits/).

## Parallel steps

### Fan-out

Runs its child steps as parallel branches.

```toml
[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](#decide). 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](/reference/history-and-threads/#parallel-branches) gives the rules.

```toml title="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.](/screenshots/workflow-editor-roles.webp?v=3ff23d4252)

*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.

```toml
[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](/workflows/run-steps-in-parallel/), [Decide with a model](/workflows/decide-with-a-model/).

### Fan-in

Joins the branches of a fan-out.

```toml
[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](/workflows/run-steps-in-parallel/), [Write prompts](/workflows/write-prompts/).

## Ending a run

### Terminal

Ends the run with an outcome.

```toml
[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](/reference/workflow-file/#repeat). |
| `cause` | output name | No | None | Works as a route's [`cause`](/reference/workflow-file/#routes) 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.

```toml title="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.

:::caution
A run that reaches no terminal does not finish. It is Paused and shows "Waiting on you".
:::

Related: [Error handling](/reference/error-handling/).

## Organising steps

### Import

Puts another workflow file in this step's place.

```toml
[steps.<id>]
exits.<terminal> = "steps.<id>"
```

| Key | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| [`import`](/reference/workflow-file/#imports) | 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.

```toml title="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"
```

```toml title="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](/workflows/reuse-a-workflow/), [Give steps a style](/styles/configure-steps/).

### Group

Gathers steps that belong together under one name.

```toml
[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.

```toml title="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](/reference/workflow-file/#groups), [Styles and personas](/styles/).

## Branching

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](#decide) is the one kind whose only job is to choose a route, and it asks a decision model rather than following a rule.

```toml title="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`.

## Related

- [Workflow file](/reference/workflow-file/): every key a workflow and its steps take.
- [Add branches](/workflows/add-branches/): how routes choose the next step.
- [Add gates and waits](/workflows/gates-and-waits/): how a run stops for a person or for time.
- [Reuse a workflow](/workflows/reuse-a-workflow/): build and import a workflow of your own.
