Skip to main content
Formulas describe what work looks like. Orders describe when it should happen. An order pairs a trigger condition with an action — either a formula or a shell script — and the controller checks those triggers automatically. When a trigger opens, the order fires. No human dispatch needed. When you run gc start, you launch a controller — a background process that wakes up every 30 seconds (a tick), checks the state of the city, and takes action. One of the things it does on each tick is evaluate the triggers that unblock an order from running. That periodic check is what makes orders work. We’ll pick up where Tutorial 06 left off. You should have my-city running with agents and formulas configured. If you’ve been dispatching formulas by hand with gc sling, orders are the next step: they turn that manual dispatch into something the city does on its own, on a schedule or in response to events.

A simple order

Orders live in an orders/ directory at the top level of your city, alongside formulas/ and agents/. Each order is a flat *.toml file in that directory.
orders/
  pancakes-check.toml
  dep-update.toml
formulas/
  pancakes.toml
Here’s a minimal order that dispatches the pancakes formula from Tutorial 05 every five minutes:
# orders/pancakes-check.toml
[order]
description = "Cook pancakes on a timer"
formula = "pancakes"
trigger = "cooldown"
interval = "5m"
pool = "worker"
The pool field tells the controller where to send the work. A pool is a named group of one or more agents that share a work queue — the agents chapter introduced them briefly. When an order fires, the controller creates a wisp from the formula and routes it to the named pool. Any agent in that pool can pick it up. The controller evaluates trigger conditions on every tick. When five minutes have passed since the last run, it instantiates the pancakes formula as a wisp and routes it to the worker pool. The order name comes from the file basename (pancakes-check.tomlpancakes-check), not from anything in the TOML. When the dispatcher stamps the wisp for a pool target it writes two metadata keys: gc.routed_to=<pool> so the worker’s bd ready query finds it via the shared routed queue, and gc.pool_demand=order so the supervisor’s default scale_check counts the wisp as pool demand even though the wisp itself is a molecule (which bd ready filters out as a workflow container). Both keys come from the same dispatcher write and you don’t need to set them by hand; wisps that pre-date this dispatcher version won’t carry the second key and won’t generate demand from the in-process default scale_check until they close or are re-created. Orders are discovered when the city starts and whenever the controller reloads config. You don’t need to restart anything if the city is already watching the orders directory.

Inspecting orders

Once you’ve defined some orders, you’ll want to see what the controller sees — which orders exist, what their triggers look like, and whether any are due. Three commands give you that view. gc order list shows every enabled order in your city — whether or not it has ever fired:
~/my-city
$ gc order list
NAME            TYPE     TRIGGER   INTERVAL/SCHED  TARGET
pancakes-check  formula  cooldown  5m              worker
dep-update      formula  cooldown  1h              worker
release-notes   formula  cooldown  24h             worker
Your output will also include a handful of built-in mol-* orders that ship with the tutorial template (beads-health, gate-sweep, mol-dog-jsonl, mol-dog-reaper, orphan-sweep, prune-branches, spawn-storm-detect, wisp-compact, etc.). They’re the city’s housekeeping orders — you can leave them alone. The TARGET column is the pool the order will route to (the field is still pool in the TOML). To see the full definition:
~/my-city
$ gc order show pancakes-check
Order:  pancakes-check
Description: Cook pancakes on a timer
Formula:     pancakes
Trigger:     cooldown
Interval:    5m
Target:      worker
Source:      /Users/you/my-city/orders/pancakes-check.toml
To check which orders are due right now:
~/my-city
$ gc order check
NAME            TRIGGER   DUE  REASON
pancakes-check  cooldown  yes  never run
dep-update      cooldown  no   cooldown: 14m remaining
release-notes   cooldown  no   cooldown: 18h remaining

Running an order manually

Any order can be triggered by hand, bypassing its trigger:
~/my-city
$ gc order run pancakes-check
Order "pancakes-check" executed: wisp mc-2xz gc.routed_to=worker
For exec orders, the output is simpler — Order "<name>" executed (exec). This is useful for testing a new order or for kicking off work that’s almost due anyway.

Trigger types

