Node + Docker (no Cloudflare)
If you want openma without a Cloudflare account — on a VPS, your laptop, fly.io, your k8s cluster, or anywhere else that runs Linux/macOS — this is the path. Same code. The harness, the event log, the recovery logic, the API, the Console UI: identical to the Cloudflare deploy. Only the I/O bindings change.
SQL backends, one image
Section titled “SQL backends, one image”| SQLite + LocalFs (default) | Postgres + LocalFs | MySQL + LocalFs | |
|---|---|---|---|
| Bundled compose | docker-compose.yml | docker-compose.postgres.yml | External MySQL 8.x DSN |
| Best for | Single instance, ≤10 GB sessions, dev, demo | Multi-instance, large tables, share an existing PG cluster | Existing MySQL infrastructure |
| SQL concurrency | One writer per process | Many writers — HA-able | Many writers — CAS-backed claims |
| Backups | cp ./data/*.db or litestream → S3 | pg_dump / managed PG snapshots | mysqldump / managed snapshots |
Same openma/main-node:dev image either way — DATABASE_URL env at
runtime decides. SQLite needs only DATABASE_PATH; server databases use
DATABASE_URL=postgres://... or DATABASE_URL=mysql://.... better-auth
uses the selected server database; only SQLite mode uses a separate
auth.db.
better-sqlite3 is an optional dependency of main-node and oma-vault:
it is installed by default, but a Postgres or MySQL image does not need its
native addon. If it is missing or failed to build, a server-database
deployment still starts, and SQLite mode refuses to start with a message
naming the dependency. Installs use the prebuilt addon that better-sqlite3
ships for Linux (glibc and musl), macOS and Windows on x64 and arm64; the
workspace does not compile it (allowBuilds in pnpm-workspace.yaml), so an
image build needs neither a C++ toolchain nor access to nodejs.org.
Quickstart (Docker, SQLite)
Section titled “Quickstart (Docker, SQLite)”git clone https://github.com/openma-ai/open-managed-agents.gitcd open-managed-agentscp .env.example .env
# Two secrets are required before first boot — both generated locally:# BETTER_AUTH_SECRET — signs Console sessions# PLATFORM_ROOT_SECRET — encrypts credentials, model-card API keys, integration tokens# (lose it and every encrypted row is unreadable — back it up)$EDITOR .env# BETTER_AUTH_SECRET=$(openssl rand -hex 32)# PLATFORM_ROOT_SECRET=$(openssl rand -base64 32)# ANTHROPIC_API_KEY is optional — in production, prefer a per-tenant Model Card.# SANDBOX_PROVIDER is required; choose an isolated runtime and add its credentials:# SANDBOX_PROVIDER=e2b# E2B_API_KEY=...
# First time builds the image; subsequent runs skip --builddocker compose up -d --build
# Sanitycurl localhost:8787/health# → {"status":"ok","backends":{"db":"sqlite ..."},...}
# Console UI on the same port — sign up with email + password,# you'll be in the app immediately (no email-OTP gate by default)open http://localhost:8787Drive the harness end-to-end:
AID=$(curl -s -X POST localhost:8787/v1/agents -H 'content-type: application/json' \ -d '{"name":"hello","model":"claude-sonnet-4-6","tools":[{"type":"agent_toolset_20260401"}]}' \ | jq -r .id)
SID=$(curl -s -X POST localhost:8787/v1/sessions -H 'content-type: application/json' \ -d "{\"agent\":\"$AID\"}" | jq -r .id)
curl -N localhost:8787/v1/sessions/$SID/events/stream &
curl -s -X POST localhost:8787/v1/sessions/$SID/events -H 'content-type: application/json' \ -d '{"events":[{"type":"user.message","content":[{"type":"text","text":"Run uname -a"}]}]}'The SSE stream prints agent.tool_use → agent.tool_result → agent.message
→ session.status_idle.
Postgres backend
Section titled “Postgres backend”# Same .env (compose sets DATABASE_URL internally to the bundled postgres)docker compose -f docker-compose.postgres.yml up -d --build
curl localhost:8787/health# → {"backends":{"agents":"postgres","events":"postgres","db":"postgres ..."}, ...}Brings up postgres:16-alpine on a named volume oma-pgdata. No host
port published — only oma-server reaches it on the bridge network.
For a managed PG (RDS / Neon / Supabase / your cluster), drop the
postgres service block and set DATABASE_URL=postgres://user:pw@host:5432/oma
in .env.
MySQL backend
Section titled “MySQL backend”Use a fresh MySQL 8.x database and the same image:
DATABASE_URL=mysql://oma:password@mysql.internal:3306/oma \ pnpm --filter @open-managed-agents/main-node start
curl localhost:8787/health# → {"auth":"better-auth-mysql","backends":{"agents":"mysql","events":"mysql",...}}The MySQL adapter owns native DDL and optimistic CAS/transaction details; v1 stores use the same Ports as SQLite and Postgres. Bootstrap fails closed when an existing database needs a newer explicit migration. Until a shared realtime adapter is configured, run one main-node replica so SSE fanout is not split between in-process hubs.
Default self-host has no email server, so the auth path is plain email + password — sign-up creates a session immediately, no OTP screen.
| Mode | When | How to enable |
|---|---|---|
| email + password (default) | Single user / team / dev / demo | Just docker compose up, sign up via Console |
| AUTH_DISABLED=1 | ”I’m the only user, skip auth entirely” | echo "AUTH_DISABLED=1" >> .env, restart. Every request becomes tenant_id=default. |
| email + OTP gate | Real prod, you wired up SMTP | AUTH_REQUIRE_EMAIL_VERIFY=1, override sendVerificationOTP in apps/main-node/src/auth/config.ts to call Resend / SES / SMTP / Postmark / etc. |
| Google OAuth | Team SSO | GOOGLE_CLIENT_ID + GOOGLE_CLIENT_SECRET in .env. Console renders Google button automatically. |
| OpenID Connect (company SSO) | Keycloak, Okta, or an internal IdP | Pass signIn.oidc to createBetterAuthComponent in your composition root (below). Console renders one button per provider. |
OpenID Connect providers
Section titled “OpenID Connect providers”A deployment that builds its own composition root can offer any OpenID Connect
provider and, optionally, turn email and password off. Users sign in with
authorization code and PKCE. The first sign-in creates the user and a
workspace, like any sign-up. Register this redirect URI with the provider:
<PUBLIC_BASE_URL>/auth/oauth2/callback/<id>.
import { loadNodeConfig } from "@open-managed-agents/main-node/config";import { createBetterAuthComponent, nodeDefaults, openNodeDatabase,} from "@open-managed-agents/main-node/components";
const config = loadNodeConfig(process.env);const database = await openNodeDatabase(config.database);const auth = await createBetterAuthComponent({ config, database, email: null, signIn: { oidc: [{ id: "sso", // lowercase; appears in the callback path name: "Company SSO", // button label discoveryUrl: "https://sso.example.com/.well-known/openid-configuration", clientId: process.env.SSO_CLIENT_ID!, clientSecret: process.env.SSO_CLIENT_SECRET!, // scopes: ["openid", "profile", "email"], // default // clientAuthentication: "basic", // default "post" }], emailPassword: false, // only SSO can sign users in },});const components = await nodeDefaults(config, { database, auth });/auth-info reports the providers as oidc: [{ id, name }] and whether
email_password is on. Discovery URLs must use HTTPS (loopback HTTP is
allowed for local testing).
Sandbox modes
Section titled “Sandbox modes”The agent’s bash / read / write / etc. tools execute in a sandbox.
SANDBOX_PROVIDER env picks the backend.
| Mode | When to pick | Required env |
|---|---|---|
litebox | Local hardware isolation, no daemon | SANDBOX_PROVIDER=litebox. Host needs /dev/kvm (Linux) or Apple Silicon (macOS). Per-VM Firecracker-equivalent via libkrun. |
boxrun | Multi-host with KVM, OMA pods on managed k8s | SANDBOX_PROVIDER=boxrun BOXRUN_URL=http://host:8100/v1/default. Run boxlite serve on a separate KVM host; OMA pods reach it over HTTP. |
daytona | Production / untrusted code | SANDBOX_PROVIDER=daytona DAYTONA_API_KEY=... (+ optional DAYTONA_API_URL). Daytona-managed Linux VMs, one per session. |
e2b | E2B SaaS / E2B Infra self-host / Aliyun Agent Sandbox (e2b-compatible) | SANDBOX_PROVIDER=e2b E2B_API_KEY=... (+ optional E2B_API_URL, E2B_SANDBOX_URL, and E2B_DOMAIN for a compatible endpoint; SANDBOX_IMAGE selects the template). Firecracker microVMs. |
SANDBOX_PROVIDER has no default. Missing, unknown, and subprocess values
fail closed; the non-isolated local adapter is retained only as an internal
test fixture and cannot be selected by the deployable Node server.
Remote sandboxes (Daytona, E2B, BoxRun) sharing memory mounts use
MEMORY_S3_* env (any S3-compatible store — AWS / R2 / OSS / MinIO).
Run an ACP harness in E2B
Section titled “Run an ACP harness in E2B”Set an Agent’s OMA extension to harness: "acp-sandbox" and provide the
process command under acp.agent. The selected E2B template must contain that
binary:
{ "_oma": { "harness": "acp-sandbox", "acp": { "agent": { "command": "codex-acp", "args": [], "cwd": "/workspace" }, "restart": { "mode": "on-crash", "max_restarts": 3, "window_ms": 60000 } } }}OpenMA uses E2B’s live-stdin command channel for ACP JSON-RPC rather than log
polling. While a turn is running it renews a 90-second lease every 30 seconds.
When idle, E2B is configured to memory-pause and auto-resume instead of killing
the ACP process. On host shutdown OpenMA preserves the ACP session checkpoint,
requests session/close when the agent advertises that capability, then tears
down the child before the sandbox.
For deployment-level fencing, portable /workspace checkpoints, immutable
Session output manifests, and the difference between an official Managed
Agents worker and an OpenMA-supervised harness, see
Managed Runtime Host.
Vault sidecar (outbound credential injection)
Section titled “Vault sidecar (outbound credential injection)”docker compose up brings up an oma-vault container alongside
oma-server. It’s a mockttp HTTPS MITM proxy — when the sandbox makes
outbound calls to a host you registered a credential for, the proxy
injects the matching Authorization header. The agent process never
sees the secret value; it lives only in the proxy’s lookup table.
Enable per-vault:
# Create a vaultVID=$(curl -s -X POST localhost:8787/v1/vaults \ -H 'content-type: application/json' -H "x-api-key: $YOUR_KEY" \ -d '{"name":"github-prod"}' | jq -r .id)
# Add a credential bound to api.github.comcurl -s -X POST localhost:8787/v1/vaults/$VID/credentials \ -H 'content-type: application/json' -H "x-api-key: $YOUR_KEY" \ -d '{ "display_name":"github-pat", "auth":{"type":"static_bearer","token":"ghp_xxx","mcp_server_url":"https://api.github.com"} }'
# Bind to a session — agent's `curl https://api.github.com/...` then sees# the Authorization header injected at the network layerSkip the sidecar if you don’t need it: docker compose up oma-server.
Console branding
Section titled “Console branding”Point CONSOLE_BRANDING_DIR at a directory with a branding.json and the
files it names. Every field is optional:
{ "product_name": "Acme Agents", "logo": "logo.svg", "logo_dark": "logo-dark.svg", "favicon": "favicon.png", "stylesheet": "theme.css", "login": { "title": "Sign in to Acme Agents", "subtitle": "Use your Acme account" }}-
product_namereplaces “openma” in the page title, the sign-in page, and the dashboard heading. -
logo/logo_darkreplace the mark on the sign-in pages; the image keeps the mark’s height and its own width. -
stylesheetloads after the console’s CSS, so it can override the theme tokens, for example::root { --brand: #2563eb; --brand-hover: #1d4ed8; --brand-subtle: #eff6ff; --brand-fg: #fff; }.dark { --brand: #60a5fa; --brand-hover: #93c5fd; --brand-subtle: #172554; --brand-fg: #0b1220; }
The directory is served under /branding/. With CONSOLE_DIR set, the
control plane also writes the title, favicon, stylesheet, and settings into
index.html, so the first paint is already branded. File names must stay
inside the directory, and the server refuses to start when branding.json
is invalid or names a missing file.
Operator gotchas
Section titled “Operator gotchas”AUTH_DISABLED=1belongs in.env, not the shell. Compose substitutes${AUTH_DISABLED}from the.envfile at the compose dir — exporting it only in your shell is silently ignored.docker compose restartdoes NOT re-read env. After editing.env, usedocker compose up -d --force-recreate oma-server.oma-vaultandoma-serverMUST agree on the backend. If you setDATABASE_URLto a Postgres URL onoma-serveronly, the vault sidecar will fall back to its own SQLite and never see the credentials you wrote. The bundled compose handles this for you; if you customise, mirror the env.- First boot from an old
oma.db.main-noderuns idempotentALTER TABLEfor the unified-runtime turn marker columns on every start, so existing dbs upgrade in place. If you ever seeno such column: turn_id, you’re on a pre-migration build — pull a newer image.
Skill sources on a private network
Section titled “Skill sources on a private network”Skills → Install from URL makes the control plane fetch the given URL
itself. To keep that from reaching internal services, it only fetches
https URLs whose host resolves to a public address, checks the address
again at connect time, and does not follow redirects. A skill source on
your intranet needs an explicit allowlist entry:
# Host names, IP literals, or *.example.com (subdomains only)SKILL_SOURCES_ALLOW_HOSTS=skills.corp.example,*.git.corp.exampleAllowed hosts skip the private-address check and may use plain http.
Archive URLs in a v2 index must stay on the index’s origin.
Migrating between backends
Section titled “Migrating between backends”SQLite → Postgres (one-way):
# Exportsqlite3 ./data/oma.db ".dump agents agent_versions sessions session_events session_streams \ memories memory_stores memory_versions vaults credentials session_memory_stores \ tenant membership" > /tmp/oma-dump.sql
# Strip SQLite-specific pragmassed -i '/^PRAGMA\|sqlite_sequence/d' /tmp/oma-dump.sql
# Import into Postgresdocker exec -i oma-postgres psql -U oma -d oma < /tmp/oma-dump.sqlagent.id, session.id are TEXT, BIGINT timestamps work in both,
JSON columns are TEXT not jsonb — no schema translation needed beyond
the pragma strip.
Backups
Section titled “Backups”| SQLite | Postgres | MySQL | |
|---|---|---|---|
| Hot backup | litestream replicates ./data/*.db to S3 continuously | pg_dump / managed PG snapshots / WAL streaming | mysqldump / managed MySQL snapshots |
| Restore | Stop server, copy db back, restart | pg_restore into fresh PG, point DATABASE_URL at it | Restore into fresh MySQL, point DATABASE_URL at it |
| Sandbox workdirs | Back up ./data/sandboxes/ separately | Same | Same |
| Memory blobs | Back up ./data/memory-blobs/ or set MEMORY_S3_* | Same | Same |
| Auth | Separate ./data/auth.db | In the Postgres database | In the MySQL database |
The backends share the same crash-recovery test surface (55 tests across adapter, recovery logic, real-process SIGKILL bootstrap, CF DO eviction).
Crash recovery demo
Section titled “Crash recovery demo”main-node persists session state on every write — kill -9 doesn’t
lose the conversation.
# 1. Drop a few synthetic eventsSID=$(curl -s -X POST localhost:8787/v1/sessions -d '{}' | jq -r .id)for i in 1 2 3; do curl -s -X POST localhost:8787/v1/sessions/$SID/_test_emit \ -H 'content-type: application/json' -d "{\"text\":\"event-$i\"}"done
# 2. SSE shows ids 1, 2, 3curl -N localhost:8787/v1/sessions/$SID/events/stream# Ctrl-C
# 3. Hard-kill — no graceful shutdowndocker kill -s SIGKILL oma-server
# 4. Restartdocker compose up -d
# 5. Resume from where you left offcurl -N -H 'Last-Event-ID: 3' localhost:8787/v1/sessions/$SID/events/stream