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

# Outer loop over an epic

> An outer loop that delivers an epic's tickets through a sub workflow

In this example you work through all of an epic's sub tickets in one run. The outer loop selects the next unfinished sub ticket, hands it to an imported delivery workflow, and comes back to select again. It stops when the select step reports that nothing remains.

About five minutes to set up. The run can take a long time, one delivery per sub ticket.

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

![select routes a remaining ticket into the imported sub workflow and returns; no remaining ticket ends the run, and a sub ticket the reviewer will not pass ends it failed.](/epic.svg?v=1609c64e01)

## Before you start

You need:

- a signed-in harness that can read your tracker;
- a project whose primary folder holds the code the sub tickets change. The example makes no worktree, so the agent changes files in that folder itself. A plain folder works, except with Codex. See [plain folders and Git](/projects/#plain-folders-and-git);
- the workflow: [download the archive](/epic.zip). It holds `epic.toml` and `imports/ticket.toml`.

## 1. Add the workflow

1. Unpack the archive into your workflow library, `~/.orbital/workflows/`. Keep every relative path, so `imports/ticket.toml` sits beside `epic.toml`.

2. Check it:

   ```sh
   orbital validate ~/.orbital/workflows/epic.toml
   ```

![The workflow editor showing a workflow with an imported sub workflow drawn as one step.](/screenshots/workflow-editor-imports.webp?v=3f00eb3b61)

*The imported sub workflow shows as one step in its parent.*

## 2. Read how it works

The `work` input has the `epic` kind, so Orbital looks the epic up when the run starts and refuses an address it cannot read. The select prompt reads that address as `{{ inputs.work.url }}`.

`select` declares two outputs. `remaining` is a choice of `ticket` or `none`, and its two routes answer both, so the step needs no `next`. `current_work` matters more. The `work` kind accepts a validated work scope rather than free text. A select step that returns a vague description, or a ticket URL that is its own parent, fails validation instead of sending the implementing agent somewhere unhelpful. Any prompt downstream renders that scope with `{{ current_work }}`.

`ticket` is an import step. `import = "imports/ticket.toml"` brings the other file's steps into this workflow as a group, so they run as `ticket.implement` and `ticket.review`. Each terminal of the imported file is an exit named by its id, and the import step's `exits` say where each one goes: `delivered` returns to the select step, and `rejected` goes to `stalled`, which ends the run Failed. An exit without a mapping would end the run with that terminal's outcome.

The `delivered` exit sets `loop_restart = true`. That clears the previous sub ticket's context, the history of its steps and its agent sessions. It keeps the original inputs, the run identity, visit counts and budgets. Without it, the tenth sub ticket would carry nine sub tickets of history into every prompt.

Bounding the inner review loop needs a budget rather than a visit cap. `max_visits` counts every visit to a step for the life of the run, and a restarted loop keeps those counts. So a cap of four on `ticket.implement` would bound the whole epic at four sub tickets rather than four attempts at one of them. The imported file declares `[budgets.sub_ticket]` instead, and three routes leave `review`. A reject spends a round and goes back to `implement`. Once the five rounds are spent, that route is skipped and the next reject route ends at `rejected`. A pass resets the budget, so the next sub ticket starts fresh. The workflow's `max_visits` remains the outer guard on total agent turns.

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

[Download epic.toml](/examples/epic/epic.toml) or [ticket.toml](/examples/epic/imports/ticket.toml) on its own.

Replace the sub workflow with your own delivery workflow. The [ticket workflow](/examples/workflows/ticket/) is the realistic one: it publishes a pull request, watches its state and merges.

## 3. Start the run

Choose **New task** and pick the project that holds the code the sub tickets change. Choose the `epic` workflow, set work to the epic's URL and write the goal.

:::tip
Watch the first two iterations before you leave the run alone.
:::

## What you get

Each iteration selects one sub ticket, implements and reviews it, then returns to the select step with a cleared context. A select step that reports none ends the run Succeeded. When the reviewer keeps rejecting the same sub ticket after five repairs, the budget is spent and the run ends Failed at `stalled`, labelled "A sub ticket could not be delivered".

## Related

- [Reuse a workflow](/workflows/reuse-a-workflow/): how a sub workflow's terminals become exits
- [Add a loop](/workflows/add-a-loop/): the other ways to bound a loop
- [Ticket to merge](/examples/workflows/ticket/): a realistic delivery workflow to import instead
- [Deliver an epic](/examples/deliver-an-epic/): the shipped epic workflow
