AI Workforce OS Architecture
AI Workforce OS separates deciding from doing: a Go backend is the source of truth, a Python runtime only reasons with the model, and a Next.js frontend draws the office from events. This page is for anyone who deploys, extends or audits the system: how the pieces fit and how a request travels end to end.
On this page
- Components
- Backend and runtime boundary
- Request flow
- Realtime channel
- Data model at a glance
- Multi-tenancy
- Simulation versus real AI
- Deployment and edge
- Repository layout
Components#
All business state lives in the backend; the runtime keeps no state between calls. Postgres, Redis, object storage and the sandbox are optional in development and each has a fallback.
| Component | Technology | Responsibility |
|---|---|---|
| Frontend | Next.js, React Three Fiber | 3D office and dashboard mode. Consumes REST and the WebSocket; every animation derives from real events. |
| Backend | Go, chi | REST API /api/v1, WebSocket /ws, orchestrator, approvals, audit, budget, authentication and RBAC. |
| Agent runtime | Python, FastAPI, CrewAI | Receives a task and context, calls the model provider and returns a structured result. |
| Postgres | PostgreSQL | Data for all organizations, with row-level security and numbered SQL migrations. Without DATABASE_URL the backend runs in memory for demos. |
| Redis | Redis | Event pub/sub to the WebSocket hub, locks and rate limits. Without REDIS_URL, events and locks are in-process. |
| Object storage | S3-compatible (RustFS in development) | Drive file bytes. Without S3_ENDPOINT and S3_BUCKET they are stored in Postgres. |
| Sandbox runner | Go, Docker | Runs code that an agent asked for and someone approved, in disposable containers with no network. Only with SANDBOX_URL and SANDBOX_TOKEN. |
| Caddy | Caddy | Production proxy: automatic TLS, sends /api/* and /ws to the backend and everything else to the frontend. |
Backend and runtime boundary#
The core rule: the runtime never talks to Postgres, Redis or external systems. It receives a task with its context and returns a result, tool requests and usage; it only contacts the model provider. The backend decides permissions, autonomy and budget, and is the only one that executes tools.
The backend calls the runtime over HTTP with JSON at RUNTIME_URL. If RUNTIME_TOKEN is set on both sides, every call carries that secret as a bearer token. The AI engine sits behind an interface of its own (AgentEngine), so CrewAI can be replaced without touching the backend.
| Runtime endpoint | Use |
|---|---|
POST /v1/plan, /v1/plan-phases, /v1/plan-phase, /v1/replan | Break a request or a project into tasks with dependencies. |
POST /v1/run-task | Run a task; returns output, consults, tool_requests and usage. |
POST /v1/consult, /v1/review, /v1/counterargument | Agent-to-agent consultation, quality review and a counterargument for high-risk approvals. |
POST /v1/synthesize, /v1/summarize | Consolidated report and summaries. |
POST /v1/route, /v1/chat-reply | Route a chat message and generate an agent reply. |
POST /v1/estimate, /v1/context-sizes, /v1/provider-test | Cost estimation, prompt size and testing an organization's key. |
GET /healthz | Status and mode: simulation or live. |
Tip
The agent runtime has its own page with the full contract, the providers and the data policy.
Request flow#
The user's ask reaches the orchestrator, which asks the runtime for a plan, validates it and creates tasks. Each task is run by an agent that may request tools; sensitive actions wait for human approval, and a report is synthesized at the end.
- The user submits text with
POST /api/v1/requests(or through chat, which routes it). The backend answers202withrequest_idand emitsrequest.received. - The orchestrator asks the runtime for a plan and validates it before accepting it: duplicate keys, unknown or circular dependencies and maximum depth. It then emits
plan.createdandtask.created. - Tasks run respecting
depends_on. Each one changes the agent state (agent.state_changed) and may trigger agent consultations (message.sent) and a quality review (task.reviewed). - Tools requested by the runtime go through the autonomy policy and permissions. If approval is needed, a pending record is created (
approval.requested) and the task entersawaiting_approval. - A user with permission decides with
POST /api/v1/approvals/{id}/decision. The task resumes or is blocked. - When all tasks finish, the runtime synthesizes the report and the backend emits
report.createdandrequest.completed. - In parallel, the cost of every call is recorded and the budget is enforced:
budget.warningwarns andbudget.exceededpauses execution.
POST /api/v1/requests {"text": "..."} -> 202 {"request_id": "..."}
orchestrator -> runtime POST /v1/plan -> tasks + depends_on
backend validates the plan -> plan.created, task.created*
per task: runtime POST /v1/run-task -> output, consults, tool_requests, usage
tool_requests -> policy/autonomy check -> approval.requested (if needed)
POST /api/v1/approvals/{id}/decision -> approval.resolved, task resumes
runtime POST /v1/synthesize -> report.created, request.completed
Realtime channel#
GET /ws opens one WebSocket per organization. The backend publishes each event to Redis (channel aiworkforce:events) and a hub forwards the frame only to clients whose org_id matches; a frame without org_id is dropped. A frame with an audience reaches only those users.
On connect a hello event carries the agent list, followed by domain events. There is no replay, so the client must reload state over REST after reconnecting.
- The server pings every 30 s and closes if no pong arrives within 60 s.
- A slow client, with 256 frames queued, is disconnected.
- With authentication on, the socket closes with code
4401after 15 minutes (the default access-token lifetime) so the client reconnects with a fresh token.
{
"id": "<uuid>",
"type": "task.completed",
"ts": "2026-01-01T12:03:22Z",
"org_id": "<org id>",
"agent_id": "sales",
"payload": { "task": { "id": "...", "status": "done" } }
}
| Group | Event types |
|---|---|
| Connection | hello, error, metrics.updated, activity.logged |
| Requests | request.received, request.queued, request.dequeued, request.resumed, request.status_changed, request.completed, plan.created, report.created |
| Tasks | task.created, task.started, task.completed, task.failed, task.blocked, task.retrying, task.reviewed, task.steered, task.reassigned |
| Agents and chat | agent.created, agent.state_changed, message.sent, chat.message, chat.typing, route.decided, initiative.raised |
| Approvals | approval.requested, approval.progress, approval.reminder, approval.resolved |
| Costs | cost.estimated, budget.warning, budget.exceeded, budget.resumed, provider.no_balance |
| Projects | project.created, project.launched, project.delta, project.status_changed, project.budget_warning, project.plan_change_* |
| Rewards and office | coins.earned, shop.purchased, item.equipped, item.unequipped, office.item_placed, office.item_removed, office.layout_updated |
Note
The table groups the main events; some modules emit additional types with the same frame shape.
Data model at a glance#
Tables live in Postgres and every business table carries org_id. This is the summary view; the full schema is in the numbered migrations under backend/internal/infrastructure/postgres/migrations/.
| Entity | Main tables | Notes |
|---|---|---|
| Organizations and users | organizations, users, memberships, refresh_tokens, invitations | Roles per membership: owner, admin, member, viewer. |
| Agents | agents, agent_profiles, agent_settings, agent_controls | State, tools, permissions and autonomy level. |
| Requests and tasks | requests, tasks, request_runs, task_checkpoints, reports | Task states: pending, running, blocked, awaiting_approval, done, failed. |
| Approvals | approvals, standing_approvals, approval_executions | Double approval, per-project standing permissions and single execution of what was approved. |
| Projects | projects, project_templates, plan_changes | Plans with nodes, budget and template versions. |
| Memory | memories, memory_docs | Long-term memory per agent. |
| Skills | skills, skill_versions, skill_assignments | Versioned, with a review workflow. |
| Rewards | coin_wallets, coin_ledger, shop_inventory, shop_equipment, shop_office | Coins for finished work and character and office items. |
| Governance and cost | audit_logs, audit_chain_heads, usage_entries, budget_caps | Chained audit trail, usage ledger and caps. |
Multi-tenancy#
The org_id travels inside the access token and is never taken from client input. The HTTP layer puts it in the request context and every query is filtered by it. A user can belong to several organizations and switches between them by opening a new session.
As defense in depth, Postgres enforces row-level security:
- Each transaction sets
app.org_idwithset_config(..., true), local to the transaction; the policies compareorg_idwithapp_current_org(). - If the value is missing, queries see and write no rows: they fail closed.
- The application runs
SET ROLEto a non-owner role (DB_APP_ROLE, defaultapp_user, withoutBYPASSRLS); migrations useMIGRATE_DATABASE_URL. - The WebSocket hub also filters by organization.
Tip
The authentication and multi-tenancy page covers roles, sessions and the RLS policies in detail.
Simulation versus real AI#
The runtime's HTTP contract is the same in both modes; the engine changes. In simulation (with SIMULATION=true or with no provider configured) the runtime returns scripted results with simulated latencies, with no network and no cost. In live mode it uses the configured providers and its GET /healthz reports mode: live and the provider.
If a provider is configured but the live engine cannot start, the runtime answers in simulation with status: degraded and a warning; there is no silent fallback. The backend reflects the mode in its own GET /healthz as runtime_mode.
curl http://localhost:8080/healthz
# {"status":"ok","runtime":"up","runtime_mode":"simulation"}
Tip
SIMULATION=true always wins: no network calls are made even when keys are present.
Deployment and edge#
There are three Compose files:
docker-compose.yml: development with accounts. Startspostgres,redis,objects(S3 storage),agent-runtime,backendandfrontend; the sandbox runner is in thesandboxprofile.docker-compose.demo.yml: layered on the previous one for the open demo mode (AUTH_ENABLED=false).docker-compose.prod.yml: production.caddy,postgres,redis,agent-runtime,backendandfrontend; only Caddy publishes ports (80 and 443).
Note
In production Caddy terminates TLS, adds security headers (HSTS, X-Content-Type-Options, X-Frame-Options, Referrer-Policy) and routes by path: /api/* and /ws to the backend, everything else to the frontend.
Repository layout#
The backend is layered: api depends on application, which depends on domain; infrastructure implements the ports.
backend/ Go (chi)
cmd/server/ main.go: composition root (wires every service)
cmd/sandbox-runner/ code-execution runner
internal/ domain/ application/ api/ auth/ events/ projects/ drive/
skills/ memory/ rewards/ connections/ controls/ audit/ ...
internal/infrastructure/{postgres,redis,runtime,memory}
internal/infrastructure/postgres/migrations/ numbered SQL migrations
agent-runtime/ Python FastAPI (CrewAI engine, simulation engine)
app/ main.py, engine.py, simulation.py, crewai_engine.py, providers.py ...
frontend/ Next.js + React Three Fiber (src/app, src/components, src/lib)
e2e/ Playwright
sandbox/ sandbox container images
Caddyfile production edge (TLS, reverse proxy)
docker-compose.yml dev stack; docker-compose.demo.yml, docker-compose.prod.yml
docs/ SPEC.md and architecture/*.md
landing/ this public site