# Running Gas City across multiple cities and providers

> Source: <https://gist.github.com/thinkjones/4a6d9cb771668c89407843821816c3ba>
> Published: 2026-07-09 17:11:27+00:00

**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_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):

```
# 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"
# ...etc
```

**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`

, not`gc.run-operator`

) and`dir`

is the rig. The`binding.`

/`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 a`prompt.template.md`

*coordinator*prompt (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 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,`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 the*city's own*pack with an explicit`name`

and`template = "<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.
