This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
# Install dependencies
uv sync --dev
# Run checks (do all three before every commit)
uv run mypy src tests
uv run ruff check src tests && uv run ruff format --check src tests
uv run pytest tests/unit -q # hundreds of test files, fast, no Docker needed (CI runs -n 4)
# E2E tests (need Docker for testcontainer Postgres + sandbox)
DOCKER_HOST=unix:///Users/tom/.docker/run/docker.sock uv run pytest tests/e2e -q
# Run a single test
uv run pytest tests/unit/test_context.py::TestBuildMessages::test_simple_conversation -xvs
# Migrations (aios migrate also applies the procrastinate schema +
# lock-release trigger; bare `alembic upgrade head` does not)
set -a && source .env && set +a
uv run aios migrate
# Start the system (two processes)
uv run python -m aios api # API server on :8080 (AIOS_API_PORT)
uv run python -m aios worker # procrastinate workerEvery git worktree gets its own isolated DB on the shared local Postgres via
aios dev bootstrap. The first step of any worktree smoke is to bootstrap,
not to source .env. Sourcing the main checkout's .env from a worktree
silently points you at the shared dev DB (localhost:5433/aios) — real
connection records and live traffic land in a shared/prod-shadow DB (#349).
Sanctioned worktree-smoke sequence:
uv run aios dev bootstrap # provisions aios_dev_<id> + writes worktree-local .env
# open a FRESH shell (no stale AIOS_* exports)
set -a && source .env && set +a # the WORKTREE-LOCAL .env, written by bootstrap
uv run aios dev status # sanity check: expect `mode: isolated`
uv run python -m aios api # / aios workeraios dev status prints a mode: line — isolated (bootstrapped, on its own
aios_dev_* DB), shared (linked worktree pointed at the shared dev DB — fix
before proceeding), or unbootstrapped (run aios dev bootstrap). As a
backstop, aios api / aios worker hard-fail when started from a linked
worktree against the shared DB; override with AIOS_ALLOW_SHARED_DB=1 only if
you really mean it.
aios is a typer-based client CLI that talks to a running API. Config is read from env (AIOS_URL, AIOS_API_KEY) or .env; every command accepts --url / --api-key overrides and a global --format {table,json}.
# Reachability + auth check
uv run aios status
# Resource inspection
uv run aios agents list
uv run aios agents get <agent_id>
uv run aios sessions list --status running
uv run aios sessions events <session_id> --kind message
# Interactive chat (creates a session, streams the reply)
uv run aios chat --agent <agent_id> --environment-id <env_id>
# One-shot: send a message and stream until the turn ends
uv run aios chat --agent <agent_id> --environment-id <env_id> -m "list /workspace"
# Tail a running session from another terminal
uv run aios sessions stream <session_id>
# Post a user message without entering the REPL
uv run aios sessions send <session_id> "hello"
# Create resources from JSON (thin wire — server validates)
uv run aios agents create --file agent.json
uv run aios skills create --dir path/to/my-skill --title "My Skill"Every resource has CRUD subcommands. See aios <resource> --help for specifics. Operator commands (aios api, aios worker, aios migrate) are also typer-backed but keep their original behavior.
Two pre-commit invariants are checked in CI; both fail if the committed artifact drifts from its source-of-truth:
openapi.jsonis regenerated from FastAPI introspection. Fix on drift:./scripts/regen-openapi.sh && git add openapi.json.packages/aios-sdk/aios_sdk/_generated/is regenerated fromopenapi.json. Fix on drift:./scripts/regen-client.sh && git add packages/aios-sdk(also regenerates openapi.json as a prerequisite).
Trigger: any API route, response model, error envelope, or pydantic schema change that flows through FastAPI introspection. Run both before pushing when you've touched API-layer code.
-
Migration failure fails API candidate admission. The API image starts its candidate through
aios-deploy-entrypoint --candidate aios api, using the new container's baked-in migration scripts.aios migrateretries bounded lock contention and exits non-zero when attempts are exhausted; the entrypoint propagates that status without starting the API, so the candidate cannot become healthy or be promoted. Ordinaryaios apistartup does not migrate: this keeps application-image rollback startable against a forward-migrated database. Automated rollback must never run migration downgrades. -
There is no new-code/old-schema serving window. The candidate API does not start until its migrations complete. Migrations must nevertheless remain compatible with the old application image because that image can continue serving during admission and can be selected for application rollback.
-
Destructive migrations need expand/contract. For
drop column,drop table,change type, etc., the old image may still query a removed shape while the candidate is being admitted or after application rollback. A PR that introduces a destructive migration MUST either:- Split into three deploys: deploy A (code handles both old and new schema) → deploy B (apply the migration through candidate admission) → deploy C (code requires the new schema), OR
- Ship together with code that handles both schemas across the rollout: read-tolerant of either form, write-tolerant of either form.
-
Only aios-api owns migrations. aios-worker, aios-signal, aios-telegram, and aios-whatsapp do not run migrations. The eumemic-ops
coolify-flagsaudit enforces this constellation-wide: any non-aios-api app withmigratein either its pre- or post-deployment command fails the audit.
Operator-facing background lives in eumemic-ops at
skills/coolify-api/references/db-direct.md §"Run migrations against the new
code (use post_deployment_command)".
aios is an event-driven agent runtime. The headline property: every tool is implicitly async — the model stays responsive to user messages even while tools are running.
There is no loop. A procrastinate wake_session job runs run_session_step() which calls the model once, kicks off tool calls as fire-and-forget asyncio tasks, and returns. Tool completion appends a result event and defers a new wake. The "loop" is the job queue re-entering the step function.
Key flow: wake_session → run_session_step (loop.py) → stream_litellm (completion.py) → launch_tool_calls (tool_dispatch.py) → tool tasks append results → defer_wake → next step.
- API process (
aios api): FastAPI server. Handles HTTP requests, appends user messages, defers wake jobs, serves SSE streams. Does NOT run tools or call models. - Worker process (
aios worker): Runs procrastinate jobs. Calls the model, dispatches tools, manages sandbox containers. Holds worker-scoped globals onharness/runtime.py.
Both share Postgres: same database, same LISTEN/NOTIFY, same procrastinate job queue.
harness/context.py builds the chat-completions message list from the event log. It's the most critical file in the harness:
- Monotonicity invariant: appending events only appends to the context, never rewrites earlier messages (prompt cache stability).
- Pending-result synthesis: in-flight tool calls get synthetic
"pending"results so the message structure is valid. - Blind-spot injection: tool results that arrived during inference are injected as user messages at the tail.
reacting_towatermark: each assistant message records the max seq of events it saw, sofind_sessions_needing_inferenceknows what's "new."
Tool calls the harness doesn't execute itself — type="custom" (client-executed) and always_ask-gated built-ins/MCP awaiting operator approval — sit unresolved in the event log alongside any async in-flight built-in. The session does NOT park. End of step flips status="idle" with stop_reason={"type":"end_turn"} regardless of what the model emitted; pending tool calls are observable via the derived Session.awaiting view.
The same wake mechanism drives all three cases:
- In-flight async built-in: task's
_tool_lifecycle.finallyappends the result and triggers a sweep. - Custom tool: client POSTs to
/sessions/:id/tool-results(operator) or/connectors/runtime/tool-results(connector runtime) — both calldefer_wakeafter appending. - always_ask awaiting confirm: operator POSTs to
/sessions/:id/tool-confirmations; allow appends a lifecycle event that the next step's_dispatch_confirmed_toolsconsumes.
A user message arriving during any of these wakes the session via the same find_sessions_needing_inference short-circuit (sweep.py — if any(evt.role == "user")). The model sees pending tool calls via the _PENDING_BACKGROUND / _PENDING_EXTERNAL synthesized placeholders in build_messages and can interleave responses freely.
Built-in and externally-executed tools can be called in the same response. The inference gate waits for all tool_call_ids before re-firing under the no-user-stimulus path; a user message lifts that wait.
The worker runs two Postgres connection pools:
- asyncpg (ours): event log, session state, all application queries. The pool sets
statement_timeout/idle_in_transaction_session_timeoutand TCP keepalive GUCs viaserver_settings(seedb/pool.py); the psycopg3/migrate path deliberately does not. - psycopg3 (procrastinate's): job queue. Separate connector, separate connections.
They don't share connections; they share Postgres state.
The durable workflow runtime runs deterministic, replayable scripts as an append-only journal with replay-from-memo — a different shape from the session step. A workflow run is a row plus its wf_run_events journal; append_run_event (db/queries/workflows.py) is the single writer to that journal, allocating a gapless seq and idempotent on (run_id, call_key, type). run_workflow_step (workflows/step.py) rebuilds the memo from the call_result journal events and hands it to the script host, which fast-forwards already-resolved capabilities by .send()-ing results into the script coroutine. No model is called in a step — it is pure deterministic replay.
- Credential-free out-of-process host:
run_script_host(workflows/host_launcher.py) spawnspython -m aios.workflows.wf_script_hostas a separate subprocess under a deny-by-default env allowlist (_CHILD_ENV_ALLOWLIST). The child imports only stdlib +aios.workflows.determinism/._protocol— neveraios.crypto/aios.db/aios.harness/aios.services— so the masterCryptoBoxand the all-accounts pool are never inherited. The parent enforces a wall-clockSIGKILLdeadline. - Per-run compute ceiling (#780):
_scaled_seconds(base, init_len)(host_launcher.py) scales the subprocess deadline by INIT-frame size (DEFAULT_DEADLINE_SECONDS=30,DEADLINE_SECONDS_PER_INIT_MIB=30, capped atMAX_SCALED_SECONDS=600) — a run replaying a large accumulated memo gets proportionally more time. - Attenuation clamp (#794): a step computes
child_surface = attenuation_service.clamp(surface_of(agent), surface_of(run))(step.py);clamp(services/attenuation.py) is the lattice meet, so a child session's authority never exceeds the run's snapshotted surface. The result is written immutably to the childsession.surface. - Journaled
log()/phase()(#783): scriptlog()/phase()→ ANNOTATION frames (workflows/_protocol.py) → journaled bystep.pyastype="annotation"events (WfRunEventTypeinmodels/workflows.py), keyed bycall_keyso the(run_id, call_key, type)unique constraint dedups them to emit-once across replays.
Where it lives: step = workflows/step.py (run_workflow_step); sweep = workflows/sweep.py (wake_runs_needing_step → list_run_ids_needing_step in db/queries/workflows.py); queries = db/queries/workflows.py; job = wake_workflow (harness/tasks.py).
Shares vs. diverges from session wake: it shares the procrastinate job queue, lock=run_id / queueing_lock=run_id, LISTEN/NOTIFY, and commit-before-NOTIFY. It diverges: workflow wake is defer_run_wake (services/wake.py) which appends no journal span (wf_run_events is single-writer), supports batch=True coalescing (#780, workflow_wake_batch_seconds), and runs at _BACKGROUND_PRIORITY=-10 so it yields to interactive sessions; and the step is deterministic replay with no model call plus the attenuation clamp — unlike the session step.
- Python 3.13+ with PEP 695 type parameter syntax:
class ListResponse[T](BaseModel),def _list_scoped[T](...). Do NOT useTypeVar+Generic[T]. from __future__ import annotationsat the top of every source file.- Raw SQL + asyncpg, no ORM. Every query lives in the
db/queriespackage — one module per resource (db/queries/sessions.py,events.py,connections.py,vaults.py,triggers.py, …). Shared tenant-scoping helpers (_get_scoped,_list_scoped,_archive_scoped,_build_set_assignments,parse_jsonb,_escape_like) live indb/queries/__init__.pyand are imported by the per-resource modules. Every public query is re-exported from the package root, sofrom aios.db import queries; queries.foo()andfrom aios.db.queries import fooboth work and stay patchable (patch.object(queries, "foo", ...)). pg_notify($1, $2)function form, never literalNOTIFY. Postgres case-folds unquoted identifiers; our ULIDs have uppercase.- structlog with
structlog.stdlib.LoggerFactory(). NOTPrintLoggerFactory. - Extreme simplicity. No defensive guards for model mistakes, no fuzzy matching, no model-specific shims. The model sees raw errors and retries through the session log. Ask: "can the model handle this failure itself?" If yes, don't add complexity.
- Events are OpenAI chat-completions format, not Anthropic Messages format. LiteLLM translates at the provider boundary.
- Conventional commits:
feat:,fix:,refactor:, etc. Substantial commit bodies. - Co-Authored-By trailer on AI-authored commits.
- Ask non-trivial design decisions before writing code.
- Investigate before fixing — when something behaves oddly (unexpected errors, stuck jobs, state inconsistencies), find the root cause first. Don't kill processes, delete rows, or reset state as a shortcut.
- Fail hard, no fallbacks — no silent skipping, no dummy values, no error suppression. The model sees raw errors and retries through the session log; that IS the design.
- Correct-by-construction — the Key invariants below (gapless seq, monotonic context, tool-always-appends-result) are examples of this stance. Prefer designs that produce valid state in one pass over multi-stage corrective pipelines.
- Unify toward minimal primitives; compose, don't accrete — favor a small set of orthogonal primitives whose compositions express the full space of desired behavior, over a growing catalog of ad-hoc features. When a new capability is asked for, first ask: is it a composition of existing primitives, or a small generalization of one? Add a new mechanism only when it genuinely isn't. Encode variation as a kind (a discriminated arm), never a boolean flag, so illegal combinations are unrepresentable rather than runtime-guarded — the
triggersaction union is the model. This is the architecture-altitude complement of "premature abstractions are still bad," not its contradiction: unify only once you've found the right primitive — one that dissolves special cases and makes invalid states impossible — never to pre-emptively DRY three similar lines. - Push back on bad requests — if a request conflicts with existing architecture, or has an obvious root-cause fix the user missed, flag it before implementing. You have context the user may not in the moment.
- Don't deprecate, delete — remove old code paths rather than leaving shims. Git history preserves them.
- After refactors, grep exhaustively — for any rename or move, search the whole tree (including tests) for the old name before declaring done.
- Seek the perfected design, not the smallest diff — when the "minimal change" is shaped around avoiding some adjacent system (branch-protection rules, schema migrations, default settings, fixture organization, an upstream config someone else owns), that adjacent system is usually in scope. Update it. The right configuration at the time of writing compounds; the workaround accumulates and obscures intent. Constraints that feel like "I don't want to mess with X" are almost always negotiable — interrogate them, don't accept them. Proactive complement to broken windows: not just "fix what's flagged" but "build it as if from scratch every time." Scope discipline still applies — pick the right shape for the change, but its breadth should still match the task.
- Broken windows policy — when a review (or your own pass) flags a clear quality issue, fix it now even if it's pre-existing. Don't pile new code on top of broken patterns or label them "out of scope." This complements rather than contradicts "don't add features beyond the task": the rule is reactive, not proactive — you don't go hunting for cleanup, but you don't ignore what you've already seen. Premature abstractions are still bad; three similar lines is still better than one over-engineered helper. The policy targets flagged issues, not aesthetic preferences.
- Gapless seq per session — every
append_eventlocks the session row, incrementslast_event_seq, inserts. No gaps. - Monotonic context — the context is a monotonic function of the log. Prompt cache stays stable within a windowing chunk.
reacting_towatermark — every assistant message carries the seq of the latest user/tool event in its context.- Tool-always-appends-result — every async tool task MUST append exactly one tool_result event and defer a wake (try/except/finally).
- Procrastinate lock —
lock="{session_id}"for mutual exclusion,queueing_lock="{session_id}"for wake deduplication. - NOTIFY after commit —
pg_notifyfires outside the transaction so subscribers don't see uncommitted rows. - Request recoverability — every request a session composes for its own inference is recoverable from durable state: the log, plus the
requestrecord on the span that opened the send (model_request_start, or themodel_workflow_parkrecord), plus its per-accountrequest_blobs(harness/request_capture.py).services/requests.rebuild_requestrecomposes it with the samebuild_messages→finalize_messagesthe step uses. At the sameRENDER_VERSIONthe rebuild is byte-for-byte, andpayload_shasays whether it was. Any rendering change must bumpRENDER_VERSION(the golden testtests/unit/test_render_golden.pyenforces it).payload_shacovers the composed request ({messages, tools, params}, without an inlineapi_key). The wire bytes are different: cache breakpoints, provider kwargs and body-budget eviction come after capture.context_admission_payload_digestonmodel_request_endis the separate wire-level digest, and it includes auth.
All aios settings use the AIOS_ prefix (Pydantic settings with env_prefix="AIOS_"):
AIOS_API_KEY— bearer auth key. Must hash to a row inaccount_keys; auth is no longer an env-var direct compare. On a fresh DB, mint one by POSTing to/v1/accounts/bootstrap(gated byAIOS_BOOTSTRAP_TOKEN) and store the returnedplaintext_keyasAIOS_API_KEYfor both the API service and clients. A placeholder value (e.g.test-aios-key-do-not-deploy) will silently 401 against every request.AIOS_BOOTSTRAP_TOKEN— bearer token that gatesPOST /v1/accounts/bootstrap; required to mint the root account's first API key on a fresh DB.AIOS_VAULT_KEY— base64-encoded 32-byte libsodium key for encrypted database rows. Rotate withAIOS_VAULT_KEY_PREVIOUSplusaios rekey; do NOT regenerate without the rekey run if Postgres has encrypted data.AIOS_VAULT_KEY_PREVIOUS— optional decrypt-only previous vault key used duringaios rekey; unset after rotation completes.AIOS_EGRESS_CA_KEY— required base64-encoded 32-byte key for the sandbox egress CA (sandbox/egress_ca.py). Separate fromAIOS_VAULT_KEYso vault-key holders cannot mint sandbox-trusted TLS certificates.AIOS_DB_URL— Postgres connection stringAIOS_API_PORT— default 8080AIOS_TAVILY_API_KEY— for web_fetch/web_search toolsAIOS_AUTO_REVIEW_MODEL/AIOS_AUTO_REVIEW_TIMEOUT_S/AIOS_AUTO_REVIEW_STRANDED_AFTER_S— theauto_reviewMCP checker: model (LiteLLM string, no default — unset means everyauto_reviewcall fails closed to a card, so set it when enabling the policy), total verdict budget (5s, one retry inside), and the sweep's stranded-review fail-closed bound (60s).
Model provider keys use standard LiteLLM env vars (no AIOS_ prefix): OPENROUTER_API_KEY, OPENAI_API_KEY, ANTHROPIC_API_KEY, etc. These are the root-owned fallback: per-account keys and proxy base URLs live in the model_providers resource (/v1/model-providers), resolved nearest-ancestor-wins up the account tree at model-call time and encrypted per-account via the same subkey scheme as vaults. A model_providers row is looked up whole (key + api_base together, never field-merged across accounts) — see aios.services.model_providers and the conflict guard it runs before every model call.