Now available as a repo: https://github.com/thinkjones/gascity-cookbook
A worked example of a multi-city, multi-provider Gas City setup: one config monorepo that runs several "cities" (agent deployments), each on a different model provider, sharing one reusable pack of config.
The trick that makes it maintainable is a tier abstraction: your agents and workflows
only ever refer to abstract tiers — fast, solid, deep — and each city binds those
tiers to a concrete model family (Gemini/Antigravity on one city, Claude on another). The
same config is portable; only the binding changes per city.
Names in this guide are illustrative. The real concepts map 1:1 to a Gas City repo.
| Piece | What it is |
|---|---|
| Pack | A reusable directory of config (pack.toml + definitions). Meant to be imported. |
| City | A deployment: a city.toml (workspace provider + rigs) plus a pack.toml (its imports). The city is the root pack. |
| Rig | A project/repo attached to a city, where work actually happens. |
| Provider | A coding-agent CLI + model (claude, agy, …), declared under [providers.<name>]. |
| Agent / Role | A worker (or coordinator) with a prompt. Formulas route work to roles by name. |
| Formula | A workflow (e.g. build-basic) whose steps route to roles. |
Composition: at load time a city's pack.toml imports the shared pack + remote role
packs, then city.toml picks the workspace provider and rigs. No single file holds the
final config — trace it with gc config explain.
gascity-config/
├── AGENTS.md # agent-facing router (also read by non-Claude CLIs)
├── README.md # human overview
├── docs/ # durable guides (this file lives here)
│
├── packs/ # reusable packs (imported by cities)
│ └── core-pack/
│ ├── pack.toml # provider family concretes, imports, mayor named_session
│ ├── model-tiers.base.toml # role -> tier map (DATA; single source of truth)
│ ├── agents/
│ │ └── mayor/
│ │ ├── agent.toml # provider, idle_timeout, wake_mode, append_fragments
│ │ └── prompt.template.md
│ ├── template-fragments/
│ │ ├── routing.template.md # {{ define "efficient-routing-rules" }}
│ │ └── mayor-rhythm.template.md # {{ define "mayor-operating-rhythm" }}
│ ├── commands/ # `gc cc <name>` custom CLI commands
│ │ ├── gen-model-tiers/{command.toml, run.sh}
│ │ └── clear-human-mail/{command.toml, run.sh}
│ ├── orders/ # scheduled/event-driven dispatch
│ │ └── mayor-hourly-sweep.toml
│ └── assets/scripts/ # helper scripts referenced by orders/commands
│
└── cities/ # runtime deployments
├── river-city/ # primary provider: Antigravity (Gemini)
│ ├── city.toml # workspace provider, tier aliases, rigs, include=[...]
│ ├── pack.toml # imports core-pack + remote role pack
│ └── model-tiers.toml # GENERATED per-rig tier patches (included)
└── mesa-city/ # primary provider: Claude
├── city.toml
├── pack.toml
└── model-tiers.toml # GENERATED
Each pack stays clean (only its own config); cross-cutting planning lives at the repo root
under docs/.
This is the core idea. Abstract tier names are the stable interface; the concrete model is a swappable implementation.
packs/core-pack/pack.toml:
[pack]
name = "core-pack"
schema = 2
[providers]
# --- family concretes: the ONE place model IDs live ---
[providers.antigravity-fast] base = "builtin:antigravity" args = ["--model", "Gemini 3.5 Flash (Low)"]
[providers.antigravity-solid] base = "builtin:antigravity" args = ["--model", "Gemini 3.1 Pro (Low)"]
[providers.antigravity-deep] base = "builtin:antigravity" args = ["--model", "Gemini 3.1 Pro (High)"]
[providers.claude-fast] base = "builtin:claude" option_defaults = { model = "haiku" }
[providers.claude-solid] base = "builtin:claude" option_defaults = { model = "sonnet" }
[providers.claude-deep] base = "builtin:claude" option_defaults = { model = "opus" }A provider's base can point at another provider, not just a builtin. So each city binds
the three generic tier names to a family. City-level providers override imported ones
("pack is base, city wins").
cities/river-city/city.toml (Antigravity):
[workspace]
provider = "antigravity" # the default for anything without its own provider
[providers.fast] { base = "antigravity-fast" }
[providers.solid] { base = "antigravity-solid" }
[providers.deep] { base = "antigravity-deep" }cities/mesa-city/city.toml (Claude):
[workspace]
provider = "claude"
[providers.fast] { base = "claude-fast" }
[providers.solid] { base = "claude-solid" }
[providers.deep] { base = "claude-deep" }Now fast / solid / deep mean Gemini Flash Low / Pro Low / Pro High in river-city,
and Haiku / Sonnet / Opus in mesa-city. Everything downstream (agents, routing, formulas)
references only the generic names — so it's identical across cities.
Portability note: a provider's
command/path_checkare not variable-expanded — use a bare name on PATH (command = "kimi"), not$KIMI_PATHor an absolute path. Gas City broadens PATH to include~/.local/bin, Homebrew, cargo, nvm/asdf, etc., so a bare name resolves on every machine. Env-block values (secrets) do expand$VAR.
A formula like build-basic routes each step to a role (requirements-planner,
implementation-worker, publisher, …). The model a step runs on is that role agent's
provider. So "use the right model per step" = "set each role's provider to a tier".
Roles are rig-scoped (one copy per rig), so this would be N roles × M rigs of nearly
identical [[patches.agent]] blocks. Instead: keep the data + generator in the shared pack
and generate the expansion per city.
Data — packs/core-pack/model-tiers.base.toml:
[tiers]
requirements-planner = "deep"
design-author = "deep"
task-decomposer = "deep"
implementation-worker = "solid"
implementation-reviewer = "solid"
run-operator = "fast"
publisher = "fast"Generator — a shared command that reads the base map, enumerates the current city's
project rigs, and emits [[patches.agent]] blocks:
cd cities/river-city
gc cc gen-model-tiers > model-tiers.toml # cc = the core-pack import bindingProduces (one block per rig × role):
# GENERATED — do not edit by hand
[[patches.agent]]
dir = "checkout-service" # the rig
name = "requirements-planner" # UNQUALIFIED role name (no binding/rig prefix)
provider = "deep"
[[patches.agent]]
dir = "marketing-site"
name = "requirements-planner"
provider = "deep"
# ...etcWire it in — cities/river-city/city.toml (top-level, before any table):
include = ["model-tiers.toml"]Retune tiers once in model-tiers.base.toml, regenerate each city. Add a rig → regenerate;
it's picked up automatically. Same base map drives every city — Claude cities resolve deep
to Opus, Antigravity cities to Pro (High).
Patch-targeting gotcha: a patch matches the agent's stored fields —
nameis the unqualified role (run-operator, notgc.run-operator) anddiris the rig. Thebinding./rig/prefixes only appear in display names (gc rig list), never in patches.
build-basic is a formula. Each step carries gc.run_target metadata naming a role:
formula step ──gc.run_target──▶ role agent ──provider──▶ model tier ──resolves to──▶ concrete model
"requirements" requirements-planner deep Opus / Gemini Pro High
"implement" implementation-worker solid Sonnet / Gemini Pro Low
"publish" publisher fast Haiku / Gemini Flash Low
The controller sees an unassigned workflow bead tagged gc.run_target=<role>, spawns that
role's on-demand pool session, which claims the bead and runs on its provider. The
formula never mentions a model — the model is 100% the target role's provider. That's why
tiering = setting role providers, and why it's portable across families.
Launch one:
gc bd create "Add a --json flag to the export command"
gc sling run-operator <bead-id> --on build-basicA single always-on named session that plans, dispatches, and monitors — one per city.
packs/core-pack/agents/mayor/agent.toml:
name = "mayor"
provider = "deep" # coordinator gets the strong tier
append_fragments = ["mayor-operating-rhythm", "efficient-routing-rules"]
idle_timeout = "1h" # idle instead of busy-looping
wake_mode = "fresh" # re-prime clean each wake — no context pile-up
max_active_sessions = 1Declared as always-on in packs/core-pack/pack.toml:
[[named_session]]
template = "mayor"
mode = "always"
scope = "city"prompt.template.mdgives it a coordinator prompt (plan/dispatch/monitor). Without a prompt it falls back to a generic worker loop and busy-polls.append_fragmentsattach reusable prompt chunks (see next section) — here an "operating rhythm" (idle-when-empty, periodic checks) and the routing rules.- Because it's
deep, the coordinator runs on Opus / Gemini Pro (High) automatically per city.
Reusable prompt blocks in template-fragments/*.template.md, defined as Go-template blocks
and attached by bare define-name:
packs/core-pack/template-fragments/routing.template.md:
{{ define "efficient-routing-rules" }}
### Task Routing Rules
Route by how much reasoning the task needs, prefixing the target rig:
gc.routed_to=<rig>/<tier>
- Trivial / mechanical → fast
- Standard implementation, review → solid
- Non-trivial: planning, design, architecture → deep
When asked to plan anything non-trivial, delegate it to a `deep` subagent —
do not plan it yourself.
{{ end }}
Two rules that bite everyone:
- Reference by the bare define name (
efficient-routing-rules), not<file>.<name>. - The file must end in
.template.md(or legacy.md.tmpl). A plain.mdfragment is silently ignored → "template not found".
Custom commands live under a pack's commands/ and are invoked as gc <binding> <name>
(the import binding — cc for core-pack here):
gc cc gen-model-tiers > model-tiers.toml # the tier generator above
gc cc clear-human-mail # flush a recipient's mail inboxOrders pair a trigger with an action, evaluated by the controller each tick.
packs/core-pack/orders/mayor-hourly-sweep.toml:
[order]
description = "Wake the mayor once an hour to sweep status and check running convoys."
trigger = "cooldown"
interval = "1h"
exec = "bash $GC_PACK_DIR/assets/scripts/mayor-hourly-sweep.sh"
timeout = "30s"The script finds the active mayor session and nudges it (gc session nudge <id> "..." --delivery queue) — pure plumbing; all judgment stays in the mayor's prompt.
gc config show # fully-composed config
gc config explain --json --provider deep | jq . # trace a tier to its concrete model
gc lint <pack> # validate before merge
gc reload # apply config to a running city
gc status # city health
gc session list / peek <name> / reset <name> # observe / restart a session
gc prime <agent> --strict # render an agent's prompt (catch typos/fallbacks)
gc sling <role> <bead> --on build-basic # launch the build factoryConfig change loop: edit → gc lint → gc config explain (confirm the resolved value)
→ gc reload.
- Tier the interface, bind per city. Agents/formulas reference
fast/solid/deep; cities bind them. One config, N providers. - One source of truth for model IDs. Family concretes in the shared pack; cities only say which family. Bumping a model is a one-line edit.
- Generate the repetitive patches. Role×rig tier patches are derived data — keep the
base map + generator in the pack,
includethe generated fragment in the city. commandisn't var-expanded; env values are. Bare name on PATH for portability;$VARonly for secret env values.- Fragments need
.template.mdand are referenced by bare define-name. - A named session's display name is
<binding>.<name>(e.g.cc.mayor); to show it unprefixed, declare the[[named_session]]in the city's own pack with an explicitnameandtemplate = "<binding>.<agent>". - Only some providers need
install_agent_hooks. Claude auto-installs hooks; others (e.g. Antigravity) must be listed to get their managed prime/nudge/mail hooks. - Give the coordinator a real prompt or it falls back to a worker loop and busy-polls.