Skip to content

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. Node shares the Claude-compatible API, Console, and core runtime packages with Cloudflare, using SQL and filesystem adapters. It also mounts the OpenAI Agents API at /openai/v1. Follow the OpenAI SDK guide after creating an API key and Model Card in the Console.

SQLite + LocalFs (default)Postgres + LocalFsMySQL + LocalFs
Bundled composedocker-compose.ymldocker-compose.postgres.ymlExternal MySQL 8.x DSN
Best forSingle instance, ≤10 GB sessions, dev, demoMulti-instance, large tables, share an existing PG clusterExisting MySQL infrastructure
SQL concurrencyOne writer per processMany writers — HA-ableMany writers — CAS-backed claims
Backupscp ./data/*.db or litestream → S3pg_dump / managed PG snapshotsmysqldump / managed snapshots

Same openma/main-node:dev image either wayDATABASE_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.

Terminal window
git clone https://github.com/openma-ai/open-managed-agents.git
cd open-managed-agents
cp .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.
# First time builds the image; subsequent runs skip --build
docker compose up -d --build
# Sanity
curl 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:8787

Drive the harness end-to-end:

Terminal window
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_useagent.tool_resultagent.messagesession.status_idle.

Terminal window
# 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.

Use a fresh MySQL 8.x database and the same image:

Terminal window
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.

ModeWhenHow to enable
email + password (default)Single user / team / dev / demoJust 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 gateReal prod, you wired up SMTPAUTH_REQUIRE_EMAIL_VERIFY=1, override sendVerificationOTP in apps/main-node/src/auth/config.ts to call Resend / SES / SMTP / Postmark / etc.
Google OAuthTeam SSOGOOGLE_CLIENT_ID + GOOGLE_CLIENT_SECRET in .env. Console renders Google button automatically.

The agent’s bash / read / write / etc. tools execute in a sandbox. SANDBOX_PROVIDER env picks the backend.

ModeWhen to pickRequired env
subprocess (default)Local dev, trusted agent codeNone — host child_process under ./data/sandboxes/<sid>/. Zero isolation; the agent has the host’s filesystem.
liteboxLocal hardware isolation, no daemonSANDBOX_PROVIDER=litebox. Host needs /dev/kvm (Linux) or Apple Silicon (macOS). Per-VM Firecracker-equivalent via libkrun.
boxrunMulti-host with KVM, OMA pods on managed k8sSANDBOX_PROVIDER=boxrun BOXRUN_URL=http://host:8100/v1/default. Run boxlite serve on a separate KVM host; OMA pods reach it over HTTP.
daytonaProduction / untrusted codeSANDBOX_PROVIDER=daytona DAYTONA_API_KEY=... (+ optional DAYTONA_API_URL). Daytona-managed Linux VMs, one per session.
e2bE2B 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.

Remote sandboxes (Daytona, E2B, BoxRun) sharing memory mounts use MEMORY_S3_* env (any S3-compatible store — AWS / R2 / OSS / MinIO).

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:

Terminal window
# Create a vault
VID=$(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.com
curl -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 layer

Skip the sidecar if you don’t need it: docker compose up oma-server.

  • AUTH_DISABLED=1 belongs in .env, not the shell. Compose substitutes ${AUTH_DISABLED} from the .env file at the compose dir — exporting it only in your shell is silently ignored.
  • docker compose restart does NOT re-read env. After editing .env, use docker compose up -d --force-recreate oma-server.
  • oma-vault and oma-server MUST agree on the backend. If you set DATABASE_URL to a Postgres URL on oma-server only, 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-node runs idempotent ALTER TABLE for the unified-runtime turn marker columns on every start, so existing dbs upgrade in place. If you ever see no such column: turn_id, you’re on a pre-migration build — pull a newer image.

SQLite → Postgres (one-way):

Terminal window
# Export
sqlite3 ./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 pragmas
sed -i '/^PRAGMA\|sqlite_sequence/d' /tmp/oma-dump.sql
# Import into Postgres
docker exec -i oma-postgres psql -U oma -d oma < /tmp/oma-dump.sql

agent.id, session.id are TEXT, BIGINT timestamps work in both, JSON columns are TEXT not jsonb — no schema translation needed beyond the pragma strip.

SQLitePostgresMySQL
Hot backuplitestream replicates ./data/*.db to S3 continuouslypg_dump / managed PG snapshots / WAL streamingmysqldump / managed MySQL snapshots
RestoreStop server, copy db back, restartpg_restore into fresh PG, point DATABASE_URL at itRestore into fresh MySQL, point DATABASE_URL at it
Sandbox workdirsBack up ./data/sandboxes/ separatelySameSame
Memory blobsBack up ./data/memory-blobs/ or set MEMORY_S3_*SameSame
AuthSeparate ./data/auth.dbIn the Postgres databaseIn 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).

main-node persists session state on every write — kill -9 doesn’t lose the conversation.

Terminal window
# 1. Drop a few synthetic events
SID=$(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, 3
curl -N localhost:8787/v1/sessions/$SID/events/stream
# Ctrl-C
# 3. Hard-kill — no graceful shutdown
docker kill -s SIGKILL oma-server
# 4. Restart
docker compose up -d
# 5. Resume from where you left off
curl -N -H 'Last-Event-ID: 3' localhost:8787/v1/sessions/$SID/events/stream