A generic operating system for running autonomous agents safely, unattended, across multiple brands.
Open-core: the mechanisms — kernel, governance, observability — are generic and shippable. Your agents, policies, connectors, and data are brand-private plugins.
Full design rationale:
docs/ARCHITECTURE.md.
Every side effect an agent has on the outside world passes through one mediated boundary — the gateway — that the OS controls. Policy is checked there. Budget is debited there. Identity is asserted there. Idempotency is enforced there. Audit is written there.
Without that single chokepoint, Policy / Budgets / Approvals / Audit / Evaluation are unenforceable — documentation, not controls. With it, you can leave an agent running and trust it. The rest of the repo is built around making that boundary real.
Agent wants to act ──► [ GATEWAY ] ──► external system
1. Policy.classify → green | yellow | red | deny
2. Approvals → suspend run for yellow/red until a human decides
3. Budget → hard-stop if over cap
4. Identity → act as the run's principal
5. Idempotency → dedupe retried effects (exactly-once)
6. Execute → call the capability
7. Audit → record action + reasoning + result
npm install
npm run build
npm run serve # web console + API → http://localhost:3010
npm run demo # or: scripted 4-run governance demo in the terminal (no API keys)
npm run typecheck # tsc --noEmitnpm run serve (or agent-os serve) starts a zero-dependency web console — built on Node's
built-in http, no framework — where you can launch agents, approve / reject risky actions in a
human queue, and watch each run's live audit trail. Configure the port with PORT (default 3010).
Deploy it behind a reverse proxy (nginx/Tailscale) for HTTPS; the app's own invite/cookie login gates
everything, so no extra basic-auth is needed. See docs/process-per-tenant.md (Mac/Tailscale) or the
Linux/systemd runbook in CLAUDE.md.
The same single Node process fronts the app, the JSON API, and the browser terminal on both
platforms — the only native deps are tmux and ttyd (brew install tmux ttyd / your distro's
packages). Agent sessions run inside a tmux server that daemonises out of the Node process, so
they're designed to outlive a server restart: on boot the app re-adopts whatever tmux sessions are
still alive via the persistent socket at <home>/tmux.sock (it never re-spawns them). The difference
between the platforms is entirely in how the process supervisor treats that surviving tmux server.
-
macOS (dev / self-hosted). Just
npm run serve; nothing sits in front of the Node server — it reverse-proxies/terminal/(HTTP + the ttyd WebSocket) to a localttyditself, login-gated with the same per-session authz nginx enforces in prod. Under launchd there are no cgroups, so a restart signals only the main process and the daemonised tmux server (and every agent session) survives untouched. No extra configuration needed. -
Linux + systemd (production). systemd supervises the service as a cgroup, and two unit settings are required or a
systemctl restartwill silently kill every running agent session (they resurface ascrashed) — a footgun macOS never exposes. The bundledagent-os.servicehas both set correctly; if you write your own unit, mirror them:Setting Value Why KillModeprocessDefault ( control-group/mixed) SIGKILLs the whole cgroup on stop. A tmux daemon double-forks out of the process tree but not the cgroup, so it dies with the restart.processsignals only the main PID and leaves the tmux server (and its sessions) running for the fresh process to re-adopt.PrivateTmpfalsetruegives each service invocation a throwaway/tmp; a session surviving a restart would be pinned to the old, torn-down/tmpnamespace and theclaudeCLI'smkdir /tmp/claude-<uid>fails withENOENT.falseshares the host's stable/tmp.Front
/terminal/with nginxauth_request→/api/auth/me(ttyd doesn't pass through the app in prod, so the app's shared-terminal proxy is inert there — don't leave the writable terminals ungated). Full runbook — nginxX-Forwarded-*handling, deploy steps, per-user uid isolation — is inCLAUDE.md; multi-tenant fan-out is indocs/process-per-tenant.md.Operational note: because sessions now outlive the app, never run
tmuxagainst<home>/tmux.sockas root (e.g.sudo tmux) — it leaves the socket root-owned and the service (running as its own user) can no longer spawn sessions on it. If spawns start failing withPermission denied, checkls -la <home>/tmux.sock— the owner must be the service user; if a stale root-owned socket with no live server is there, remove it and the next spawn recreates it.
npm run demo exercises the whole trust layer against mock capabilities and prints the exact
append-only audit trail the gateway wrote for each run:
| Scenario | What it proves |
|---|---|
| 1. Green + retry | Allowed actions run; an idempotent retry is deduped (effect fires once). |
| 2. Approvals | $49 refund → yellow → head approves; $5000 refund → red → owner declines → blocked. |
| 3. Budget | $0.02 cap, $0.01/action → hard-stop on the 3rd. |
| 4. Policy deny | prod.* is denied outright; the capability never executes. |
Durable audit is written to data/audit/<tenant>/<run_id>.jsonl — one append-only file per run.
The console is invite-gated. On first boot the owner is seeded automatically (set
AGENT_OS_OWNER_EMAIL, default owner@localhost) and a one-time magic-link login URL is printed to
the server console and data/server.log. Everyone else signs in via an invite link the owner/admins
generate. Three roles: owner (runs everything, approves red/owner requests, manages the team),
admin (approves yellow/head requests, manages team & assignments, runs any agent), and
member (runs only assigned agents, never approves). Manage it on the Team page, or from the box:
agent-os invite teammate@company.com member # mint a magic link to copy/send
agent-os login-link you@company.com # fresh link for an existing member (recovery)
agent-os members # list members + rolesState lives in a per-workspace SQLite DB (<home>/agent-os.db, via Node's built-in node:sqlite —
no new deps): members & sessions, agent assignments, connectors, terminal sessions, the inbox feed,
approvals, and an audit mirror. One DB per data home keeps instances isolated.
agent-os (this repo) is the software. Your agents and their runtime state are your data,
and they live in a separate, configurable data home — so you can keep them in their own private
git repo and contribute to the open-source software without ever committing your agents.
agent-os/ # the SOFTWARE (this repo; you contribute here)
src/ web/ terminal/ # the mechanism
config/agents/example-*/ # bundled example agents (read-only seeds/fixtures)
config/policy/ # bundled default policy
$AGENT_OS_HOME (default ./data, gitignored — can be its OWN private repo)
agents/<id>/ # YOUR agent = one folder
agent.json CLAUDE.md # definition (tracked)
.claude/ memory/ # runtime state Claude writes (gitignored)
policy/default.policy.json # your policy override (optional; else the bundled one)
audit/ *.log tmux.sock # per-instance runtime
Resolution order for the home: $AGENT_OS_HOME → home in agent-os.config.json → ./data.
On load, bundled example agents and your home's agents are both registered; your agents win on id.
agent-os init ./my-brand # scaffold a data home (its own .gitignore + git repo + a starter agent)
AGENT_OS_HOME=./my-brand agent-os serve --port=3010Run several instances on one machine — give each a distinct home + PORT (the tmux socket and
logs live inside the home, and TTYD_PORT defaults to PORT+1, so instances never collide):
AGENT_OS_HOME=./brand-a PORT=3010 agent-os serve
AGENT_OS_HOME=./brand-b PORT=3020 agent-os serveA tenant is one workspace (its own DB, members, agents, connectors, audit). There are two ways to run more than one; the DB file is always the isolation boundary.
-
Process-per-tenant (recommended). Each tenant is its own self-contained
agent-os serveprocess — the model above, withAGENT_OS_TENANTnaming each one. Simplest and fully isolated. On a single host (e.g. a Mac Mini over Tailscale) front them withscripts/run-tenant.sh+scripts/tailscale-serve.sh; the full runbook isdocs/process-per-tenant.md.scripts/run-tenant.sh acme ~/aos/acme 3010 you@acme.com scripts/run-tenant.sh globex ~/aos/globex 3020 you@globex.com scripts/tailscale-serve.sh 3010 3020 # → separate https origins, clean cookie isolation
-
Many tenants in one process (
src/tenant-registry.ts). A registry builds one isolated runtime per tenant (own DB/tmux/ttyd/cron/Slack), routed by subdomain (<slug>.<baseDomain>) or the loopbackx-aos-tenantheader, listed in a control plane (src/state/control.ts). Provisioning is superadmin-only (agent-os tenant create <slug> --owner <email>/POST /api/admin/tenants, gated byAOS_SUPERADMIN_TOKEN). Needs wildcard DNS + abaseDomain; pick this when you outgrow a handful of tenants. The seed tenant (configtenant) keeps the legacy un-nested home, so existing installs need no migration. Seedocs/scoping-model.md.
An agent's runtime (in agent.json) selects how a terminal session is driven:
runtime: "mock"→ the scriptedterminal/agent-runner.shdemo (no API keys; shows the gate).runtime: "claude-code"→ a realclaudesession opened in the agent's own folder ($AGENT_OS_HOME/agents/<id>/).terminal/claude-launch.shcds into the folder, writes a project-local.claude/settings.jsonwiring aPreToolUsegate hook (terminal/gate-hook.sh), and execsclaudeseeded with the task. EveryBashcall the agent makes is classified by the same gateway — risky ones (rm,deploy,prod,stripe…) pause and surface as an inbox approval.
The bundled sandbox agent (created in ./data/agents/sandbox/ — i.e. your data, not committed)
demonstrates the claude-code path end to end.
Each claude-code agent can pin its own model, effort (low…max), and permissionMode
(default/acceptEdits/plan/auto/dontAsk/bypassPermissions) in agent.json — editable from the
agent's console page (GET/PUT /api/agents/:id/config). Any field left blank inherits a workspace
default set once in Settings → Runtime defaults (GET/PUT /api/settings/runtime-defaults); a field
blank there too falls through to the claude CLI's own default. At launch the server resolves
agent → workspace → CLI default and claude-launch.sh maps the result onto --model / --effort /
--permission-mode. permissionMode only changes the agent's own prompt posture — the PreToolUse
gate hook still blocks risky effects for inbox approval underneath it, even under bypassPermissions
(which is why the headless automation lane can safely run --dangerously-skip-permissions).
Swapping the foreign CLI (Codex/Gemini/etc.) is not wired up: the launcher seam is generic enough, but those CLIs have no
PreToolUse-hook equivalent, so the gateway invariant would need an MCP-fronted-only or sandbox enforcement model first. Seedocs/PILLARS.md(Pillar 1).
agent-os/
├── config/ # BUNDLED examples that ship with the software
│ ├── agent-os.config.json # tenant, home, dirs, defaults
│ ├── policy/default.policy.json # green/yellow/red/deny rules (policy is DATA)
│ └── agents/<id>/ # example agent.json manifest + CLAUDE.md (seeds/fixtures)
│
├── terminal/ # how a terminal session is driven + governed
│ ├── agent-runner.sh # runtime:mock → scripted demo
│ ├── claude-launch.sh # runtime:claude-code → real claude, opened in the agent's folder
│ └── gate-hook.sh # PreToolUse gate the launched claude is wired to
│
├── src/home.ts # resolves the data home ($AGENT_OS_HOME → config → ./data)
│
├── src/
│ ├── types.ts # the only contracts the core depends on
│ ├── kernel.ts # composition root — wires everything (AgentOS, loadAgentOS)
│ ├── index.ts # boot entrypoint
│ ├── demo.ts # the runnable demo above
│ │
│ ├── core/ # ── Orchestrator: the Run lifecycle (the kernel)
│ │ ├── run.ts
│ │ └── orchestrator.ts
│ ├── gateway/ # ── THE mediated effect boundary
│ │ ├── gateway.ts # the 7-step pipeline
│ │ └── idempotency.ts
│ ├── governance/ # ── Trust plane
│ │ ├── policy.ts # rule engine (data-driven)
│ │ ├── approvals.ts # HITL queue + decision capture
│ │ ├── budget.ts # $/token caps + hard-stop
│ │ ├── identity.ts # act-as principal (≠ secrets)
│ │ └── audit.ts # append-only event store (system of record)
│ ├── observability/ # ── Did it stay alive? Did it work?
│ │ ├── monitor.ts # heartbeats / stale-run detection
│ │ └── evaluation.ts # outcome signal (feeds learning)
│ ├── capabilities/ # ── Plugin table: governable side effects
│ │ ├── registry.ts
│ │ └── examples.ts # echo / slack / stripe.refund / prod.restart (mocks)
│ ├── edge/ # ── World ↔ system
│ │ ├── triggers.ts # WHEN a run starts (cron/webhook/manual)
│ │ └── secrets.ts # vault, namespaced per tenant+principal
│ ├── runtime/ # ── How an agent is driven
│ │ ├── mock-adapter.ts # deterministic, used by the demo
│ │ └── claude-code-adapter.ts # reference sketch: claude --print + PreToolUse hook
│ └── state/
│ └── stores.ts # Tasks (state) · Memory (episodic) · Knowledge (semantic)
│
└── docs/ARCHITECTURE.md # the full design
The split is deliberate and is also the open-source seam: config/ + plugin implementations are
brand-private; everything in src/ (minus the example plugins) is the generic core.
Add a capability (a governable side effect). Implement Capability and register it. The gateway
governs it automatically; the policy file decides its risk.
const sendInvoice: Capability = {
id: 'billing.sendInvoice',
description: 'Email an invoice to a customer',
defaultRisk: 'yellow',
estimateCost: () => ({ usd: 0.002, tokens: 0 }),
async invoke(args, ctx) {
const key = await ctx.secrets.get(ctx.run.tenant, ctx.run.principal, 'BILLING_API_KEY');
// ... perform the effect using `key` ...
return { ok: true, data: { invoiceId: 'inv_123' } };
},
};
os.registerCapabilities([sendInvoice]);Change policy without touching code — edit config/policy/default.policy.json:
First match wins, so put the more specific (conditional) rule first.
Add an agent — drop config/agents/<id>/agent.json (+ CLAUDE.md) and register its behavior
(mock) or point it at the claude-code runtime.
Wire a real agent — implement the claude-code adapter (see src/runtime/claude-code-adapter.ts):
spawn claude --print without bypassPermissions, front dangerous tools via an OS-owned MCP server,
and gate the rest with a PreToolUse hook that calls gateway.invoke.
This is a starter: the spine is real and runnable; the leaves are swappable stubs.
| Real & working | Reference stub (swap for prod) |
|---|---|
| Gateway 7-step pipeline | In-memory budget / approvals / idempotency (→ Postgres/Redis) |
| Policy engine (data-driven, glob + conditions) | StubIdentity (→ STS / scoped OAuth) |
| Append-only JSONL audit (per tenant/run) | EnvSecretsVault (→ Vault / SSM) |
Run lifecycle + waiting_approval suspend/resume |
In-memory Tasks/Memory/Knowledge (→ DB / vector store) |
| Budget hard-stop, idempotency dedupe | ClaudeCodeAdapter (reference sketch only) |
| Evaluation signal off the audit stream | Console is a snapshot (→ web cockpit) |
| Multi-tenant fields throughout | Triggers: manual only (→ cron/webhook/event bus) |
Deferred by design (build last, after Evaluation has data): Dreaming / self-learning.
See docs/ARCHITECTURE.md §6–§7 for the maturity phases and the full gap map.
MIT.
{ "match": { "capability": "billing.sendInvoice", "when": { "arg": "amountUsd", "op": "gt", "value": 500 } }, "risk": "red" }, { "match": { "capability": "billing.sendInvoice" }, "risk": "yellow" }