0% 5h quota used · resets in 2h live · 08/09/2026 12:11 CEST

README — what Country OS is

/app/README.md

Country OS

Autonomous management system for Selectra's countries. Humans define goals per country; the system runs scheduled agent tasks that audit, propose, and (with approval where required) apply changes to the country's business and websites.

Design principles

  1. The OS is not the AI. The durable assets are the goal/task definitions, tools, memory, ledger and safety rails. The model runner is a plugin.
  2. Runner-agnostic tasks. Tasks are markdown skills + a YAML manifest. Today the executor spawns claude -p (subscription auth); swapping to the API, the Agent SDK or another vendor means rewriting only orchestrator/src/runners/.
  3. CLI tools, not servers. Tools the agent uses are plain CLIs in tools/bin/ (self-documenting via --help). Context-cheap, composable, testable by humans, portable to any runtime. MCP can be added later if a remote/OAuth service needs it.
  4. Judgment is for agents, plumbing is for scripts. Anything deterministic (fetching data, diffing, crawling) is a script the agent wrote once — not agent turns.
  5. Two-level safety. Task policy (autonomy: autonomous | approval) decides whether a human gate exists; write/publish tools independently require an approval token, so the agent cannot skip the gate mid-run.
  6. Ledger from run #1. Every run logs model, turns, duration and API-equivalent cost per country — the dataset that prices each country if subscription auth goes away.

Layout

orchestrator/        Thin Node service: cron scheduler + executor + ledger + approvals
  src/providers.yaml Who serves a run, and the env recipe that points the CLI at them
  src/repos.yaml     The application repos tasks may work in: clone source, remote,
                     base branch, Sentry project. `workspace:`/`repos:` draw their
                     vocabulary from it; naming an undeclared repo is a boot error
countries/TASKS.md   tasks.yaml field reference (manifest fields, autonomy levels,
                     workspaces, repos, gates)
countries/<cc>/      One workspace per country (two-letter code)
  CLAUDE.md          Goals, constraints, business context (the country "brain")
  tasks.yaml         Task manifest: skill, schedule, model, autonomy, budget
  skills/            Workflow definitions (markdown + scripts) — portable
  memory/            What the agent has learned/decided
  reports/           Audit and review outputs
  proposals/         Pending changes awaiting approval
