Skip to content
Orbital

Reuse a workflow

Move a shared sequence of steps into its own workflow file and import it

When several workflows share a sequence of steps, move the sequence into its own workflow file and import it. One step with an import key stands in for the whole file.

epic.toml
[steps.ticket]
import = "imports/ticket.toml"
exits.delivered = { to = "steps.select", loop_restart = true }
exits.rejected = "steps.stalled"

An imported workflow is an ordinary workflow file that another workflow imports. It has its own version, entry and terminals, so you can validate and run it on its own before any parent depends on it. It has to be a complete workflow, not a fragment.

The import step becomes a group that holds the imported steps, so the editor, the validator and the run all see one workflow.

  • Imported step IDs take the import step’s ID as a prefix, so ticket.review is the imported review step, and its final prose is context.response.ticket.review.
  • A route to the import step reaches the imported entry.
  • Every terminal of the imported file is an exit, named by its ID. exits.<name> on the import step says where the run goes when it leaves through that exit: a step, or { to = "steps.select", loop_restart = true } when arriving there starts a new pass. An exit you leave unmapped ends the run with that terminal’s outcome. A file with one exit still needs it named, and an imported terminal may not be aborted.
  • Outputs and facts keep their names across the boundary, because the parent and the imported steps share the run’s context. That is what lets a parent condition read a value the imported workflow produced.

Routes into a terminal inside the imported file decide why the run leaves it, and the parent only says where it goes. So conditions and budgets belong inside the imported file, and the import step carries only its exits. Budgets are shared by name across the parent and every file it imports.

Paths in import and prompt_file resolve from the file that names them. A cycle of imports is refused. The import reference gives the rest of the rules.

An imported workflow that declares inputs gets them from the import step. inputs.<name> on the import step is a template that Orbital renders each time an imported step reads that input, so it may read the parent’s inputs and the run’s context. Every required input of the imported file must be passed, and an input it does not declare is refused. An optional input left out reads as its default, or empty. An import step that passes no inputs at all leaves its steps reading the parent’s inputs, as an import converted from a DOT file does.

repeat.toml
[steps.deliver]
import = "imports/ship-ticket.toml"
inputs.ticket = "{{ context.ticket }}"
exits.shipped = "steps.shipped"
exits.rejected = "steps.stuck"

Here an earlier step hands over a ticket output, and the imported workflow reads it as its own ticket input, {{ inputs.ticket }}. Orbital looks up only the inputs a run starts with, so the imported steps read the reference, not the ticket’s fields. A looked-up input passed on unchanged, as inputs.ticket = "{{ inputs.ticket }}", keeps its fields. Repeat a workflow walks through that example.

An import step is a group for the steps it brings in, so it can carry a label and a style. The style applies to every imported step that does not set the same thing itself, through its own style or through a group inside the imported file. The imported file’s own styles join the parent’s, so its steps still find the styles they name. Give steps a style explains the order.

An import step cannot change the prompts of the steps inside. Put shared configuration on the import step or in the parent workflow.

You need the epic example, unpacked into ~/.orbital/workflows/ with every relative path intact. It holds the parent and the imported workflow shown below.

This one implements and reviews a single piece of work. Its two terminals, delivered and rejected, become the exits the parent maps. Its own sub_ticket budget bounds the review loop inside it.

imports/ticket.toml
version = 3
entry = "steps.implement"
description = "Implement and locally review one sub ticket"
[budgets.sub_ticket]
rounds = 5
[steps.implement]
prompt = "Implement the current work described below. Satisfy only its requirements. {{ current_work }}"
outputs.implementation_summary = "text"
next = "steps.review"
[steps.review]
prompt = "Review the implementation against the current work described below. Choose pass when it satisfies the work, or reject when it does not. {{ current_work }}"
outputs.verdict = { kind = "choice", options = ["pass", "reject"] }
routes = [
{ if = "verdict == 'pass'", to = "steps.delivered", resets = "budgets.sub_ticket" },
{ if = "verdict == 'reject'", to = "steps.implement", spends = "budgets.sub_ticket" },
{ if = "verdict == 'reject'", to = "steps.rejected" },
]
[steps.delivered]
kind = "terminal"
[steps.rejected]
kind = "terminal"
outcome = "failed"
label = "Five reviews rejected the same sub ticket"

It reads {{ current_work }} rather than a declared input, so the parent decides what one piece of work is. Any parent that sets current work can use it.

Terminal window
orbital validate ~/.orbital/workflows/imports/ticket.toml

[steps.ticket] with import = "imports/ticket.toml" is the import step. The path is relative to the parent, because relative paths resolve from the file that names them.

epic.toml
version = 3
entry = "steps.select"
description = "Work through an epic one sub ticket at a time"
max_visits = 200
[inputs.work]
kind = "epic"
label = "Epic"
hint = "The epic whose sub tickets this run delivers"
[steps.select]
prompt = """
Read the epic at {{ inputs.work.url }} and every sub ticket under it.
Choose the next unfinished sub ticket and report it as the current work.
Choose ticket while one remains, or none once every sub ticket is finished.
"""
outputs.current_work = "work"
outputs.remaining = { kind = "choice", options = ["ticket", "none"] }
routes = [
{ if = "remaining == 'ticket'", to = "steps.ticket" },
{ if = "remaining == 'none'", to = "steps.finished" },
]
[steps.ticket]
import = "imports/ticket.toml"
exits.delivered = { to = "steps.select", loop_restart = true }
exits.rejected = "steps.stalled"
[steps.finished]
kind = "terminal"
[steps.stalled]
kind = "terminal"
outcome = "failed"
label = "A sub ticket could not be delivered"

Each exits key names the exit it maps. Here delivered returns to the select step with a loop restart, so the next sub ticket starts fresh, and rejected goes to the stalled terminal.

Terminal window
orbital validate ~/.orbital/workflows/epic.toml

The imported steps sit inside the import step’s group, so the workflow contains ticket.implement and ticket.review.

The editor draws the import step as one step, with each exit on its own arrow. Choose Expand imports to see a flat, read-only view with each import expanded, and Show authored workflow to return.

The workflow editor shows an import step named landing, with two outgoing arrows labelled exit closed and exit merged, and an Expand imports button above the canvas.
The import step maps each exit on its own arrow.

Include every imported file and prompt file, and keep their relative paths. The epic example shows the layout. The ticket workflow is a parent with two imported workflows and twelve prompt files, shipped as one archive.

If someone pastes the parent into Import on the Workflows page instead, the prompt files it refers to become empty prompts to fill in.

The Import a workflow page has one large text box with Import and Cancel buttons below it.
Pasted TOML is not stored until you save.