The trigger is what makes an order tick. It controls when the order fires. There are five trigger types.

Cooldown

The most common trigger. The name comes from the idea of a cooldown timer — after the order fires, it has to cool down for a set interval before it can fire again:
[order]
description = "Check for stale feature branches"
formula = "stale-branches"
trigger = "cooldown"
interval = "5m"
pool = "worker"
If the order has never run, it fires immediately on the first tick. After that, it waits until interval has elapsed since the last run. The interval is a Go duration string — 30s, 5m, 1h, 24h.

Cron

Fires on an absolute schedule, like Unix cron job:
[order]
description = "Generate release notes from yesterday's merges"
formula = "release-notes"
trigger = "cron"
schedule = "0 3 * * *"
pool = "worker"
The schedule is a 5-field cron expression: minute, hour, day-of-month, month, day-of-week. This example fires at 3:00 AM every day. Fields support * (any), exact integers, and comma-separated values (1,15 for the 1st and 15th). The difference from cooldown: a cooldown fires relative to the last run (“every 5 minutes”), while cron fires at absolute times (“at 3 AM daily”). Cooldown drifts — if the last run was at 3:02, the next is at 3:07. Cron hits the same wall-clock times every day. Cron triggers fire at most once per minute — if the order already ran during the current minute, it waits for the next match.

Condition

Fires when a shell command exits 0:
[order]
description = "Deploy when the flag file appears"
formula = "deploy"
trigger = "condition"
check = "test -f /tmp/deploy-flag"
pool = "worker"
The controller runs sh -c "<check>" with a 10-second timeout on each tick. If the command exits 0, the order fires. Any other exit code, and it doesn’t. This is the trigger for dynamic, external triggers — check a file, ping an endpoint, query a database. One caveat: the check runs synchronously during trigger evaluation. A slow check delays evaluation of subsequent orders on that tick. Keep checks fast.

Event

Fires in response to system events:
[order]
description = "Check if all PR reviews are done and merge is ready"
formula = "merge-ready"
trigger = "event"
on = "bead.closed"
pool = "worker"
This fires whenever a bead.closed event appears on the event bus. Event triggers use cursor-based tracking — each firing advances a sequence marker so the same event isn’t processed twice.

Manual

Never auto-fires. Only triggered by gc order run:
[order]
description = "Full test suite — expensive, run only when needed"
formula = "full-test-suite"
trigger = "manual"
pool = "worker"
Manual orders don’t appear in gc order check (there’s nothing to check — they’re never due automatically). They do appear in gc order list.

Formula orders vs. exec orders

So far every example has used a formula as the action. But orders can also run shell scripts directly:
[order]
description = "Delete branches already merged to main"
trigger = "cooldown"
interval = "5m"
exec = "scripts/prune-merged.sh"
An exec order runs the script on the controller — no agent, no LLM, no wisp. This is the right choice for purely mechanical operations: pruning branches, running linters, checking disk usage, anything where involving an agent would be wasteful. The rules:
  • Every order has either formula or exec, never both.
  • Exec orders can’t have a pool — there’s no agent pipeline to route to.
  • The script receives ORDER_DIR in its environment, set to the directory containing the order file. Pack-sourced orders also get PACK_DIR.
Default timeouts differ: 30 seconds for formula orders, 300 seconds for exec orders.

Timeouts

Each order can set a timeout:
[order]
description = "Run the linter on changed files"
formula = "lint-check"
trigger = "cooldown"
interval = "30s"
pool = "worker"
timeout = "60s"
For formula orders, the timeout covers the initial dispatch — compiling the formula, creating the wisp, and routing it to the pool. Once the wisp is created and handed off, the agent works on it at its own pace; the timeout doesn’t kill an agent mid-work. For exec orders, the timeout covers the full script execution — if the script is still running when time is up, the process is killed. You can also set a global cap in city.toml:
[orders]
max_timeout = "120s"
The effective timeout is the lesser of the per-order timeout and the global cap.

Order scope

