Architecture
How SAT is built, from system context down to tables, and the design decisions behind it.
SAT is a control plane for an AI agent company. The API is the system of record: it stores companies, agents, goals, tasks, runs, approvals, routines and the audit log, and it enforces budgets. It does not run agents and never calls a model. Starting work only queues a run. A runner, such as sat runner from apps/cli or any client that follows the runner protocol, claims queued runs, executes them with an agent CLI, and reports back.
The diagrams follow the C4 model: context, containers, components, deployment. They are drawn as flowcharts with C4-style boxes so they render everywhere.
System context
Who uses SAT and what it talks to.
Containers
The deployable pieces. The runner is part of the sat CLI and runs on your machines, not in Google Cloud.
| Container | Code | Runs where |
|---|---|---|
| Web control plane | apps/web | Static files on Firebase Hosting |
| SAT API | apps/api (entry point main:app) | A Cloud Run service (or any container host), one instance |
| Database | Alembic migrations in apps/api/alembic | PostgreSQL 16 (for example Cloud SQL), private IP |
| Event bus | apps/api/company/events.py | In the API process by default; Redis when EVENTS_BACKEND=redis |
| CLI, runner, scheduler | apps/cli | Operator laptops, CI, or any machine with the agent CLIs installed |
Components of the API
The agent-company domain lives in apps/api/company. Authentication lives in apps/api/api/auth_endpoints.py, services/auth_service.py and services/api_keys.py.
| Component | File | Responsibility |
|---|---|---|
| Routers | company/routers/{companies,agents,tasks,runs,insights}.py | HTTP endpoints. Mounted in this order by company/routers/__init__.py. Operation ids are the Python function names. |
| Dependencies | company/deps.py | Resolves the caller's membership (CompanyContext), require_admin, get_owned (loads a row only if it belongs to the company), error helpers. |
| Services | company/services.py | queue_run (reuses a queued run for the same agent, task and routine, whatever the trigger), next_task_number (row lock on the company), enforce_agent_budget, finish_run, expire_stale_runs, run_actor. |
| Audit | company/audit.py | record adds an AuditEvent to the session; publish sends the SSE event after commit; diff computes {field: [old, new]}. |
| Events | company/events.py | InMemoryBus (default) or RedisBus, chosen by EVENTS_BACKEND at import time. |
| Schemas | company/schemas.py | Pydantic request and response models and the status literals. |
| Models | database/models.py, database/company_models.py | SQLAlchemy tables. |
Deployment
SAT ships as two deployable parts: a static web build (apps/web/dist) and an API container image (apps/api/Dockerfile). Anything that serves static files and runs one container next to a PostgreSQL 16 database can host it. The reference shape, which this documentation assumes where it names Google Cloud services, is Firebase Hosting for the web app, Cloud Run for the API and Cloud SQL for PostgreSQL for the database.
To run it yourself:
- Database. Create a PostgreSQL 16 database and a user for the API. The API applies its own migrations at startup. See Database.
- API. Deploy the image to Cloud Run (or any container host) with
DATABASE_URL,JWT_SECRET_KEY,APP_ENV,ENVIRONMENT,ALLOWED_ORIGINSandWEB_APP_URLset, secrets coming from your secret store, and at most one instance whileEVENTS_BACKEND=memory. See Configuration. - Web app. Build
apps/webwithVITE_EVENTS_ORIGINset to the API's direct URL and serveapps/web/dist, rewriting/api/**and/healthto the API.
Live updates bypass Firebase Hosting
Firebase Hosting buffers responses that it rewrites to Cloud Run, so server-sent events would arrive late or never. Set VITE_EVENTS_ORIGIN in the web build to the API's Cloud Run URL, and the browser opens the event stream there directly. Every other request goes through the Hosting rewrite on the same origin. The browser must therefore reach the Cloud Run service without Google credentials, so the service must allow unauthenticated invocation. The API's own authentication still applies to every route except the public ones: /, /health, registration, sign-in, token refresh and password reset.
The hosted Yarlis environments are deployed by the Yarlis release system; Yarlis staff: see the internal SAT runbook on the staff portal (portal.yarlis.com/docs).
Data model
All tables live in one PostgreSQL database. Every company-scoped table has a company_id foreign key, and every query filters on it. Money columns are integer cents; a budget of 0 means no limit. Primary keys are UUIDs.
Notes on the model:
- Unique constraints.
company_members (company_id, user_id)andtasks (company_id, number). A task key such asNF-12iscompanies.task_prefixplustasks.number; it is computed, not stored. - Shared tables.
agentsandprojectspredate the company model. Their older columns (agents.type,agents.session_id,projects.tech_stack, and others) are still present andagents.typeis stillNOT NULL, so the API copiesroleinto it.company_idis nullable on both tables for the same reason. - Approvals also reference
requested_by_agent_id,requested_by_user_idanddecided_by_user_id. - Runs carry
transcriptas a JSON array of{ts, role, text}entries in the same row. Long runs grow that row; see Limits. - Legacy tables created by migration
001and not used by the company domain:sessions,session_logs,agent_tasks,deployments,system_metrics,system_health_checks.
See Database for migrations.
Request flow
A typical write, assigning a task to Grace in the NoteFlow company:
- Authentication.
get_current_userreads the Bearer token. A token starting withsat_live_orsat_test_is looked up as an API key by its SHA-256; anything else is decoded as an HS256 JWT. - Authorization.
get_company_contextloads the caller'scompany_membersrow for the company in the path. No row means404 NOT_FOUND. - Write and audit. The router changes rows and calls
audit.record, which adds anaudit_eventsrow in the same transaction. - Commit, then publish. After
COMMIT,audit.publishsends{"type": "<entity>.<verb>", "id": ...}on the company channel, so a subscriber that refetches never sees the old row. - Invalidate. The web app maps each event type to the SWR caches it makes stale and refetches them (
apps/web/src/lib/live.ts). The runner usesrun.createdandagent.updatedto try a claim, andrun.updatedwithstatus: cancelledto stop a run.
Design decisions
The API records; runners execute
The API never runs an agent, holds a model key or makes outbound calls to an LLM. It queues runs and accepts reports. Execution happens in sat runner on machines you control, with the agent CLIs and credentials already installed there.
- Why. Agent work needs a filesystem, git, long-running processes and model credentials. Keeping those out of the API keeps the API stateless apart from the database, cheap to run on one small Cloud Run instance, and free of customer model keys.
- Consequence. Nothing happens unless a runner is running. A run stays
queueduntil a runner that serves its agent's adapter claims it. - Safety. Claims are atomic (
FOR UPDATE SKIP LOCKEDplus astatus = 'queued'guard on the update), so two runners never execute the same run. Leases (runner_id,heartbeat_at,RUN_LEASE_SECONDS) fail runs whose runner disappeared, so an agent is never stuck behind a dead run.
Events are invalidations, not data
Each SSE message carries only the event type, the entity id and a few scalar hints (for example status and agent_id on run.updated). Clients refetch the data they display.
- Why. Clients never apply partial state, so they cannot drift from the database. A missed event costs one stale view until the next event or refetch, not corrupted state.
- Consequence. Events are not a durable log. There is no replay, no event id and no
Last-Event-IDsupport. Use the activity log (GET /activity) for history.
In-memory event bus by default
InMemoryBus delivers events to subscribers in the same process only. Each subscriber gets an asyncio.Queue with room for 1,000 events.
- Why. One Cloud Run instance needs no extra infrastructure, and SAT does not require Redis.
- Consequence. The API must run as exactly one instance. A second instance would split SSE clients across two buses. Deploy with
--max-instances 1, and do not split traffic between two revisions. To run more instances, setEVENTS_BACKEND=redis; see Scaling.
Membership is the only boundary
Every company path is resolved through the caller's membership. Non-members get 404, not 403, so company ids cannot be probed. See Security.
Crons are evaluated outside the API
Routines and agent heartbeats store a cron expression, but the API never evaluates it. sat scheduler does, and calls the same endpoints a person would. See Scheduler.
Related
Health Check GET
Basic health check endpoint Returns service status without requiring authentication. Responds 503 when the database is unreachable or startup migrations failed, so deploy health gates (Tollgate's canary check) refuse a broken revision.
Configuration
Every environment variable read by the SAT API, the web build, the sat CLI and the runner, with defaults and production requirements.