countries/_global/   Same layout, but NOT a country: cross-country maintenance of the
                     shared application repos (Sentry triage, PR review follow-up).
                     Leading underscore = "not a market"; branches drop it
                     (countryos/global/*)
ui/                  Read-only web console over data/ + countries/ (see ui/README.md)
tools/bin/           Shared CLI tools, on PATH for every task (`<tool> --help`):
                       gsc                Search Console traffic (read-only)
                       sentry             Sentry issue triage. `worklist` answers
                                          "what is not triaged yet?" and is what
                                          gates the triage task; `verify` answers
                                          "did that fix deploy, and did it work?"
                                          and `resolve` — the one write in the tool,
                                          behind its own SENTRY_WRITE_TOKEN — closes
                                          the issues whose errors actually stopped
data/workspaces/     One private clone per repo in repos.yaml, made at container start
                       countryos-trigger  Queue another task (chaining)
data/ledger/         JSONL run ledger (one file per month)
data/cli-version.json Which Claude Code build this image froze at, and what npm
                     publishes. The container cannot update itself, so the
                     version only moves on a rebuild — /maintenance says when
data/quota.json      What is LEFT to spend, as opposed to what was spent: the
                     subscription's 5h/7d windows and each prepaid provider's
                     balance. The windows are not polled — they ride in on the
                     response headers of runs that were happening anyway
data/sentry-resolved.json What `sentry resolve` closed and on what evidence:
                     append-only, one row per issue, so a status change made by
                     a machine is auditable after the fact
data/approvals/      Pending approval records
data/queue/          Trigger queue (one JSON request per triggered run)

Task lifecycle

cron due, or trigger (CLI / GitHub issue / another task ─▶ data/queue/ ─▶ dispatcher)
  ─▶ executor spawns runner (claude -p, cwd = country workspace)
              │
              ├─ autonomy: autonomous ─▶ acts, writes report ─▶ ledger + notify
              │
              ├─ autonomy: pr ─▶ pushes countryos/* branch + opens DRAFT PR to develop
              │      ─▶ the PR review IS the human gate (mark ready + merge = human)
              │
              └─ autonomy: approval ─▶ phase "propose": writes proposals/<task>/<run>/
                     ─▶ GitHub issue on APPROVALS_REPO (plan/business decisions,
                        not code) + Google Chat ping
                     ─▶ poller sees /approve ─▶ phase "apply" with APPROVAL_TOKEN
                     ─▶ ledger + notify (or /reject ─▶ close + notify)

How many of those may be in flight at once is MAX_CONCURRENT_RUNS (default 1). The cap counts agent processes and is taken around the spawn itself, so a runner: script task and a gate probe — neither of which starts an agent — are never made to queue behind a four-hour campaign sweep. What waited, and for how long, is queued_ms on the ledger row.

Running

cp .env.example .env      # fill in tokens / webhook
./build                   # docker compose up -d --build, once nothing is in flight
docker compose logs -f orchestrator

Use ./build rather than docker compose up -d --build directly. The daemon installs no signal handler, so recreating the container kills a running agent with the process — no ledger row, no report, and a lock left behind — and croner does not catch up a cron that fired while the container was down. ./build refuses (exit 4) when a run is in flight or queued, when a queue request is mid-dispatch, or when a firing is due in the next ten minutes, and tells you when the next free gap is. ./build --check answers the question without touching anything; ./build --force overrides it; ./build --restart restarts without rebuilding, which is all a bind-mounted change needs. The same verdict, with the schedule around it, is on the console's Maintenance page.

That also brings up the read-only console on http://127.0.0.1:4173. To put it behind a Cloudflare hostname and share the link, docker compose --profile share up -d — one token in .env, and the setup notes are in ui/README.md.

countries/, tools/ and orchestrator/src are bind-mounted, so edits to a task manifest, a skill or a CLI tool need no image rebuild — but they do need the daemon to restart, because tasks.yaml is read once at boot:

./build --restart orchestrator          # picks up manifest / tool / src changes

docker compose up -d will not do it. It only recreates a container when the compose file or the image changed, so after editing a bind-mounted file it finds everything already correct and does nothing at all — silently. The boot line says how many tasks the running process actually knows ([countryos] N task(s) loaded); compare it with --check when a task seems to have gone missing. Addressing a task the daemon has not loaded does not fail cleanly either: an unknown cc:task-id looks like free text, so the dispatcher routes it to the nearest task it does know.

Triggered tasks

Tasks don't have to be scheduled — a task without a schedule (or any task) can be triggered on demand. Every trigger source drops a request into data/queue/; the daemon dispatches it and runs the task. Triggers pick the task, never the policy: a triggered approval task still goes propose → issue → /approve, a triggered pr task still lands as a draft PR.

Free text is routed by a one-turn haiku call over the task catalog (built from tasks.yaml + each skill's description: frontmatter); exact cc:task-id syntax skips the model entirely (global: works as well as _global:). Ambiguous requests get a clarifying question back on the channel they came from.

Sources:

  • CLIdocker compose run --rm orchestrator node src/index.js --trigger "apply the seo audit to the tunisia website" (or --run tn:seo-audit for the exact, dispatcher-free form).
  • GitHub issue — open an issue labeled trigger on TRIGGERS_REPO (defaults to APPROVALS_REPO); title + body + your later comments are the request. The daemon comments its interpretation, the result, and closes the issue when done. If it asks a question, just reply on the issue.
  • Another taskcountryos-trigger tn:seo-fixes <instructions> from inside any run (it's on PATH). Chain depth is capped (TRIGGER_MAX_CHAIN, default 2) so tasks can't trigger each other in a loop.
  • Google Chat@countryos <request> in a watched space. The daemon polls spaces.messages.list every CHAT_POLL_SECONDS (60) and replies in-thread. Needs GOOGLE_CHAT_CREDENTIALS + GOOGLE_CHAT_SPACES in .env. Two auth modes (picked from the credentials file content): user auth (recommended — one-time scripts/chat-auth.js consent, no Workspace admin; reads as that user, replies via the notification webhook) or app auth (Chat-app service account; needs one-time admin approval of chat.app.messages.readonly). In user-auth mode the request message also gets status reactions — 👀 seen, ❓ unclear, ⏳ refused, ✅ done, ❌ failed — which appear as the token owner's account (the Chat API has no app/webhook reactions). Tokens minted before the reactions scope existed keep working; re-run chat-auth.js to enable them.

Rate limits: a per-task cooldown (TRIGGER_COOLDOWN_MINUTES, default 30) plus the existing already-running dedup. Set triggerable: false in tasks.yaml to keep a task out of reach of triggers entirely.

Run a single task manually (bypasses schedule and dispatcher, honors policy):

docker compose run --rm orchestrator node src/index.js --run tn:seo-audit

Check the task manifests after editing one (the daemon refuses to boot on an invalid manifest — fields documented in countries/TASKS.md):

docker compose run --rm orchestrator node src/index.js --check

Cost report:

docker compose run --rm orchestrator node scripts/report.js

Alerting & health

All notifications go to the Google Chat webhook. A task can opt out of its own success message with notify: on-failure in tasks.yaml (field reference countries/TASKS.md) — for a task that already posts to Chat itself, the 🟢 is duplicate noise. Failures are never suppressed. On top of this alerts.js tracks operational failures per key (github-api, chat-auth, claude-auth, …) in data/alerts.json: an alert fires after N consecutive failures (default 3, auth problems fire immediately with the fix command), reminds at most every 4h, and a 🟢 recovery message closes the loop. If the webhook itself is down, alerts land as comments on a [ALERT] Country OS notifications issue (ALERTS_REPO, default APPROVALS_REPO); console is the last resort. rm data/alerts.json resets the state.

A health tick (every 2min) watchdogs the pollers (any loop silent for 3× its interval alerts as stalled — every outbound HTTP call also has a hard timeout), checks disk space, stale .lock files blocking pending work, approvals pending

3 days, crash loops (4 boots in 10min) and daily API-equivalent cost, then writes data/health.json. That file's freshness is the liveness signal for a future K8s exec probe — nothing inbound is exposed. Per-run and per-day cost thresholds: COST_RUN_MAX_USD (10), COST_DAILY_MAX_USD (50); all knobs in .env.example. What alerting still cannot see: the whole container/host being down (needs the external dead-man's switch — see ROADMAP).

Rehearse any failure mode against a scratch root:

docker compose run --rm orchestrator node scripts/simulate-failure.js webhook-down
# scenarios: webhook-down | github-500 | stale-lock | cost | crash-loop | claude-auth

Auth

Uses the Claude subscription via a long-lived token: run claude setup-token on the host, put the result in .env as CLAUDE_CODE_OAUTH_TOKEN. If the subscription ever disappears, set ANTHROPIC_API_KEY instead (same CLI, API billing) or implement a new runner.