When a pack is imported into more than one rig, its orders are instantiated once per rig by default — the same way per-rig agents are. That’s usually what you want for an order that acts on a single rig’s work. But some orders are city-wide: a sweep or health probe that already iterates over every rig internally would then run redundantly, once per rig. Mark such an order city-scoped so it registers exactly once, no matter how many rigs import the pack:
[order]
description = "Sweep merged convoys across the whole city"
trigger = "cooldown"
interval = "5m"
exec = "scripts/convoy-sweep.sh"
scope = "city"
scope accepts "city" or "rig". The default (when omitted) is "rig", so existing orders are unaffected. A scope = "city" order appears once in gc order list with no rig qualifier; rig-scoped orders appear once per importing rig. This mirrors the scope field on [[named_session]]. Use scope = "city" for orders that live in a shared pack imported by several rigs — that’s where the per-rig duplication it collapses comes from. A city-scoped order keeps the formula layer of the pack it was scanned from, so its formula must resolve from that pack rather than from any one rig’s local orders/ directory.

Disabling and skipping orders

An order can be disabled in its own definition:
[order]
description = "Temporarily disabled"
formula = "nightly-bench"
trigger = "cooldown"
interval = "1m"
pool = "worker"
enabled = false
Disabled orders are excluded from scanning entirely — they don’t appear in gc order list or get evaluated. You can also skip orders by name in city.toml without editing the order file:
[orders]
skip = ["nightly-bench", "experimental-check"]
This is useful when a pack provides orders you don’t want running in your city.

Overrides

Sometimes a pack’s order is almost right but you need to tweak the interval or change the pool. Rather than copying and modifying the order file, use overrides in city.toml:
[[orders.overrides]]
name = "test-suite"
interval = "1m"

[[orders.overrides]]
name = "release-notes"
pool = "mayor"
schedule = "0 6 * * *"
Overrides can change enabled, trigger, interval, schedule, check, on, pool, and timeout. The override matches by order name. An override that targets a nonexistent order produces an error rather than silently no-opping — gc order CLI commands fail; gc start logs the error and continues running with the unmatched override skipped.

Rig scoping

Many orders expand at scan time into one instance per rig (anything in a rig’s orders/ directory or a pack imported into a rig). When the same order appears city-wide AND per-rig, an override must say which:
# Targets ONLY the city-level instance. Per-rig copies are unaffected.
[[orders.overrides]]
name = "patrol"
enabled = false

# Targets ONLY the demo-repo rig's copy.
[[orders.overrides]]
name = "patrol"
rig = "demo-repo"
enabled = false

# Wildcard: targets every instance — city-level + all rig copies.
[[orders.overrides]]
name = "patrol"
rig = "*"
enabled = false
A rigless override against a name that exists ONLY as per-rig copies is an error; the message names the rigs so you know what to type. The literal "*" is reserved as the wildcard token and may not be used as a real rig name.

Order history

Every time an order fires, Gas City creates a tracking bead labeled with the order name. You can query the history:
~/my-city
$ gc order history
ORDER           BEAD     EXECUTED
pancakes-check  mc-3hb   2026-04-08T07:36:36Z
dep-update      mc-784   2026-04-08T06:48:12Z
pancakes-check  mc-zbd   2026-04-08T07:31:22Z
release-notes   mc-zb8   2026-04-07T13:00:01Z

~/my-city
$ gc order history pancakes-check
ORDER           BEAD     EXECUTED
pancakes-check  mc-3hb   2026-04-08T07:36:36Z
pancakes-check  mc-zbd   2026-04-08T07:31:22Z
pancakes-check  mc-9p8   2026-04-08T07:26:18Z
The tracking bead is created synchronously before the dispatch goroutine launches. This is what prevents the cooldown trigger from re-firing on the very next tick — the trigger checks for recent tracking beads when deciding if the order is due.

Duplicate prevention

Before dispatching, the controller checks whether the order already has open (non-closed) work. If it does, the order is skipped even if the trigger says it’s due. This prevents pileup — if an agent is still working through the last pancakes run, the controller won’t dispatch another one.

Rig-scoped orders

Orders don’t just live at the city level. When a pack is applied to a rig, that pack’s orders come along and run scoped to that rig. Say you have a pack called dev-ops that includes a test-suite order:
packs/dev-ops/
  orders/
    test-suite.toml         # trigger = "cooldown", interval = "5m", pool = "worker"
  formulas/
    test-suite.toml
