Skip to content

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.

ui/ browser app shows state, sends requests
api/ NestJS control plane owns product state, policy and approvals
runtime/ Python on Temporal plans, delegates, decides the next step
tools/ sandbox and runners read and change systems outside Polo

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.

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

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.

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.

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

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/commands and POST /api/internal/v1/operations/commands, authenticated by a worker key (WorkerGuard in api/src/platform/security/worker.guard.ts).
  • Every command carries a command_id, the tenant_id, task_id and run_id it is bound to, an attempt_fence and an expected_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_decided is 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 system

Note 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.

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.

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.

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_id column and a forced PostgreSQL policy, installed by installTenantRls in api/src/platform/database/tenant-rls.ts.
  • Tenant scope is a transaction. TenantDatabase.withTenant(tenantId, fn) opens a transaction, sets polo.tenant_id for that transaction only, and runs fn. 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.