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

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

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.
# 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 --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_use → agent.tool_result → agent.message → session.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.
OpenID Connect (company SSO)Keycloak, Okta, or an internal IdPPass signIn.oidc to createBetterAuthComponent in your composition root (below). Console renders one button per provider.

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

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

ModeWhen to pickRequired env
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.

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

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.

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_name replaces “openma” in the page title, the sign-in page, and the dashboard heading.

  • logo / logo_dark replace the mark on the sign-in pages; the image keeps the mark’s height and its own width.

  • stylesheet loads 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.

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

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:

Terminal window
# Host names, IP literals, or *.example.com (subdomains only)
SKILL_SOURCES_ALLOW_HOSTS=skills.corp.example,*.git.corp.example

Allowed hosts skip the private-address check and may use plain http. Archive URLs in a v2 index must stay on the index’s origin.

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