And your city applies that pack to two rigs:
# city.toml
[[rigs]]
name = "my-api"

[rigs.imports.dev_ops]
source = "./packs/dev-ops"

[[rigs]]
name = "my-frontend"

[rigs.imports.dev_ops]
source = "./packs/dev-ops"
# .gc/site.toml
[[rig]]
name = "my-api"
path = "../my-api"

[[rig]]
name = "my-frontend"
path = "../my-frontend"
Now the city has the same order running independently for each rig:
~/my-city
$ gc order list
NAME        TYPE     TRIGGER      INTERVAL/SCHED  TARGET
test-suite  formula  cooldown  5m              worker
test-suite  formula  cooldown  5m              my-api/worker
test-suite  formula  cooldown  5m              my-frontend/worker
Three identical names, three different targets — the rig that owns each one is encoded in the qualified target name (my-api/worker vs my-frontend/worker). To act on a specific one, pass --rig:
$ gc order show test-suite --rig my-api
$ gc order run test-suite --rig my-api
These are three independent orders. The city-level test-suite has its own cooldown timer, its own tracking beads, its own history. The my-api version tracks separately — if the city-level order fired two minutes ago, that doesn’t affect whether the my-api order is due. Internally, Gas City distinguishes them by scoped name: test-suite vs test-suite:rig:my-api vs test-suite:rig:my-frontend. Pool targets are auto-qualified: pool = "worker" in the order definition becomes gc.routed_to=my-api/worker on the dispatched wisp, routing work to the rig’s own agents rather than the city-level pool.

Order layering

With orders coming from packs, rigs, and your city’s own orders/ directory, the same order name can exist in multiple places. When that happens, the highest-priority layer wins. The layers, from lowest to highest priority:
  1. City packs — orders that ship with a pack you’ve included (e.g., the dev-ops pack’s test-suite)
  2. City local — orders in your city’s own orders/ directory
  3. Rig packs — orders from packs applied to a specific rig
  4. Rig local — orders in a rig’s own orders/ directory
A higher layer completely replaces a lower layer’s definition for the same order name. So if the dev-ops pack defines test-suite with a 5-minute cooldown and you create your own orders/test-suite.toml with a 1-minute cooldown, yours wins — the pack version is ignored entirely.

Putting it together

Here’s a city with two orders: a frequent lint check (exec, no agent needed) and weekly release notes (formula, dispatched to an agent). Assume you’ve already created a worker agent as in Tutorial 05. The remaining pieces are just the order files and the formula they dispatch.
# orders/lint-check.toml
[order]
description = "Run the linter on changed files"
trigger = "cooldown"
interval = "30s"
exec = "scripts/lint-changed.sh"
timeout = "60s"
# orders/release-notes.toml
[order]
description = "Generate release notes from the week's merges"
formula = "release-notes"
trigger = "cron"
schedule = "0 9 * * 1"
pool = "worker"
# formulas/release-notes.toml
formula = "release-notes"

[[steps]]
id = "gather"
title = "Gather merged PRs from the last week"

[[steps]]
id = "summarize"
title = "Write release notes"
needs = ["gather"]

[[steps]]
id = "post"
title = "Post release notes to the team channel"
needs = ["summarize"]
~/my-city
$ gc start
City 'my-city' started

~/my-city
$ gc order list
NAME           TYPE     TRIGGER      INTERVAL/SCHED  TARGET
lint-check     exec     cooldown  30s             -
release-notes  formula  cron      0 9 * * 1       worker

~/my-city
$ gc order check
NAME           TRIGGER      DUE  REASON
lint-check     cooldown  yes  never run
release-notes  cron      no   next fire in 3d 14h
The lint check fires immediately (never run + cooldown trigger = due), then every 30 seconds after that. The release notes fire Monday at 9 AM, dispatching a three-step formula wisp to the worker pool. Neither requires anyone to type gc sling. Orders are formulas and scripts on autopilot, gated by time, schedule, conditions, or events, evaluated by the controller on every tick.
Last modified on June 2, 2026