Architecture
Polo is split along four boundaries. Each one exists to keep a kind of authority in one place: the browser shows state, the control plane owns it, the runtime reasons about it, and tools touch the outside world. If you know which side of a boundary a piece of code sits on, you know what it is allowed to do.
The four boundaries
Section titled “The four boundaries”ui/ browser app shows state, sends requestsapi/ NestJS control plane owns product state, policy and approvalsruntime/ Python on Temporal plans, delegates, decides the next steptools/ sandbox and runners read and change systems outside Poloui/: the browser app
Section titled “ui/: the browser app”Plain HTML, CSS and ES modules with no build step. The API serves the files itself. Each page or panel is one module in ui/features/, and words that several pages share (connection states, approval outcomes, task badges) live in ui/shared/. The UI holds no authority: every action is an HTTP call that the control plane checks again.
api/: the control plane
Section titled “api/: the control plane”A NestJS application on Fastify with TypeORM and PostgreSQL. It is a modular monolith: one module per product area under api/src/modules/, each with http/, application/, domain/ and infrastructure/ layers. It runs as several processes from the same build:
| Process | Entry point | Job |
|---|---|---|
| API | api/src/main.ts |
HTTP routes for the browser, MCP clients, webhooks and internal worker commands |
| Relay | api/src/relay.ts |
Reads the outbox and starts, signals or cancels Temporal workflows |
| Scheduler | api/src/scheduler.ts |
Recurring tasks and uptime probes |
| Watchers | api/src/*-watch.ts, api/src/knowledge-indexer.ts |
Polling workers, each with its own purpose-bound worker key |
runtime/: the agent runtime
Section titled “runtime/: the agent runtime”A Python Temporal worker started with python -m runtime. It registers two workflows, CaseWorkflow (the supervisor of one task) and AgentWorkflow (one specialist), on the task queue polo-case-v1. Workflows only orchestrate. Model calls and tool calls happen in activities (runtime/activities.py), where Pydantic AI produces typed decisions. See Task lifecycle.
tools/: sandbox and runners
Section titled “tools/: sandbox and runners”Everything an agent can use. tools/workspace/ is the offline sandbox for source edits and checks. The other packages (tools/terraform/, tools/postgres/, tools/kubernetes/, tools/containers/, tools/aws/, tools/azure/, tools/gcp/, tools/cloudformation/) are customer runners: small FastAPI services that hold the customer’s credentials and execute signed requests. See Tools and runners.
Technology
Section titled “Technology”| Part | Technology | Responsibility |
|---|---|---|
| Browser app | HTML, CSS, ES modules | Onboarding, tasks, approvals, connections, artifacts, cost |
| Control plane | TypeScript, NestJS, Fastify, TypeORM, Zod | Identity, tenancy, roles, agent profiles, tasks, agent specifications, approvals, audit, usage |
| Agent runtime | Python 3.12, Pydantic AI | Supervisor planning, specialist steps, evidence review |
| Durable execution | Temporal | Case and agent workflows, timers, signals, retries, cancellation |
| Runners | Python, FastAPI, uvicorn | Scoped reads and approved changes inside the customer’s network |
| Durable data | PostgreSQL with pgvector | Product state, outbox, operation requests and receipts, budgets, knowledge embeddings |
| Coordination | Redis | Rate limiting and readiness; nothing durable |
| Artifacts | S3-compatible object storage | Artifacts and report content |
| Secrets | Sealed values in PostgreSQL | Connection credentials and the workspace vault |
Who writes product state
Section titled “Who writes product state”The control plane is the only writer. This is the rule most of the design follows from.
- The runtime has no database credentials. It sends versioned commands to
POST /api/internal/v1/work/commandsandPOST /api/internal/v1/operations/commands, authenticated by a worker key (WorkerGuardinapi/src/platform/security/worker.guard.ts). - Every command carries a
command_id, thetenant_id,task_idandrun_idit is bound to, anattempt_fenceand anexpected_run_version. One transaction checks ownership, applies the change and stores the response. Sending the same command again returns the stored response; the same ID with a different body is a conflict. - Temporal owns execution history, timers and retries. It does not own product facts. A workflow signal such as
operation_decidedis only a hint to wake up; the activity then reads the decision from the API. - State changes that Temporal must hear about are written to an outbox table in the same transaction. The relay delivers them at least once, with stable workflow IDs (
polo-case-v1:<tenant>:<run>), so duplicates are harmless.
browser ──HTTP──▶ api ──outbox──▶ relay ──▶ Temporal ──▶ runtime ▲ │ └──────────── worker commands ───────────┘ │ └── signed PUT ──▶ runner ──▶ customer systemNote the last line: the control plane, not the runtime, calls runners. An agent proposes an operation; the API reviews, records, dispatches and verifies it. See The operation contract.
Storage
Section titled “Storage”| Store | Used for | Notes |
|---|---|---|
| PostgreSQL | All product state | One schema per module (for example polo_work). Migrations are explicit, per module, and run as a separate step (npm run migrate). |
| pgvector | Knowledge retrieval | Embeddings of authorized knowledge only. Credentials are never embedded. |
| Redis | Rate limits, readiness | Losing Redis loses no work. |
| Object storage | Artifacts, reports | Any S3-compatible store; the local launcher uses MinIO. |
| Vault | Secrets | See below. |
Secrets
Section titled “Secrets”A connection’s credential is sealed with AES-256-GCM before it is stored (RunnerVault in api/src/modules/operations/infrastructure/runner.transport.ts). The workspace ID and the connection ID are bound into the ciphertext as additional data, so a row copied to another workspace or another connection does not open.
The workspace vault (api/src/modules/operations/infrastructure/workspace.vault.ts) stores named secrets the same way. A value goes in and is never returned to a browser or a model. A connection can take its credential from a vault secret by name; replacing the secret re-seals every connection that uses it in the same transaction.
Agents see references, never values. A runner holds its own cloud or database credentials in its own encrypted configuration, so those never reach Polo at all.
Company isolation
Section titled “Company isolation”Polo calls a company’s space a workspace; the code calls it a tenant.
- Shared tables with row-level security. Every tenant table has a
tenant_idcolumn and a forced PostgreSQL policy, installed byinstallTenantRlsinapi/src/platform/database/tenant-rls.ts. - Tenant scope is a transaction.
TenantDatabase.withTenant(tenantId, fn)opens a transaction, setspolo.tenant_idfor that transaction only, and runsfn. With no tenant set, the policy matches no rows. A pooled connection cannot leak scope to the next request. - The application role cannot bypass the policy. The API connects as a non-owner role; migrations use separate credentials.
- Authentication comes first. Row-level security is a backstop for a missing filter, not the permission check. The API authorizes the member, then chooses the tenant.
- Workers cannot pick a tenant. A worker command names a tenant, task and run, and the API checks that this worker holds the live claim on that run.
- Webhooks route by installation. The tenant comes from the authenticated installation, never from the message body.
- Cells. A worker is bound to a cell (
POLO_API_CELL_ID), and the API refuses commands from a worker of another cell.
The threat model and the full list of controls are in docs/SECURITY.md; the operator’s view is on Security.
Where to go next
Section titled “Where to go next”- Task lifecycle follows one task through these boundaries.
- The operation contract explains how a change leaves Polo.
- Repository layout maps folders to the parts above.