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
- 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.
- 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 onlyorchestrator/src/runners/. - 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. - Judgment is for agents, plumbing is for scripts. Anything deterministic (fetching data, diffing, crawling) is a script the agent wrote once — not agent turns.
- 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. - 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:
- CLI —
docker compose run --rm orchestrator node src/index.js --trigger "apply the seo audit to the tunisia website"(or--run tn:seo-auditfor the exact, dispatcher-free form). - GitHub issue — open an issue labeled
triggeronTRIGGERS_REPO(defaults toAPPROVALS_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 task —
countryos-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 pollsspaces.messages.listeveryCHAT_POLL_SECONDS(60) and replies in-thread. NeedsGOOGLE_CHAT_CREDENTIALS+GOOGLE_CHAT_SPACESin.env. Two auth modes (picked from the credentials file content): user auth (recommended — one-timescripts/chat-auth.jsconsent, no Workspace admin; reads as that user, replies via the notification webhook) or app auth (Chat-app service account; needs one-time admin approval ofchat.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-runchat-auth.jsto 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.