Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Select an option

  • Save justintanner/f60b7035efd934fb0c786a1151ddeb8f to your computer and use it in GitHub Desktop.

Select an option

Save justintanner/f60b7035efd934fb0c786a1151ddeb8f to your computer and use it in GitHub Desktop.
Running Gas City across multiple cities and providers

Running Gas City across multiple cities and providers

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.


The mental model (6 pieces)

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.


Repo layout

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/.


1. Providers and the tier abstraction

This is the core idea. Abstract tier names are the stable interface; the concrete model is a swappable implementation.

In the shared pack: define the concrete model per family, once

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" }

In each city: alias the generic tier to its family (base chaining)

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 are not variable-expanded — use a bare 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-block values (secrets) do expand $VAR.


2. Per-role model tiers (generated, not hand-written)

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.

Datapacks/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 incities/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 — name is the unqualified role (run-operator, not gc.run-operator) and dir is the rig. The binding./rig/ prefixes only appear in display names (gc rig list), never in patches.


3. How a build actually flows

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

4. The coordinator ("mayor")

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"
  • prompt.template.md gives it a coordinator prompt (plan/dispatch/monitor). Without a prompt it falls back to a generic worker loop and busy-polls.
  • append_fragments attach 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.

5. Template fragments (append_fragments)

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 .md fragment is silently ignored → "template not found".

6. Custom commands and scheduled orders

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.


Everyday commands

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 lintgc config explain (confirm the resolved value) → gc reload.


Hard-won lessons

  • 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.
  • command isn't var-expanded; env values are. Bare name on PATH for portability; $VAR only for secret env values.
  • Fragments need .template.md and 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 explicit name and template = "<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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment