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]
[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'scommand
/path_check
arenotvariable-expanded β use abare name on PATH(command = "kimi"
), not$KIMI_PATH
or 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-blockvalues(secrets)doexpand$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 binding
Produces (one block per rig Γ role):
[[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"
Wire 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'sstoredfields βname
is theunqualifiedrole (run-operator
, notgc.run-operator
) anddir
is the rig. Thebinding.
/rig/
prefixes only appear indisplaynames (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-basic
A 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 = 1
Declared as always-on in packs/core-pack/pack.toml
:
[[named_session]]
template = "mayor"
mode = "always"
scope = "city"
gives it aprompt.template.md
coordinatorprompt (plan/dispatch/monitor). Without a prompt it falls back to a generic worker loop and busy-polls.attach reusable prompt chunks (see next section) β here an "operating rhythm" (idle-when-empty, periodic checks) and the routing rules.append_fragments
- 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(or legacy.template.md
.md.tmpl
). A plain.md
fragment 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 inbox
Orders 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 factory
Config change loop: edit β gc lint
β gc config explain
(confirm the resolved value)
β gc reload
.
Tier the interface, bind per city. Agents/formulas referencefast
/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,include
the generated fragment in the city.Bare name on PATH for portability;command
isn't var-expanded; env values are.$VAR
only for secret env values.Fragments need and are referenced by bare define-name..template.md
A named session's display name is(e.g.<binding>.<name>
cc.mayor
); to show it unprefixed, declare the[[named_session]]
in thecity's ownpack with an explicitname
andtemplate = "<binding>.<agent>"
.Only some providers need Claude auto-installs hooks; others (e.g. Antigravity) must be listed to get their managed prime/nudge/mail hooks.install_agent_hooks
.Give the coordinator a real prompt or it falls back to a worker loop and busy-polls.