Pipelines and triggers
A route map says which agents may talk to each other. A pipeline says how work gets in: which agent receives it, whether a human or a clock starts it, and which flags the run is parameterised by.
[pipelines.development]
description = "Take a request through investigation, development and review to a pull request"
entry = "analyst"
trigger = "manual"
[pipelines.development.flags]
run_e2e = { default = false, description = "Also run the remote end-to-end suite" }
draft_pr = { default = true, description = "Open the pull request as a draft" }
[pipelines.review-bot]
description = "Review my open Azure DevOps pull requests once an hour"
entry = "pr_scanner"
trigger = { every = "1h" }
Pipelines are deliberately thin. They do not describe a sequence of steps, so adding one does not turn the permission mesh into a pipeline engine.
Which routes a pipeline's chains may use
Every chain belongs to the pipeline that started its work — and may use the global routes and the routes scoped to that pipeline, nothing else:
[[routes]]
from = "azurix"
to = "eagle"
mode = "spawn"
pipelines = "eagle-eye" # only Eagle Eye's chains may use this
A pipeline still says nothing about order; a scoped route is a permission within a workflow, not a step in one. The scope lives on the route rather than on the pipeline so that the route map stays the one place that says who may reach whom.
| Work | Belongs to |
|---|---|
A trigger from the dashboard, POST /flights or a schedule | The pipeline triggered |
| A flight an agent sends | Its chain's pipeline |
A chain opened over a mode = "spawn" edge | The spawning chain's pipeline |
| A resumed layover | The resuming pipeline, held to what the booking chain could use — see below |
A flight sent straight to an entry = true agent | No pipeline: global routes only |
Scoping also narrows what validate asks of a pipeline. Reach, hop depth and flag declarations are
checked over each pipeline's own routes, so a pipeline need not declare flags for agents its routes
cannot reach. See Configuration for the syntax
and the rules two overlapping routes must follow.
Triggers
| Form | Meaning |
|---|---|
trigger = "manual" | A human starts it. The default. |
trigger = { every = "1h" } | Fires on a fixed interval: s, m, h, d. |
trigger = { cron = "0 9 * * 1-5" } | Fires on a five-field cron expression, in local time. |
Resuming booked work
[pipelines.follow_up]
description = "Pick up pull requests that asked to be looked at again"
entry = "follower"
trigger = { every = "45m" }
resumes = true
resumes is an optional boolean, false by default. A pipeline with resumes = true does not
start fresh work when it fires: it looks for Layovers that have come due — work a previous
chain deliberately set down to pick up later — and opens one itinerary per Layover, seeded with
what its author was waiting for.
This is how a chain follows something up days later without anything being kept alive in between. The publisher opens a pull request, books a Layover for "when there are comments", and exits; the resuming pipeline is what brings that work back. See the tools an agent has.
A resuming pipeline that finds nothing due does nothing, which is the ordinary case — and that is what makes checking every forty-five minutes affordable.
A layover is picked up at the first tick of a resuming pipeline after it comes due, not the moment it does: one due at 11:54 behind a 45-minute schedule that ticks at 11:24 and 12:09 is picked up at 12:09. The dashboard's Upcoming tab shows both times for every layover waiting.
Resumed work goes back to the agent that booked it, not to the pipeline's entry. A layover
records which agent set it down, and sending a follow-up to whatever happens to be a pipeline's
entry point would hand the publisher's pull request to the analyst. entry is still required by
the schema and is unused by a resuming pipeline; it may declare flags like any other, and it must
declare every flag the resumed agents' prompts test. The values come from the chain that booked
the layover — see a flag holds for the whole chain.
Only a resuming pipeline collects them. An ordinary schedule never picks up booked work, so a factory's hourly sweep cannot quietly start following up somebody else's.
A resumed chain is held to what its booking chain could reach. It belongs to the resuming pipeline — its flags, its joins, its place on the dashboard — but may use a route only when the pipeline whose chain set the work down permits it too. A resuming pipeline collects every layover that comes due, whoever booked it, so without this a review sweep could reach the build workflow's agents by setting its work down and waiting for the follow-up to wake it. When a follow-up resumes its own workflow's work, both pipelines permit the same routes and nothing changes.
Running several instances at once
One pipeline, many instances — one per pull request, say. Each trigger mints its own itinerary with its own Hops, Fuel, barriers and flags, so instances are already independent in every respect but one: the workspace.
[pipelines.development]
entry = "analyst"
workspace = "per-itinerary"
| Value | Meaning |
|---|---|
shared | Every itinerary works in the one work_dir. The default. |
per-itinerary | Meant to give each itinerary its own git worktree, named after the itinerary. Declared, not yet enforced. |
per-itinerarydoes nothing yet. It is accepted, andlayover explainsays beside the pipeline that it is not in force: every itinerary still works in the sharedwork_dir. What an implementation has to settle first — which commit a worktree starts from, what happens to a developer's uncommitted change, when a worktree is removed, and what to do whenwork_diris not a git repository — is recorded as an open question indecisions.md.
Two instances that both reach a read-write agent therefore edit the same files at the same time,
whatever workspace says. That fails in the way hardest to notice — plausible output built from
two unrelated changes. Until isolation exists, the protection is to not run two at once: leave a
schedule on the default overlap = "skip", and do not trigger a second instance of a writing
pipeline by hand while one is still going.
layover validate warns when a pipeline sets overlap = "allow" and reaches a writer, because two
instances will then edit the same files with nobody watching — and it no longer stays quiet because
the pipeline also says per-itinerary. A pipeline left on the default cannot reach that state, so
nothing is said about it.
Setting both every and cron is an error rather than a silent choice between them.
The one-minute floor
A schedule may not fire more often than once a minute. Every firing is a real, paid CLI invocation, and a schedule runs with nobody watching. Six-field cron expressions — the ones with a seconds column — are refused for the same reason: a seconds field can schedule work faster than a run can finish, which is a fork bomb with a clock attached.
Overlapping ticks
When a tick comes round before the last one finished
By default the tick is skipped. Starting a second copy means paying twice for one result and, on a shared workspace, two agents editing the same files. Skipping means being one interval late. For unattended spending those are not comparable.
The last wave is still going while any flight of it is queued or any run of it is alive — including a chain it spawned — so an hour-long run holds its schedule for the hour. Other pipelines' schedules are not held: the clock fires on time while runs are going.
[pipelines.review-bot]
entry = "reviewer"
trigger = { every = "5m" }
overlap = "allow" # start another anyway
| Value | Meaning |
|---|---|
skip | Miss this firing, wait for the next. The default. |
allow | Start a second instance regardless. |
Every skip is reported, because a schedule quietly skipping every tick because its work always
overruns looks exactly like a schedule that is running fine — and the difference is that nothing
is happening. The Tower says so on its console and writes it to
.layover/journal/skips-<day>.jsonl, and the dashboard's Upcoming tab lists the last seven
days of them, counts them per workflow, and marks the next tick of a workflow that is still
working. See the dashboard.
overlap = "allow" is the right answer when instances genuinely cannot interfere: agents that
only read, and — once it is enforced — a per-itinerary workspace. layover validate warns when
you set it and a writer is reachable.
layover validate also warns when an interval is shorter than timeout_sec:
warning: pipeline `review-bot` fires every 300s but a single run may take 1800s;
most ticks will be skipped
A cron expression has no single interval to compare against, so that check stays silent rather than guessing. Sizing a cron schedule is on you.
What the clock does across a restart
Nothing fires at startup. A Tower restarting is not a reason to run every hourly job at once; if it were, restarting would be expensive enough to avoid.
Next firings are computed from the clock, not from when the last run finished — otherwise the period drifts by however long the work took, and an hourly job slowly becomes a ninety-minute one. A Tower that was asleep for six hours fires once on waking rather than six times in a row.
An every schedule counts from when the Tower started, so a restart moves it: an hourly sweep
started at 09:20 fires at 10:20, 11:20 and so on. When each schedule next fires, by the clock the
Tower is actually keeping, is on the dashboard's Upcoming tab and beside the trigger on the
route map.
Flags
A flag is a boolean a pipeline accepts at trigger time and a prompt can test:
[pipelines.development.flags]
run_e2e = { default = false, description = "Also run the remote end-to-end suite" }
layover prompt tester --pipeline development --flag run_e2e=true
Rules worth knowing:
- A flag name must be an identifier. It has to survive being written inside
@include(...). - Setting an undeclared flag is an error, not a no-op. A typo at trigger time would otherwise change nothing while appearing to work.
- Two pipelines may declare the same flag, but not with different defaults. Prompts are shared
between pipelines, so the same
@include(run_e2e)line is read by every pipeline that reaches that agent. Disagreeing defaults make it mean different things depending on which trigger fired.layover validatewarns.
A flag holds for the whole chain
The value chosen when work is triggered — POST /flights with "flags": {"run_e2e": true}, or the
dashboard's trigger dialog — is the value every run caused by that trigger is composed with,
not only the first:
| Work | Composed with |
|---|---|
| The run the trigger wakes | The flags the trigger chose, defaults for the rest |
| A flight an agent sends on | The same flags as the run that sent it |
A chain opened over a mode = "spawn" edge | The same flags, and the same pipeline, as the chain that spawned it |
| A resumed layover | The booking chain's values, for every flag the resuming pipeline declares; its defaults for the rest |
| A scheduled tick | The pipeline's defaults — a clock chooses nothing |
A flight to a bare entry = true agent | Every flag any pipeline declares, at the default of the first pipeline to declare it |
The flags travel with the queued work rather than living only in the Tower's memory, so a chain waiting in the queue when the Tower restarts keeps them. An agent never supplies its own: they come from the Tower's record of the run, like its identity, because an agent that could turn a flag on could turn on the section of its instructions that lets it publish.
A spawned chain also counts towards the pipeline that spawned it — its runs and their cost appear under that workflow, and it may use that workflow's scoped routes — because a reviewer spawned by a sweep is unarguably part of the sweep.
layover prompt <agent> --pipeline <name> --flag … renders what a run triggered that way receives,
and the last row is what it renders without --pipeline.
Entry points
An agent is an entry point when a pipeline names it, or when it is marked entry = true.
These are different things. entry = true is a bare permission — useful for an agent you want to
poke by hand. A pipeline is a named trigger that also carries a schedule and flags, and it is the
normal way in.
A flight sent straight to an entry = true agent belongs to no pipeline, so it may use global
routes only. validate warns when every route out of such an agent is scoped, because triggered
that way it could send nothing.
A factory with neither cannot be triggered at all, which is an error.
Sizing the rails
This is the part most likely to be got wrong, because nothing computes it for you.
A chain carries at most max_hops flights: the trigger is flight 1, and each send spends one hop.
For a factory with a loop, count the loop:
flights = lead_in + 2N + 1
where lead_in is the flights spent before the looping agent's first run and N is the number of
times the loop turns. For the reference factory that is 2N + 6, so eight review cycles need
max_hops = 22 — against a default of 8.
layover validate warns when an agent sits further from an entry point than max_hops can reach,
and when a join = "all" barrier has an upstream that could never afford the flight into it:
warning: agent `target` waits for every upstream, but `c` could only deliver on flight 4 and
`max_hops` is 3; the barrier can never release and the itinerary would stall
That second check matters because plain reachability misses it. A joined agent looks close if any upstream is close, but it does not wake until the last one arrives.
Neither check will catch an undersized loop budget, because both measure shortest paths and no static check can know how many times a loop will turn.
Getting it wrong is not a clean failure. Hops running out mid-repair leaves half-finished work in the shared workspace and no run alive to clean it up.