Agent OS — engineering contract for phases 0–2 (binding for every workstream)
Read docs/ARCHITECTURE.md first (design rationale, in French). This file is the precise interface contract.
Scope of this milestone: foundations, durable kernel, ONE agent per mission (no sub-agents yet — the data model
already supports them), Anthropic + OpenAI providers, file/shell/git tools in a Seatbelt sandbox, policy +
durable approvals, live console. Out of scope now: sub-agent orchestration, compaction, long-term memory,
remote runners, MCP, web browsing.
0. Rules for everyone
- TypeScript strict, ESM, Node ≥ 24, no
anywithout a justification comment. zod on every untrusted input. @aos/shared(packages/shared/src/{core,api}.ts) anddb/migrations/*.sqlare FROZEN; additive changes only, listed in your final report. New migrations get a new number (011_…,020_…).- No agent framework dependency (no LangChain/LangGraph/CrewAI/AutoGen/Agents SDK/Claude Agent SDK).
Model access only through the official SDKs
@anthropic-ai/sdkandopenai. - Never fabricate data in the UI or tests-as-demo. Unknown ⇒ null / "—".
- Secrets never in logs, events, steps, prompts or the browser. Model API keys are read from env only
(
ANTHROPIC_API_KEY,OPENAI_API_KEY). - Real model calls cost money: tests that call a model must be opt-in (
AOS_LIVE_TESTS=1), use the cheapest model (claude-haiku-5-5,gpt-6-luna), tiny prompts, and stay under USD 0.50 in total per workstream. Dev keys:set -a; . ~/.config/agent-os/dev.env; set +a(file exists, mode 0600; never print it). - Do not
git commit. Do not run plainpnpm install; add dependencies with the lock:while ! mkdir /tmp/aos-pnpm.lock 2>/dev/null; do sleep 2; done; pnpm --filter <pkg> add <dep>; rmdir /tmp/aos-pnpm.lock - Local Postgres 17 is running (unix socket /tmp). Tests create their own database
aos_test_<random>(createdb), applydb/migrations, and drop it afterwards. - Stay inside your directories (ownership table below). Do not touch
~/.claude*,~/.codex*, or the sibling projectclaude-remote.
| Workstream | Owns |
|---|---|
| kernel | packages/kernel/** |
| providers | packages/providers/** |
| engine+tools+worker | packages/engine/**, packages/tools/**, apps/worker/** |
| server | apps/server/** |
| web | apps/web/** |
1. Processes
browser ─HTTPS/WSS→ Caddy (tunnel) → apps/server :8640 auth, REST, events WS, static console. Reads/writes Postgres
apps/worker claims runs, executes the engine, journals steps. No listener.
PostgreSQL 17 the only shared state (journal, queue, waits, events)The server never runs agent code. The worker never serves HTTP. They communicate only through Postgres
(tables + LISTEN/NOTIFY aos_events as a doorbell with a slow-poll fallback).
Env (both): DATABASE_URL, AOS_DATA_DIR (artifacts under artifacts/<sha256[0:2]>/<sha256>),
AOS_WORKSPACE_ROOT (projects live in <root>/<rel_path>), ANTHROPIC_API_KEY, OPENAI_API_KEY,
AOS_LOG_LEVEL. Server: AOS_PORT (8640), AOS_HOST, AOS_PUBLIC_ORIGIN, AOS_SECRET_KEY (64 hex),
AOS_TRUSTED_PROXIES, AOS_WEB_DIR. Worker: AOS_WORKER_ID (default hostname-pid), AOS_MAX_CONCURRENT_RUNS.
2. Kernel (@aos/kernel) — durable execution on Postgres
Model: deterministic replay over a step journal. A run's logic is an async function (ctx) => ResultEnvelope.
Every interaction with the outside world goes through ctx.step(...). When a worker (re)claims a run it calls
the function from the top; steps that already have a recorded result return it immediately without executing;
execution proceeds for real from the first step without a result. Therefore run logic must be deterministic
given step results: no Date.now(), Math.random(), crypto.randomUUID() outside steps — use ctx.now(),
ctx.uuid().
export interface StepSpec<Req, Res> {
kind: string; // 'model_call' | 'tool_call' | 'wait' | 'now' | 'uuid' | …
name: string;
effect: EffectClass;
request: Req; // JSON-serialisable; hashed (canonical JSON) → request_hash
/** Optional probe for non_idempotent steps found 'started' after a crash: has the effect happened? */
reconcile?: (req: Req, idemKey: string) => Promise<{ done: true; result: Res } | { done: false } | { unknown: true }>;
}
export interface RunContext {
readonly runId: string; readonly missionId: string; readonly parentRunId: string | null;
readonly contract: DelegationContract; readonly workspaceAbs: string; readonly signal: AbortSignal; // aborted on cancel / lost lease
step<Req, Res>(spec: StepSpec<Req, Res>, exec: (idemKey: string, signal: AbortSignal) => Promise<{ result: Res; usage?: Partial<Usage> }>): Promise<Res>;
/** Durable wait. First execution: inserts a `waits` row, emits wait.created, sets run 'waiting', releases the
* lease and throws an internal Suspend (never caught by run logic). After resolution the run is re-queued;
* on replay this call returns the resolution. Expired ⇒ returns `{expired:true}`. */
wait<T = unknown>(kind: 'approval' | 'question', payload: Record<string, unknown>, opts?: { timeoutSec?: number }): Promise<T | { expired: true }>;
sleep(ms: number): Promise<void>; // durable timer (wake_at), same suspend mechanism
now(): Promise<Date>; uuid(): Promise<string>; // journaled 'pure' steps
emit(kind: EventKind, payload: Record<string, unknown>, trust?: Trust): Promise<void>; // persisted event (idempotent on replay: skipped while replaying)
emitTransient(kind: 'model.delta', payload: Record<string, unknown>): void; // NOTIFY only, not persisted
ownerMessages(): Promise<{ id: number; text: string }[]>; // unread owner.message events for this run, journaled as a step so replay is stable
readonly replaying: boolean; // true while returning recorded results
usage(): Usage; budgetLeft(): { usd: number; turns: number; wallSec: number };
}Step protocol (all in one transaction each, guarded by WHERE id=$run AND fencing_token=$token):
ctx.stepallocatesseq = next_seq(monotonic in call order — replay makes the same calls in the same order).- Row exists
completed⇒ assertrequest_hashequal (else throwNonDeterminismError→ runfailed), return result. - Row exists
failed⇒ rethrow the recorded error (deterministic). (Transient provider errors are retried INSIDEexecby the caller; a step fails only when retries are exhausted.) - Row exists
started(crash between intent and result):pure|read|model_call|idempotent_write⇒ execute again with the sameidem_key;non_idempotent⇒ callreconcile;done⇒ record result;not done⇒ execute;unknownor no probe ⇒ markneeds_reconcile, create an approval-like wait{kind:'question', text:'Step N (<name>) may or may not have run…', options:['it ran — continue','it did not run — run it','abort']}and suspend. - No row ⇒ insert
started(intent) + emitstep.started; runexec; then in ONE transaction: update step tocompletedwith result/usage, add usage toruns.usage,missions.spent_usd,spend_days, bumpnext_seq, emitstep.completed+run.usage. Failure ⇒failed+step.failed. - After each step: if
cancel_requested⇒ abort ⇒ runcancelled. If budget (usd/turns/wall) exceeded ⇒ the engine is told viabudgetLeft()and must finish withbudget_exhausted(kernel hard-stops model_call steps once usd ≤ 0 by throwingBudgetExhaustedError). Daily cap (AosSettings.dailyBudgetUsdvsspend_days) ⇒ same error for every run.
Leases: claimRun(workerId) = UPDATE … WHERE id = (SELECT id FROM runs WHERE status='queued' AND (wake_at IS NULL OR wake_at<=now()) OR (status='running' AND lease_expires_at<now()) ORDER BY created_at FOR UPDATE SKIP LOCKED LIMIT 1) SET status='running', lease_owner, lease_expires_at=now()+90s, fencing_token=fencing_token+1.
Heartbeat every 30 s extends the lease (fenced); a failed heartbeat aborts ctx.signal and the worker stops
touching the run. A zombie's writes fail the fencing check (LeaseLostError).
Other kernel API: createMission(input) → {missionId, rootRunId} (creates mission + root run queued),
createRun(parent, contract, model…) (for later phases), requestCancel(missionId|runId) (cascades to descendants;
pending waits → cancelled; waiting runs → cancelled immediately), resolveWait(waitId, resolution, actor)
(marks resolved, emits wait.resolved, re-queues the run), expireWaits() (janitor, every 30 s),
postOwnerMessage(runId, text), runWorkerLoop({concurrency, logic: (ctx) => Promise<ResultEnvelope>, signal}),
migrate(databaseUrl) (applies db/migrations with an advisory lock; exported for server/worker/tests; also a CLI
node packages/kernel/dist/migrate-cli.js), listenEvents(cb) (LISTEN + 2 s poll fallback by cursor),
artifacts: putArtifact(bytes|stream, {name, mime, runId, stepSeq}) → {sha256,size} (content-addressed, atomic),
openArtifact(sha256).
Mission status mirrors the root run's status. When logic returns, the envelope is stored in runs.result,
status = envelope.status, ended_at set, events emitted. Uncaught logic error ⇒ failed with the message.
Mandatory tests (real Postgres): happy path; replay returns recorded results without re-executing (counter in
exec); request-hash mismatch ⇒ NonDeterminismError; chaos: run logic in a CHILD PROCESS that process.kills
itself (SIGKILL) at every step boundary in turn (before intent, after intent/before exec, after exec/before
commit, after commit) for a 6-step run mixing effects; a second worker resumes; assert each pure/read exec
count ≥ 1, each idempotent_write observable effect exactly once (keyed by idem_key), non_idempotent with a
reconcile probe executed exactly once, and without a probe ⇒ needs_reconcile + pending question, final
result identical to the uninterrupted run; lease expiry + zombie write refused (fencing); two workers never
execute the same step concurrently (50 runs × 2 workers); durable wait across worker restarts; wait expiry;
cancel while running / waiting; budget hard stop; daily cap; sleep survives restart; owner messages replay-stable.
3. Providers (@aos/providers)
export interface ToolDef { name: string; description: string; inputSchema: Record<string, unknown>; } // JSON Schema, strict-compatible
export interface ModelRequest {
model: string; effort?: Effort; system: string; tools: ToolDef[];
/** Provider-native transcript so far: opaque array owned by the provider adapter. Append-only. */
transcript: unknown[];
maxOutputTokens?: number; signal?: AbortSignal;
onDelta?: (d: { type: 'text' | 'reasoning'; text: string }) => void;
}
export interface ProviderAdapter {
readonly id: Provider;
/** Start a native transcript from the first user message. */
initTranscript(userText: string): unknown[];
/** Native entries to append for: an assistant response (`ModelResponse.native`), tool results, a later user/steering message, an operator note. */
appendToolResults(transcript: unknown[], results: ToolResult[]): unknown[];
appendUser(transcript: unknown[], text: string): unknown[];
appendOperatorNote(transcript: unknown[], text: string): unknown[]; // mid-conversation system message where supported, else a clearly delimited user-turn note
call(req: ModelRequest): Promise<ModelResponse>; // streaming under the hood; retries 429/5xx/overloaded with backoff (honour retry-after), max ~6 attempts; throws ProviderError{retryable,status,code}
listModels(): Promise<ModelInfo[]>; // live from the Models API merged with the static catalogue below
price(model: string, usage: Usage): number; // USD from the price table (cache read/write priced separately)
}
export function getProvider(id: Provider): ProviderAdapter;
export const CATALOGUE: ModelInfo[]; export function seedPrices(db): Promise<void>; // fills model_pricesAnthropic adapter — follow the bundled Claude API reference EXACTLY (read before coding):
/private/tmp/claude-501/bundled-skills/2.1.322/ac43f8c5e0edf74167af31b9754cc782/claude-api/ → SKILL.md,
typescript/claude-api/{README,tool-use,streaming}.md, shared/{tool-use-concepts,prompt-caching,agent-design,error-codes,models,model-migration}.md.
Key points: client.messages.stream(...) + .finalMessage(); adaptive thinking (omit thinking or
{type:'adaptive'}; never budget_tokens, never disabled), output_config.effort; display:'summarized' so the
console can show operational reasoning; NO forced tool_choice; strict: true tools; eager_input_streaming: true
on client tools with validation of the parsed input; return ALL tool results of a turn in ONE user message, failed
tools as is_error; preserve thinking blocks verbatim (transcript is append-only — never edit earlier entries);
handle stop_reason refusal (→ StopReason 'refusal'), pause_turn, max_tokens; enable server-side fallbacks
(betas:['server-side-fallback-2026-07-01'], fallbacks:'default') on models that support it; prompt caching:
stable prefix (tools → system) with a cache breakpoint on the system prefix + automatic caching of the tail;
operator notes as mid-conversation {role:'system'} messages on supporting models; max_tokens ~64000 with
streaming; typed error chain (RateLimitError, APIStatusError, APIConnectionError…).
Models (exact IDs, no date suffix): claude-fable-5-2 (frontier), claude-opus-5-5 (main, default),
claude-sonnet-5-5 (worker), claude-haiku-5-5 (volume). Prices per MTok in/out: 8/40, 4/20, 2/10, 0.10/0.50
(cache read 0.20/0.20/0.10/0.01; 5-min cache write = 1.25× input).
OpenAI adapter — Responses API via the official openai SDK, stateless (no previous_response_id,
store:false), streaming, function tools with strict:true, reasoning effort mapped from Effort, reasoning
summaries requested and encrypted reasoning items kept in the native transcript and replayed unchanged
(include:['reasoning.encrypted_content']), parallel tool calls allowed. Verify every parameter name against
the installed SDK's types and the live docs (https://developers.openai.com/api/docs) — do not guess.
Models: gpt-6-astra (frontier), gpt-6.1-sol (main), gpt-6.1-terra (worker), gpt-6-luna (volume).
Prices per MTok in/out: 10/50, 2/10, 1/5, 0.10/0.50 (cached input 1.00/0.10/0.05/0.01). Verify IDs with
client.models.list(); if an ID is absent mark available:false (never silently substitute).
Tests: unit tests with recorded fixtures for both adapters (transcript building, tool-call parsing incl.
parallel calls, usage/price math, error mapping, refusal, truncated tool input); opt-in live tests
(AOS_LIVE_TESTS=1): one text turn and one tool round-trip per provider on the volume model, asserting usage > 0
and price > 0, plus listModels() availability of the eight IDs (report which are available).
4. Tools (@aos/tools) and sandbox
export interface ToolContext { runId: string; missionId: string; workspaceAbs: string; network: 'none'|'open'; dataDir: string; signal: AbortSignal; idemKey: string; putArtifact(name: string, data: Buffer|string, mime?: string): Promise<{sha256:string,size:number}>; }
export interface Tool<I = unknown> {
def: ToolDef; input: z.ZodType<I>;
effect: EffectClass | ((input: I) => EffectClass);
risk: RiskClass | ((input: I) => RiskClass);
parallelSafe: boolean;
run(input: I, ctx: ToolContext): Promise<{ ok: boolean; output: string; trust?: Trust }>;
reconcile?: (input: I, ctx: ToolContext) => Promise<{done:true; output:string}|{done:false}|{unknown:true}>;
describeForApproval?(input: I): { title: string; detail: string; diff?: string };
}
export const TOOLS: Record<string, Tool>;Tools for this milestone: read_file (line ranges, 2 MiB cap, binary refusal), list_dir, search
(ripgrep if present else bounded walk; name or content), edit_file (exact string replace; fails if the string
is absent or not unique unless replaceAll; staleness check: fails if the file changed since this run last
read/wrote it — track sha per path in the engine state via tool results), write_file (atomic), shell
({command, timeoutSec ≤ 600} run through the sandbox, cwd = workspace, combined output, exit code),
git_status, git_diff, git_commit (stages given paths or all, commits with a mission trailer; idempotent:
no changes ⇒ ok no-op), git_push (risk external, effect non_idempotent, reconcile = compare remote ref),
ask_owner (question → engine turns it into ctx.wait('question')), update_criteria (flip passes of a
done criterion WITH evidence; cannot edit criterion text), finish (the only way to end a run: status, summary,
evidence, open questions → ResultEnvelope).
Path safety for every file tool: resolve inside workspaceAbs with realpath of the deepest existing ancestor,
reject .., absolute paths outside, NUL, symlink escapes; protected paths are never writable by any tool,
regardless of policy: .git/ internals (except through git tools), .aos/, AGENTS.md/CLAUDE.md at the
project root (read-only instructions), shell rc files, anything outside the workspace.
Sandbox (tier 1, macOS): shell runs /usr/bin/sandbox-exec -p <generated profile> /bin/zsh -lc <command>
as a process group with a deny-default-write profile: read allowed except ~/.ssh, ~/.aws, ~/.config,
~/.claude*, ~/.codex*, ~/Library/Keychains, the AOS data dir and env files; write allowed only under the
workspace and a per-run temp dir (TMPDIR); network: 'none' ⇒ (deny network*); 'open' ⇒ outbound allowed
(inbound denied). Environment passed to the command is a minimal allowlist (PATH, HOME, LANG, TMPDIR,
TERM) — never the worker's env (no API keys). taskpolicy -c utility when available. Output cap 1 MiB then
kill; timeout ⇒ TERM then KILL of the group. Fail closed: if sandbox-exec is unavailable or the profile
fails to apply, the tool returns an error and never runs unsandboxed. On non-macOS (CI) the shell tool is
disabled with a clear error. Verify enforcement with real tests: write outside workspace refused, read of a
protected dir refused, network blocked with none (e.g. curl -m 3 https://example.com fails), env has no
ANTHROPIC_API_KEY, timeout kills children, protected paths unwritable via file tools.
Tool output handling (engine-side helper exported by tools): outputs > 12 000 chars are stored as an artifact;
the model sees head 6 000 + tail 3 000 chars and a note with the artifact name/size and how to read more
(read_file on .aos/artifacts/<name> is NOT how — provide a read_artifact tool: {name, offset, limit}).
5. Engine (@aos/engine) — the agent loop as kernel run logic
export function agentLogic(deps): (ctx: RunContext) => Promise<ResultEnvelope> where deps = providers, tools,
policy lookup, settings. Loop:
- Build the frozen prefix once: tool list (deterministic order, filtered by contract allowlist), system prompt
(role, operating rules, done-criteria protocol, how to finish, sandbox/network facts, project instructions from
<workspace>/AGENTS.mdorCLAUDE.mdif present (≤ 32 KiB) +projects.instructions). No timestamps, no IDs. Recordprompt_hash. First user message: objective, boundaries, done criteria (or, when none were given, the instruction to propose 3–8 verifiable criteria viaupdate_criteriasemantics → new tool callpropose_criteriathat asks the owner to confirm through aquestionwait before any write), context refs. - Each turn:
ownerMessages()→ append as user steering messages;ctx.step(model_call)(exec callsprovider.call, forwards deltas viaemitTransient, returns{blocks, stop, usage, model, native}; usage priced withprovider.price) → appendnativeto the transcript. - Tool calls of the turn: validate input with zod (invalid ⇒ error result, no execution). Policy decision per
call (§6).
parallelSaferead tools of the same turn run concurrently (each its own step, steps allocated in call order BEFORE awaiting — keep seq deterministic); others sequentially. Each execution is actx.step({kind:'tool_call', effect, request:{name,input}, reconcile}). Results → one batch appended viaappendToolResults. - Stop conditions:
finishtool ⇒ envelope (statusdoneallowed only if every done criterionpasseswith evidence — otherwise the tool returns an error explaining what is missing;partial/blocked/failedare always allowed with a reason); stopend_turnwithout tool calls ⇒ append an operator note asking to continue or callfinish(max 2 nudges, thenpartial);refusal⇒blockedwith the category;max_tokens⇒ operator note to continue; budget/turn/wall limits ⇒budget_exhausted(the engine first gives the model ONE final turn with an operator note to summarise state when ≥ 90 % of the budget is used); cancel ⇒cancelled. - Stall detection (deterministic): the same tool call (name + canonical input) 3 times in a row with the same
result, or 15 turns without any successful write/commit/criterion change ⇒ one operator note ("you appear
stuck: re-plan or ask the owner"), second trigger ⇒
ask_owner-style question wait. - Context guard for this milestone (no compaction yet): track input tokens of the last call; at ≥ 70 % of the
model's context window add an operator note to wrap up; at ≥ 90 % finish
partialwith a state summary. - Git checkpointing: if the workspace is a git repo, the mission works on a branch
aos/<missionId[0:8]>created at start (step, idempotent); the system prompt asks for a commit after each verified unit of work.
Replay rule: the transcript is rebuilt deterministically from step results on every claim; nothing else is state.
Tests: a scripted fake provider drives full loops without network: tool round-trips, parallel reads, invalid tool
input, approval ask → approve / deny / expire, question wait, finish gating on criteria, nudges, stall
detection, budget exhaustion, cancel, steering message mid-run, crash/replay in the middle of a tool batch
(kill the worker child process, resume, same final transcript), edit staleness. One opt-in live test per provider
(AOS_LIVE_TESTS=1, volume model): in a temp git repo with a failing unit test, the agent fixes the bug, runs the
test in the sandbox, commits, flips the criterion and finishes done — assert test passes and commit exists.
6. Policy and approvals
Decision order deny → ask → allow, evaluated in the engine (never by the model):
- hard denies (protected paths, tool not in allowlist, network tool while
network='none'); - owner
policy_rules(project-scoped first, then global):deny>ask>allow; - defaults by risk:
readallow ·workspace_writeallow (sandbox + worktree branch) ·networkask ·externalask.shellrisk:workspace_writewhennetwork='none';networkwhen'open'(ask once per mission: an approval withremembercreates an allow rule for the project). Ask ⇒ctx.wait('approval', {tool, input, risk, title, detail, diff?}), timeout from settings (default 72 h), expiry = deny. Deny ⇒ tool resultok:false"Denied by owner/policy: " (the model may adapt). Every decision is an eventpolicy.decision.remember:trueon approve inserts anallowrule matching the tool (+commandPrefixfor shell: first token(s) of the command).
7. Worker (apps/worker)
main.ts: loads env, migrate, seedPrices, runWorkerLoop({concurrency: AOS_MAX_CONCURRENT_RUNS (default 4), logic: agentLogic(...)}), janitor (expireWaits), graceful shutdown (stop claiming, abort signals, leave runs
running with expiring leases so another worker resumes — never mark them failed). Structured pino logs without
prompts/outputs.
8. Server (apps/server) — adapted from Claude Remote
Keep unchanged in behaviour (rename env CR_* → AOS_*, cookie __Host-aos_sid, product name "Agent OS",
WebAuthn RP from origin): security/* (password, TOTP, WebAuthn, sessions, CSRF, throttle, guard, setup token),
modules/auth, static.ts (CSP with per-document script hashes), audit.ts, janitor.ts, rate-limit buckets,
modules/notifications, modules/settings (now AosSettings), OpenAPI. REMOVE: runtime client, terminal WS,
sessions, files, tasks, cluster, skills/MCP, agent profiles, branding variants (single brand; GET /auth/status
returns branding:{agent:'aos', productName:'Agent OS', agentName:'Agent'}).
New REST under /api/v1 (all authenticated + CSRF on mutations; zod validation; audit on mutations):
| Area | Endpoints |
|---|---|
| health | GET /health → {ok, version, agent:'aos'} (public) · GET /system/status (db latency, worker liveness = any lease heartbeat or workers heartbeat row < 60 s old — add a small workers(id, last_seen, version) table via a new migration, written by the worker — provider key presence booleans, counts) |
| projects | GET/POST /projects · GET/PATCH/DELETE /projects/:id (DELETE unregisters only). create = mkdir + git init; existing; clone (URL whitelist https/ssh/git@/gitsrv:, GIT_TERMINAL_PROMPT=0, argv arrays). Paths confined to AOS_WORKSPACE_ROOT (same path-safety rules) |
| models | GET /models → ModelInfo[] (cached 10 min) |
| missions | GET /missions?status=&projectId= · POST /missions (CreateMission; refuses when today's spend ≥ daily cap; default model/effort/budget from settings) · GET /missions/:id · POST /missions/:id/cancel · GET /missions/:id/runs → RunDto[] · GET /runs/:id · GET /runs/:id/steps?after=&limit= → StepDto[] (request/result bodies truncated to 20 000 chars each with a truncated flag) · POST /runs/:id/message {text} (steering) · GET /runs/:id/artifacts · GET /artifacts/:sha256?name= (download, attachment, nosniff) |
| waits | GET /waits?status=pending → WaitDto[] · POST /waits/:id/resolve (ResolveWait) |
| policy | GET/POST /policy/rules · DELETE /policy/rules/:id |
| spend | GET /spend → SpendSummary |
| events | WS /api/v1/events?cursor=<lastEventId>&missionId=<optional> — same auth as Claude Remote's WS (cookie, Origin, re-validation). On connect: replays persisted events with id > cursor (bounded 5 000, then system.notice "gap, refetch"), then live events from listenEvents; transient model.delta forwarded with id:-1. JSON AosEvent per frame. Slow consumers dropped. |
Server tests: keep and adapt the auth/security suite (every route 401 without session — iterate the route table), plus missions/waits/policy/projects/events (cursor replay, auth, no secret leakage: assert responses never contain the configured API keys), path validation, daily cap refusal, artifact download headers.
9. Web (apps/web) — adapted from the Claude Remote console
Keep: design system (light default, dark, system), auth/setup/login flows, app shell, PWA, command palette, notifications, service worker, CSP constraints (static export, webpack build, zod jitless, no inline scripts, self-hosted assets). Remove terminal, sessions, files/editor, tasks, cluster, skills, MCP pages and the agent branding switch. Accent for Agent OS: a third identity distinct from Claude Remote (indigo) and Codex Remote (emerald): amber → orange gradient with matching tokens in both themes (AA contrast), new icon set + manifest ("Agent OS").
Routes: / → /home/; /home/ (needs-you approvals/questions first, running missions with live spend,
today's spend vs cap, system health); /missions/ (list, filters, create dialog: project, objective, optional done
criteria, model picker from /models grouped by provider with prices, effort, budget, network toggle with a clear
risk note); /mission/?id= (header: status, spend/budget bar, criteria checklist with evidence; run tree
(single node now, tree-ready); live timeline of the selected run: model turns (text + collapsible operational
reasoning summary), tool calls with input, output (collapsed, monospace, artifacts downloadable), diffs for
edit_file/git_diff rendered as diffs, policy decisions, waits inline with approve/deny/answer controls;
streaming deltas; composer to send a steering message; cancel); /approvals/ (inbox: exact tool call, risk badge,
diff, approve / deny / approve-and-remember, answer questions); /projects/; /policy/ (rules CRUD);
/spend/ (today vs cap, 7 days, by model — real numbers only); /system/; /settings/ (defaults, budgets,
security devices). Phone-first: Home and Approvals must be excellent on a 390-px screen (approve from the lock-screen
notification path is out of scope). Events via one shared WS with cursor resume (persist last cursor in memory;
on reconnect resume without gaps; on system.notice gap → refetch). Never render remote images or auto-link
URLs from model/tool output (plain text; code blocks escaped).
Dev mock (apps/web/dev/mock-server.mjs) updated to this API with a scripted mission (fixture clearly labelled).
Playwright (mock): setup/login, create mission, live timeline renders streamed turn + tool call + diff, approval
flow from Home on phone viewport, question answer, cancel, spend page, theme, CSP-clean on every route,
screenshots (light/dark, phone/desktop) reviewed by the agent.
10. As built — deviations and additions (integration, 2026-11-10)
The sections above are the contract the workstreams were given. Where the code differs, the code is the truth; this list is what a reader of §1–§9 must know. Nothing below changes a frozen shape incompatibly.
§1 Env. Also read: NODE_ENV (server), AOS_ENV_FILE (server + worker: a KEY=VALUE file loaded at
start, never overriding the environment), AOS_SANDBOX_EXTRA_DENY_READ (engine: comma list of absolute
paths the sandboxed shell must not read), AOS_MIGRATIONS_DIR (kernel). Full table: docs/deployment.md.
§2 Kernel.
- Step kinds journaled in practice:
setup(run_meta,prefix,git_branch),owner_messages(request{afterId}, result{messages:[{id,text}]}),model_call,policy(policy_rules,remember_rule),tool_call,wait(nameapproval|question, request{kind, payload, timeoutSec}, result = the resolution or{expired:true}),timer(sleep),now({iso}),uuid({uuid}).now,uuidandowner_messagesemit nostep.*event. StepSpec.assumedResult?(addition): the result recorded when the owner answers "it ran — continue".- Reconcile question payload:
{text, options: RECONCILE_OPTIONS, reconcile: true, stepSeq, stepKind, stepName}. step.started/step.completedevents carryrequest/resultwhen the body is ≤ 16 KiB (truncatedflag otherwise);run.usage={usage, budget, missionSpentUsd};wait.created={waitId, kind, payload, resolveBy};wait.resolved={waitId, kind, status, resolution?, actor?}.- Every function takes an optional trailing
Kernelhandle (createKernel({databaseUrl, dataDir}));listenEvents(cb, {kernel})returns{ready, cursor(), close()};openArtifactreturns{size, mime, path, stream()};resolveWaitreturns{resolved, status, runId}. Migrations011_workers.sql,012_kernel_emit_index.sqlwere added. - A step interrupted by cancel / shutdown / lost lease stays
started(it has no result); it is not marked failed.
§4 Tools. Tool.run returns ToolRunResult {ok, output, trust?, fileStateOut?, diff?, control?};
ToolContext gains optional fileState, getArtifact, sandbox. Extra tools: propose_criteria,
read_artifact. effect / risk may depend on the mission network (shell). A tool_call step records
{ok, content, artifact, trust, fileStateOut?, diff?} (control tools: {ok, content, criteria?, changed?}).
The shell's exit code is the first line of content: [exit code N] (with timed out…, output exceeded…,
cancelled, killed by SIG… before it when they apply).
§5 Engine.
model_callrequest is a digest{model, effort, turn, transcriptSha256, toolNames}(never the transcript); the result is{blocks, stop, usage, model, native, refusal?, nativeStop, attempts}.- Each execution of a model step starts with a transient
model.delta {reset:true, turn}; deltas carryturn. - A tool call that is refused before running (unknown tool, invalid input, policy deny, denied / expired
approval) has no step: the refusal is only the model's next context (+ a
policy.decisionevent).ask_ownerhas notool_callstep either (only itswaitstep);propose_criteriajournals one only when the owner accepts. - Criteria changes are announced by
system.notice {code:'criteria', message, doneCriteria}. - Questions:
ask_owner{text, options?, source:'ask_owner'}; criteria proposal{text, detail, options:['Yes — use these criteria'], source:'propose_criteria', proposed}(any reply that is not an acceptance sends the agent back to revise); stall{text, detail, options:['Let it continue','Stop the mission'], source:'stall'}. - Model failures: retryable provider errors that outlive the adapter's retries are retried after durable
sleeps (30 s, 2 min, 5 min); a missing provider key ends the run
blocked.
§6 Policy (owner decision). Order: hard denies → any matching deny rule, project or global →
any matching ask → any matching allow → defaults by risk. A global deny beats a project allow.
A hard deny also covers every non-read tool while the done criteria are not confirmed.
policy.decision payload: {tool, callId, risk, decision, reason, source: 'hard'|'rule'|'default'|'owner'|'expiry', ruleId?, remembered?, rule?};
the event has seq: null. Approval wait payload: {tool, input, risk, title, detail, reason, diff?}.
§8 Server.
kernel-adapter.ts/providers-adapter.tsuse ordinary typed imports of@aos/kernel/@aos/providers; the adapter test runs in the default suite; the server inserts no event itself.MissionDtogains optionalprovider,model,effort(of the root run) —@aos/shared, additive.nativeis removed frommodel_callstep results and fromstep.completedevents before they are sent to the browser (browser-view.ts).StepDtois sent with an extratruncated: boolean. Server-local DTOs (SystemStatus,ArtifactDto,ModelInfoEx,UpdateProject,SteeringMessage) live inapps/server/src/dto.ts.SystemStatus={collectedAt, version, db{ok,latencyMs}, worker{alive|null,lastSeenAt,source,workers[]}, providers{anthropic,openai}, counts{missionsQueued,missionsRunning,missionsWaiting,pendingWaits,wsClients}}.- WebSocket notices are
system.noticeevents withid:-1,missionId:'':{code:'gap', message, latestId}and{code:'auth.expired'}(then close 4401).policy.decisionevents are replayed over the WebSocket by cursor only; there is no REST endpoint for them. - Extra routes:
GET /system/audit,GET/PUT /settings,GET /notifications,POST /notifications/read,GET /openapi.json.
§9 Web. The timeline also shows tool calls the model issued that were never executed ("not executed"),
derived from the model step's blocks minus the journaled steps; a step still started when its run has
ended is shown "interrupted". The dev mock emits the real shapes listed here.