Open-source alternative to Claude Managed Agents — and a foundation for open-source, self-hosted Claude Tag-style agents.
🌐 oma.duyet.net · 📖 docs.oma.duyet.net · 💬 github.com/duyet/oma
Write a harness. Deploy. The platform runs it — with sessions, sandboxes, tools, memory, vaults, Slack/GitHub/Linear integrations, and crash recovery out of the box. Drop-in compatible with the Claude Managed Agents API; runs on Cloudflare Workers + Durable Objects, or docker compose up on your own box.
Use Open Managed Agents when you want:
- A self-hosted Claude Managed Agents API implementation.
- An open-source, self-hosted Claude Tag-style workflow with BYOK model credentials.
- MCP, private tools, encrypted vaults, and durable sessions under your own deployment boundary.
Compare: Open Tag · Open-source Claude Tag · Self-hosted Claude Tag
More Console screenshots
![]() |
![]() |
| Sandbox Runtimes — every backend behind one interface | GitHub integrations — issues assigned to an agent spawn a briefed session |
![]() |
|
| Skills library |
The same harness, business logic, and event-log model run on both. Pick the one that matches your hosting story:
| Self-host (Node) | Cloudflare | |
|---|---|---|
| Where it lives | Your VPS / Mac / Docker host / fly.io / your k8s | Cloudflare Workers + DO + Containers |
| Storage | SQLite or Postgres + local FS | D1 + KV + R2 |
| Sandbox | Multi-provider: LocalSubprocess / LiteBox / Daytona / E2B / BoxRun / Kubernetes + BYOK | Cloudflare Sandbox (Containers) |
| Time to running | docker compose up (~2 min) |
wrangler deploy (~10 min once configured) |
| Best for | OSS users, on-prem, no CF account, data-resident deploys | Edge scale, no host management, already on CF |
Same SDK. Same /v1/agents / /v1/sessions API. Same Console UI. Same
crash-recovery semantics. Switch between them by changing env vars, not code.
git clone https://github.com/duyet/oma.git
cd oma
cp .env.example .env
# Two secrets are required before first boot (server refuses to start
# without them) — both generated locally:
# BETTER_AUTH_SECRET — signs Console sessions
# PLATFORM_ROOT_SECRET — encrypts credentials, model-card API keys, integration tokens at rest
# (lose it and every encrypted row is unreadable — back it up)
# A third, API_KEY, is optional but the smoke test below needs it (otherwise
# every curl 401s — there's no Console session cookie yet on a fresh install):
$EDITOR .env
# BETTER_AUTH_SECRET=$(openssl rand -hex 32)
# PLATFORM_ROOT_SECRET=$(openssl rand -base64 32)
# API_KEY=$(openssl rand -hex 24)
#
# Optional: ANTHROPIC_API_KEY lets the first agent run without a Model Card.
# In production, add a Model Card per tenant from the Console instead.
# SQLite + LocalSubprocess sandbox (default — fastest path)
docker compose up -d
# Or Postgres backend
# docker compose -f docker-compose.postgres.yml up -d
curl localhost:8787/health
# → {"status":"ok","backends":{"db":"sqlite ..."}, ...}
open http://localhost:8787 # Console UI on the same portSmoke test the harness end-to-end (uses the API_KEY you set in .env above):
KEY=$(grep '^API_KEY=' .env | cut -d= -f2)
AID=$(curl -s -X POST localhost:8787/v1/agents -H "x-api-key: $KEY" -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 "x-api-key: $KEY" -H 'content-type: application/json' \
-d "{\"agent\":\"$AID\"}" | jq -r .id)
curl -s -X POST localhost:8787/v1/sessions/$SID/events -H "x-api-key: $KEY" -H 'content-type: application/json' \
-d '{"events":[{"type":"user.message","content":[{"type":"text","text":"Run: uname -a"}]}]}'Skipped API_KEY and want zero auth friction instead for a single-user local
trial? echo "AUTH_DISABLED=1" >> .env, restart, and drop the -H "x-api-key: ..." from every curl above — every request becomes
tenant_id="default". Full auth modes (email/password sign-up, OTP, Google
OAuth): docs.oma.duyet.net/self-host/node-docker#login.
Full self-host guide (sandbox modes, Postgres, BoxRun, vault sidecar, Console UI, operator gotchas): docs.oma.duyet.net/self-host/overview
Requires Workers Paid plan (for Durable Objects + Containers).
git clone https://github.com/duyet/oma.git
cd oma
pnpm install
# Local dev (no CF account needed) — wrangler dev with simulators
cp .dev.vars.example .dev.vars && $EDITOR .dev.vars
# PLATFORM_ROOT_SECRET is required to start; API_KEY ships prefilled with a
# dev-only placeholder (dev-test-key-change-me) so the smoke test below
# works out of the box — change it before exposing this past localhost.
pnpm dev
# API → http://localhost:8787
# Console → http://localhost:5173
# Deploy — one wizard: creates the D1 databases, KV namespace, and R2
# buckets; patches wrangler.jsonc in apps/main + apps/agent + apps/integrations
# with the resulting IDs; generates and sets PLATFORM_ROOT_SECRET,
# INTEGRATIONS_INTERNAL_SECRET, BETTER_AUTH_SECRET, and API_KEY as Worker
# secrets; applies D1 migrations; deploys all three workers in dependency
# order (integrations → agent → main).
npx wrangler login
./scripts/setup-cf.sh
# → prints each worker's URL; the main worker serves both the API and the
# bundled Console UI. Re-run anytime — it's idempotent (reuses existing
# resources, skips secrets that are already set). Flags: --no-deploy
# (provision only), --skip-secrets (already set them), --reset-secrets
# (rotate everything).
# Optional — only if you want a tenant-less default LLM (otherwise add a
# Model Card in the Console): export ANTHROPIC_API_KEY before running the
# wizard, or set it interactively when prompted.What gets deployed:
| Component | What it does |
|---|---|
Main Worker (oma-managed-agents) |
API routes — agents, sessions, environments, vaults, memory, files — plus the bundled Console UI |
Agent Worker (oma-sandbox-default) |
SessionDO + harness + sandbox per environment |
Integrations Worker (oma-managed-agents-integrations) |
Linear / GitHub / Slack OAuth + webhook gateway |
D1 (oma-auth, oma-integrations) |
Control-plane + tenant data; integration provider tables |
KV (CONFIG_KV) |
Config storage for agents, environments, credentials |
| R2 (files, workspace, memory, backups) | Uploaded files, sandbox workspace persistence, memory-store bytes, snapshot backups |
Want Kubernetes-backed sandboxes instead of Cloudflare Containers? See the k8s-bridge quickstart (full guide: docs/deploy/k8s-bridge.md).
For local dev (pnpm dev above), the API is already reachable with the
.dev.vars placeholder key. For a Cloudflare deploy, open the main worker
URL setup-cf.sh printed, sign up (the first account becomes that tenant's
owner), and mint a key from Console → API Keys — setup-cf.sh generates
its own API_KEY secret directly on the worker, so that value is never
shown to you. Either way, the minimal API equivalent to clicking through the
Console:
BASE=http://localhost:8787 # local pnpm dev — swap for your deployed URL
KEY=dev-test-key-change-me # .dev.vars.example's default (local only —
# for a deploy, use the key you minted above)
AGENT=$(curl -s $BASE/v1/agents \
-H "x-api-key: $KEY" -H "content-type: application/json" \
-d '{
"name": "Coder",
"model": "claude-sonnet-4-6",
"system": "You are a helpful coding assistant.",
"tools": [{ "type": "agent_toolset_20260401" }]
}' | jq -r .id)
SESSION=$(curl -s $BASE/v1/sessions \
-H "x-api-key: $KEY" -H "content-type: application/json" \
-d "{\"agent\":\"$AGENT\"}" | jq -r .id)
# Send a turn AND stream the reply token-by-token in one shot
curl -N -X POST $BASE/v1/sessions/$SESSION/messages \
-H "x-api-key: $KEY" -H "content-type: application/json" \
-d '{"content":"Write a Python script that fetches HN top stories"}'For long-lived sessions use GET /v1/sessions/$SESSION/events/stream — replays history on connect, never closes. For the full Console-driven flow (Model Cards, vaults, integrations) see docs.oma.duyet.net/quickstart.
apps/web (the marketing site) auto-deploys to oma.duyet.net via
.github/workflows/deploy-website.yml
on every push to main that touches apps/web/**. One-time manual setup:
- Create a Cloudflare API token with Workers Scripts (edit) + Account
Settings (read) permissions, and set it as the
CLOUDFLARE_API_TOKENrepo secret. SetCLOUDFLARE_ACCOUNT_ID(from the CF dashboard) as theCLOUDFLARE_ACCOUNT_IDrepo secret. - In the Cloudflare dashboard, add
oma.duyet.netas a Custom Domain / DNS record on the account that ownsduyet.net—wrangler deployprovisions the route fromapps/web/wrangler.jsonc, but the zone itself must already be on that account.
No other manual steps are required; workflow_dispatch is also available
for manual re-runs.
examples/ has copy-paste-ready agent configs for common
personas (coding assistant, data analyst, research agent) plus full harness
demos (claude-agent-sdk, GitHub-repo attach, self-improvement-agent)
with pre-built Docker images published to GHCR by
build-example-images.yml, plus
a build-it-yourself provider-swap demo (grok-coding-agent).
See examples/README.md.
A meta-harness is not an agent — it's the platform that runs agents. It defines stable interfaces for everything an agent needs, and stays out of the way of the agent loop:
┌─────────────────────────────────────────────────────────┐
│ Harness (the brain — your code) │
│ - Reads events, builds context, calls the model │
│ - Decides HOW: caching, compaction, tool delivery │
│ - Stateless: crash → rebuild from event log → resume │
├─────────────────────────────────────────────────────────┤
│ Meta-Harness (the platform — SessionDO) │
│ - Prepares WHAT is available: tools, skills, history │
│ - Manages lifecycle: sandbox, events, WebSocket │
│ - Crash recovery, credential isolation, usage tracking │
├─────────────────────────────────────────────────────────┤
│ Infrastructure (Cloudflare or Node self-host) │
│ - Event log: Durable-Object SQLite (CF) or SQLite/Pg │
│ - Sandbox: CF Containers / subprocess / LiteBox / E2B │
│ - Storage: KV + R2 (CF) or local FS (self-host) │
└─────────────────────────────────────────────────────────┘
The platform prepares what is available. The harness decides how to deliver it to the model.
| Platform manages | Harness decides |
|---|---|
| Event log persistence (SQLite) | Context engineering (filtering, ordering) |
| Sandbox lifecycle (containers) | Caching strategy (cache breakpoints) |
| Tool registration (built-in + MCP) | Compaction strategy (when to compress) |
| WebSocket broadcast | Retry strategy (backoff, transient detection) |
| Crash recovery | Stop conditions (max steps, completion signals) |
| Credential isolation (vaults) | System prompt construction |
| Memory (file-backed stores, versioned) | Tool delivery (all at once vs. progressive) |
The default harness works out of the box. When you need custom behavior — different caching, compaction, context engineering — implement HarnessInterface and register it by name:
// apps/agent/src/harness/research-loop.ts
import type { HarnessInterface, HarnessContext } from "./interface";
import { generateText, stepCountIs } from "ai";
export class ResearchHarness implements HarnessInterface {
async run(ctx: HarnessContext): Promise<void> {
const messages = ctx.runtime.history.getMessages();
// Your context engineering, caching strategy, loop — tools, sandbox,
// and broadcast are platform-provided via ctx.
const result = await generateText({
model: ctx.model,
system: ctx.systemPrompt,
messages,
tools: ctx.tools,
stopWhen: stepCountIs(50),
});
ctx.runtime.broadcast({
type: "agent.message",
content: [{ type: "text", text: result.text }],
});
}
}Register it in apps/agent/src/index.ts, next to the built-in harnesses:
import { ResearchHarness } from "./harness/research-loop";
registerHarness("research", () => new ResearchHarness());Point an agent at it and redeploy the agent worker (self-host, or your fork's CI):
{ "name": "Researcher", "model": "claude-sonnet-4-6", "harness": "research" }There's no standalone harness-deploy path today — a custom harness lives in the
agent worker's source tree and ships with it. Full contract (the optional
onSessionInit / shouldCompact / compact / deriveModelContext hooks) and
a worked example: AGENTS.md § Custom Harness. The
/new-harness skill in this repo scaffolds the file and registration for you.
Compatible with the Claude Managed Agents API. Same endpoints, same event types, works with existing SDKs.
Agents — Create and manage agent configurations
POST /v1/agents # Create agent
GET /v1/agents # List agents
GET /v1/agents/:id # Get agent
PUT /v1/agents/:id # Update agent
DELETE /v1/agents/:id # Delete agent
POST /v1/agents/:id/archive # Archive agent
GET /v1/agents/:id/versions # Version history
GET /v1/agents/:id/versions/:version # Get specific versionEnvironments — Sandbox execution environments
POST /v1/environments # Create environment
GET /v1/environments # List environments
GET /v1/environments/:id # Get environment
PUT /v1/environments/:id # Update environment
DELETE /v1/environments/:id # Delete environmentSessions — Run agent conversations
POST /v1/sessions # Create session
GET /v1/sessions # List sessions
GET /v1/sessions/:id # Get session
POST /v1/sessions/:id # Update session
DELETE /v1/sessions/:id # Delete session
POST /v1/sessions/:id/archive # Archive session
POST /v1/sessions/:id/events # Send events (user messages)
GET /v1/sessions/:id/events # Get events (JSON or SSE)
GET /v1/sessions/:id/events/stream # SSE stream
POST /v1/sessions/:id/resources # Attach resource
GET /v1/sessions/:id/resources # List resources
DELETE /v1/sessions/:id/resources/:resId # Remove resourceVaults — Secure credential storage
POST /v1/vaults # Create vault
POST /v1/vaults/:id/credentials # Add credential
GET /v1/vaults/:id/credentials # List (secrets stripped)Memory Stores — persistent storage; Claude Managed Agents Memory contract
When attached to a session, each store is mounted into the sandbox at
/mnt/memory/<store_name>/. The agent reads and writes it with the
standard file tools (bash/read/write/edit/glob/grep) — there are no
bespoke memory_* tools.
R2 holds the bytes-of-truth (key <store_id>/<memory_path>); D1 holds the
index + audit, kept eventually consistent via R2 Event Notifications →
Cloudflare Queue → Consumer.
POST /v1/memory_stores # Create store
GET /v1/memory_stores # List stores
GET /v1/memory_stores/:id # Retrieve store
POST /v1/memory_stores/:id/archive # Archive (one-way)
DELETE /v1/memory_stores/:id # Delete store + memories + versions
POST /v1/memory_stores/:id/memories # Create/upsert memory {path, content, precondition?}
GET /v1/memory_stores/:id/memories?path_prefix=&depth=N # List memories (metadata)
GET /v1/memory_stores/:id/memories/:mid # Retrieve memory (with content)
POST /v1/memory_stores/:id/memories/:mid # Update memory {path?, content?, precondition?}
DELETE /v1/memory_stores/:id/memories/:mid # Delete memory
GET /v1/memory_stores/:id/memory_versions?memory_id= # Audit history (newest first)
GET /v1/memory_stores/:id/memory_versions/:ver_id # Single version (with snapshot content)
POST /v1/memory_stores/:id/memory_versions/:ver_id/redact # Redact prior version (refuses live head)CAS via precondition: { type: "content_sha256", content_sha256 }. 100KB
cap per memory. 30-day version retention with the most-recent version per
memory always preserved. Rollback = retrieve a version and write its
content as a new memory revision (no special endpoint).
CLI:
oma memory stores create "User Preferences"
oma memory write <store-id> /preferences/formatting.md --content "Always use tabs."
oma memory ls <store-id> --prefix /preferences/
oma memory versions <store-id> --memory-id <mem-id>Files & Skills
POST /v1/files # Upload file
GET /v1/files/:id/content # Download file
POST /v1/skills # Create skill
GET /v1/skills # List skillsThe agent_toolset_20260401 provides:
| Tool | Description |
|---|---|
bash |
Execute commands in the sandbox |
read |
Read files from sandbox filesystem |
write |
Write/create files (auto-creates directories) |
edit |
Surgical string replacement in files |
glob |
Find files matching a pattern |
grep |
Search file contents with regex |
web_fetch |
URL → markdown via Workers AI; auto-summarized when agent.aux_model is set, raw saved to /workspace/.web/ |
web_search |
Web search. Defaults to DuckDuckGo (free, no key). Optional backends via tool type: web_search_20250305 (Anthropic server-side, Claude models only), web_search_tavily (requires TAVILY_API_KEY) |
schedule / cancel_schedule / list_schedules |
Cron-style self-wakeup for long-running agents |
browser (opt-in) |
Headless browser session — navigate, click, screenshot. Opt-in via tools: [{ name: "browser", enabled: true }] so the default-tool list nudges agents toward cheaper web_fetch |
Derived tools are auto-generated based on session config:
| Tool | Source |
|---|---|
call_agent_* |
Callable Agents (multi-agent delegation) |
mcp__<server>__<tool> |
MCP Servers (double underscore is the actual separator) |
(Memory Stores do not add bespoke tools — agents access them as filesystem
mounts at /mnt/memory/<store_name>/ via the standard file tools above.)
OMA registers any Model Context Protocol server attached to an agent. Each upstream tool surfaces to the model as mcp__<server>__<tool> (double underscore — copy the name exactly). Up to 20 servers per agent.
| Transport | When to use | How |
|---|---|---|
| HTTP / SSE | Hosted MCP servers (Linear, GitHub Copilot, Notion, …) | {"type":"url","url":"https://mcp.linear.app/mcp"} |
| stdio | npm / PyPI MCP packages with no hosted endpoint | {"type":"stdio","command":"uvx","args":[...],"port":8765} — OMA spawns inside the sandbox container, talks to 127.0.0.1:port/sse |
Credentials never enter the sandbox; the outbound resolver matches by host and injects at forward time.
| Auth mode | Configured as | Refresh |
|---|---|---|
| none | no authorization_token, no matching vault credential |
n/a |
| inline bearer | "authorization_token": "..." on the server entry |
no |
| vault static bearer | session vault has a static_bearer credential whose mcp_server_url matches |
no |
| vault OAuth | session vault has an mcp_oauth credential (with refresh_token + token_endpoint) |
yes — on 401 and 403 (Airtable/Asana/Sentry use 403 for expired tokens), CAS-writes new token to D1, retries once |
# Servers attach to the agent (not the session)
curl -X PUT $BASE/v1/agents/$AGENT -H "x-api-key: $KEY" -H "content-type: application/json" \
-d '{"mcp_servers":[{"name":"linear","type":"url","url":"https://mcp.linear.app/mcp"}]}'
# Bind an OAuth credential via Vault
oma connect linear --vault $VAULT_IDTool discovery is bounded at 15 s per server; one bad server logs and skips, the rest stay live. Full design: docs.oma.duyet.net/build/vault-and-mcp.
Tenant-level registry. Register a server once (POST /v1/mcp_servers,
optionally pinning a vault credential_id) and reference it from any agent by
registry_id instead of repeating the inline url. An inline url always
wins; GET /v1/mcp-proxy/_health/:sid reports per-server credential
resolution for the sandbox status page.
A skill is a SKILL.md plus reference files (templates, schemas, examples). At session start the platform mounts everything under /home/user/.skills/{name}/ in the sandbox and inlines the SKILL.md body directly into the system prompt — no lazy read, no follow-up read tool call. Format is compatible with Anthropic's Claude Code skills.
Create a skill (JSON; files inlined):
POST /v1/skills
{
"files": [
{ "filename": "SKILL.md", "content": "---\nname: invoice-parser\ndescription: Parse supplier invoices.\n---\n\n# Steps\n1. ..." },
{ "filename": "schema.json", "content": "{...}" }
]
}For large skills with binaries: POST /v1/skills/upload multipart with file=<my-skill.zip>.
Attach to an agent with the object form — a bare string array silently does not bind:
{ "skills": [{ "skill_id": "skill_abc123", "type": "custom" }] }The agent's system prompt then receives, at session start:
<source name="skill:skill_abc123">
<skill name="invoice-parser">
{full SKILL.md body}
</skill>
</source>
and the files appear at /home/user/.skills/invoice-parser/SKILL.md etc.
Four built-in skills ship ready to attach (no upload): xlsx, pdf, docx, pptx. Reference them with {"skill_id":"builtin_pdf","type":"anthropic"}.
Six more prompt-fragment skills ship as ready-to-seed folders in examples/skills/ — data-viz, generate-html, query-sql, github, git-commit, spreadsheet-xlsx. Load them into a deployment with ./scripts/seed-skills.sh (add SEED_ANTHROPIC=1 to also import Anthropic's public catalog), then attach with {"skill_id":"<id>","type":"custom"}.
Tools never see your tokens. When a sandbox makes an HTTP request, an outbound resolver — oma-vault sidecar on self-host (mockttp HTTPS proxy with a trusted self-signed CA), the agent worker's outboundByHost interceptor on Cloudflare — matches the request hostname against the session's vaults, strips any inbound Authorization/x-api-key/x-goog-api-key, injects the real credential, and forwards. A prompt-injected agent has nothing to leak; env | grep TOKEN returns nothing inside the sandbox.
# Create a vault and add a static bearer bound to api.github.com
VID=$(curl -sX POST $BASE/v1/vaults -H "x-api-key: $KEY" \
-d '{"name":"github-prod"}' | jq -r .id)
curl -sX POST $BASE/v1/vaults/$VID/credentials -H "x-api-key: $KEY" -d '{
"display_name": "gh-pat",
"auth": {
"type": "static_bearer",
"token": "ghp_xxx",
"mcp_server_url": "https://api.github.com"
}
}'
# Bind on session create
curl -sX POST $BASE/v1/sessions -H "x-api-key: $KEY" \
-d "{\"agent\":\"$AGENT\",\"vault_ids\":[\"$VID\"]}"
# Inside the sandbox: curl https://api.github.com/user → 200, Authorization injected at the network layerThree credential types share one resolver:
| Type | Match by | Refresh |
|---|---|---|
static_bearer |
request host matches mcp_server_url |
never |
mcp_oauth |
request host matches mcp_server_url |
on 401 / 403 via token_endpoint, CAS-writes new token to D1 |
cap_cli |
sandbox CLI invocations match cli_id in the cap registry (gh, glab, aws, …) |
per-CLI |
Max 20 credentials per vault. Each forward emits a structured op:"mcp_proxy.forward" log. Full design: docs/mcp-credential-architecture.md, docs.oma.duyet.net/build/vault-and-mcp.
Publish an agent into a third-party tool and have it act as a real teammate there — assigned, mentioned, replied to like any other user.
Make an agent a member of your Linear workspace with its own identity, avatar, and @autocomplete slot. The agent appears in the assignee dropdown, gets pinged on @mentions, replies in the Agent panel, and pushes status back to issues it's working on.
Two install kinds:
| Kind | When to pick | Setup |
|---|---|---|
personal_token (PAT) |
Single workspace, fastest path, no OAuth App | oma linear install-pat --workspace <slug> --pat <linear-pat> |
dedicated (OAuth App) |
Multi-workspace, proper bot identity, OAuth refresh | Console Integrations → Linear → Publish agent (wizard issues per-publication callback + webhook URLs to paste into your own Linear OAuth App at linear.app/settings/api) |
The full agent-side playbook (when to ask the human, how to offer browser automation, exactly what to paste into Linear's form) lives at skills/oma/integrations-linear.md.
PAT-mode autopilot — let the bot pick up unassigned issues by label/state/project:
oma linear rules create <pub-id> --label triage --state Backlog --project "Inbox"
oma linear rules list <pub-id>
oma linear rules delete <rule-id>Inspect / manage:
oma linear list # workspaces
oma linear pubs <installation-id> # publications (status=live, persona, caps)
oma linear get <pub-id> # single publication
oma linear update <pub-id> --caps issue.read,comment.write,issue.update,…
oma linear unpublish <pub-id>How it works:
| Piece | What it does |
|---|---|
| Per-publication identity | dedicated registers a per-agent Linear OAuth App; personal_token shares the human's PAT (no App registered) |
| Inbound webhook | Linear events become user messages on a session — assigned, @mention, comment-mention, new comment in an active thread, Agent panel (agentSessionCreated / agentSessionPrompted, commentReply for threaded continuation) |
| Outbound MCP | The agent talks back through mcp.linear.app/mcp with its own bearer (PAT or OAuth-refreshed), so writes are attributed to the persona |
| Capability gate | Per-publication allowlist (issues / comments / labels / assignment / triage) limits what the agent can do |
The Linear integration ships in packages/linear/ (provider logic, webhook signing, MCP wiring) with thin CF wrappers in apps/integrations/src/routes/linear/publications.ts.
Give an agent its own GitHub App with a real bot identity — assignable on issues, requestable as a reviewer on PRs, posts comments under its own @<slug>[bot] handle. Each agent is a separate App on github.com (per-publication, not a shared marketplace bot), so credentials and audit trails stay isolated.
# (1) Console — humans clicking through a wizard
Integrations → GitHub → Publish agent
# (2) CLI — agents driving oma on a user's behalf
oma github bind <agent-id> --env <env-id> # → opens one-click GitHub App Manifest flow
oma github handoff <form-token> # alt: 7-day URL for an org admin to complete
oma github list
oma github pubs <installation-id>
oma github update <pub-id> --caps pr.read,pr.review.write,issue.comment.write,…
oma github unpublish <pub-id>bind returns a manifestStartUrl; opening it auto-POSTs an App manifest to github.com/settings/apps/new with redirect URL + webhook URL + recommended permissions baked in. After confirming, GitHub redirects through to "Install on org" and the publication flips to live. Manual fallback: oma github submit <form-token> --app-id … --private-key-file … --webhook-secret … if you registered the App by hand.
Engagement is label-based. On install OMA auto-creates a label (default: lowercased persona name) in every selected repo. Add the label to any issue/PR to engage the bot for every subsequent activity on that thread; remove the label to mute. @<slug>[bot] mention in body or comment is the fallback path (GitHub's @ autocomplete excludes Bot accounts, so it's plain-text).
How it works:
| Piece | What it does |
|---|---|
| Per-publication App | Each agent registers its own GitHub App via Manifest flow; credentials stored encrypted per-publication |
| Inbound webhook | issues, issue_comment, pull_request, pull_request_review, pull_request_review_comment become user messages on a session (one per <repo>#<num>) |
| Outbound MCP | Agent talks to GitHub's hosted MCP at api.githubcopilot.com/mcp/ with the installation token; same token also injected as GITHUB_TOKEN for sandbox gh / git |
| Token rotation | 1-hour installation token auto-refreshed via App JWT on every webhook dispatch |
| Capability gate | Per-publication allowlist; destructive ops (pr.merge, repo.branch.delete, workflow.dispatch, release.create, *.delete) require explicit opt-in |
The GitHub integration ships in packages/github/ with thin CF wrappers in apps/integrations/src/routes/github/.
Publish an agent into a Slack workspace as a dedicated bot — @mentionable in channels, replies in threads, joins DMs, hosts the AI assistant pane. Per-channel sessions: one running session per (publication, channel), with all events in that channel converging on the same session id.
# (1) Console — humans clicking through a wizard
Integrations → Slack → Publish agent # ↑ opens api.slack.com with a pre-filled manifest
# (2) CLI — agents driving oma on a user's behalf
oma slack publish <agent-id> --env <env-id> # → returns manifestLaunchUrl + formToken (60 min TTL)
oma slack submit <form-token> --client-id … --client-secret … --signing-secret …
oma slack handoff <form-token> # alt: 7-day shareable URL for a workspace admin
oma slack list
oma slack pubs <installation-id>
oma slack update <pub-id> --caps message.write,thread.reply,reaction.add,…
oma slack unpublish <pub-id>One-click managed install. When the operator configures a single distributable Slack App (SLACK_MANAGED_CLIENT_ID / SLACK_MANAGED_CLIENT_SECRET / SLACK_MANAGED_SIGNING_SECRET), the publish wizard shows an Add to Slack button that skips the manifest + paste-credentials steps and goes straight to OAuth (POST /slack/publications/start-managed). One app installs into many workspaces from one events URL — inbound events fan in by team_id to the right per-workspace installation. Without the three secrets it falls back to the bring-your-own-App manifest flow above. Full operator + end-user guide: docs/slack-integration.md.
The full agent-side playbook (manifest-flow caveats, GATEWAY_ORIGIN HTTPS requirement, what to paste where, MCP toggle probe) lives at skills/oma/integrations-slack.md.
How it works:
| Piece | What it does |
|---|---|
| Per-publication App | Each agent registers as its own dedicated Slack App via the "Create from manifest" URL flow — own client id, signing secret, bot user; no shared marketplace App |
| Inbound webhook | app_mention / DM / thread reply → direct_invocation signal; top-level channel post → debounced channel_scan_armed (90 s window); reactions on bot-authored messages → reaction_on_bot_message; member_joined/member_left_channel for the bot → joined_channel / session_closed; channel_archive / channel_unarchive → close / reopen |
| Dual-token outbound | OAuth v2 yields both bot (xoxb-) and user (xoxp-) tokens. The xoxp- vault binds to mcp.slack.com/mcp for typed mcp__slack__* tools (search, history, canvases); the xoxb- vault binds to slack.com/api for chat.postMessage, reactions, etc. Bot replies default to in-thread |
| Capability gate | Per-publication allowlist (message.read/write/update/delete, thread.reply, reaction.add/remove, user.read, search.read, canvas.write) |
| Resumable install | Publication-first — the row exists from minute one with callback + webhook URLs baked into the manifest. Mid-flow failures stay resumable from Console (pending_setup → credentials_filled → awaiting_install → live) |
The Slack integration ships in packages/slack/ with thin CF wrappers in apps/integrations/src/routes/slack/.
Operator setup: the integrations gateway needs GATEWAY_ORIGIN pointing at a publicly-reachable HTTPS host — Slack verifies both the OAuth redirect URL and the Events Request URL before letting an install complete.
Run a Telegram bot backed by an OMA agent. Unlike the OAuth integrations above,
this is a single deployment-level bot wired entirely through env vars on the
integrations gateway — set TELEGRAM_BOT_TOKEN (BotFather) plus TELEGRAM_AGENT_ID
(and optionally TELEGRAM_VAULT_IDS / TELEGRAM_ENVIRONMENT_ID), then point
BotFather's webhook at /telegram/webhook. One session per chat.
- Attachments — inbound photos and documents are downloaded and forwarded to the agent as image / document content blocks (caption becomes the text); an oversized or expired file degrades to a text placeholder so the turn still lands.
- Auto-idle — a periodic sweep pauses the sandbox of any chat idle longer than
TELEGRAM_IDLE_TIMEOUT_MS(default 5 minutes) to stop paying for idle containers. The next message implicitly resumes it (the sandbox warms lazily), so no explicit resume is needed.
Publish an agent as a standalone bot that end users talk to without an OMA
account — a hosted chat page at /p/<slug>, an embeddable widget, guest access,
and optional per-message billing.
- Widget embed —
<script src="https://<host>/p/<slug>/widget.js" async></script>drops a floating chat launcher onto any site. The Console My Bots page (/my-bots) surfaces the public URL, a QR code, and the copy-paste snippet. - Consumer auth (
/v1/public/auth/*) — magic-link, one-tap guest mode, and in-place upgrade (guest → email, history preserved). Creators see who used their bot viaGET /v1/publications/:id/users. - Metering & paywall (
@duyet/oma-payments) — per-publication pricing (free/per_message/per_1k_tokens/subscription) over a credit wallet. Blocked turns return HTTP 402; top-ups run through Stripe Checkout (POST /webhooks/stripe). Kill-switch viaPAYMENTS_DISABLED.
Fire sessions on a cron cadence with no human turn — digests, polling, recurring maintenance:
curl -s $BASE/v1/agents/$AGENT/schedules -H "x-api-key: $KEY" \
-d '{"cron_expression":"0 9 * * 1","timezone":"America/New_York",
"environment_id":"env_xxx","input":"Post the weekly digest."}'next_run_at advances via an atomic compare-and-set on each per-minute tick
(no double-fire); POST .../schedules/:id/run fires immediately. Cloudflare
deployment only for now. Full reference in AGENTS.md.
open-managed-agents/
├── apps/
│ ├── main/ # API worker (Cloudflare) — Hono routes, auth, rate limiting
│ ├── main-node/ # apps/main-node — the self-host Node.js server (same control-plane API as apps/main, packaged for docker compose)
│ ├── agent/ # Agent worker — SessionDO + harness + sandbox
│ ├── integrations/ # Integrations gateway — Linear / GitHub / Slack OAuth + webhooks
│ ├── oma-vault/ # Vault sidecar — outbound auth-header injection (per-host secrets)
│ ├── console/ # Web dashboard — React + Vite + Tailwind v4
│ ├── docs/ # Docs site (Astro Starlight) — published to docs.oma.duyet.net
│ └── web/ # Marketing site (Astro) — published to oma.duyet.net
├── packages/
│ ├── cli/ # `oma` CLI — agent / session / integration commands
│ ├── sdk/ # TypeScript SDK — typed REST + SSE client (`Oma` class)
│ ├── api-types/ # Shared TypeScript types (config schemas, events)
│ ├── http-routes/ # Public REST route definitions (shared by main + main-node)
│ ├── session-runtime/ # Harness runtime — event log, broadcast, recovery
│ ├── sandbox/ # Sandbox adapters (subprocess / litebox / daytona / e2b / boxrun)
│ ├── credentials-store/ # Encrypted credentials (AES-GCM under PLATFORM_ROOT_SECRET)
│ ├── model-cards-store/ # Encrypted model-card API keys
│ ├── vaults-store/ # Vault definitions + outbound auth wiring
│ ├── linear/ github/ slack/ # Provider logic (OAuth, webhook signing, MCP wiring)
│ ├── integrations-core/ # Provider-neutral persistence interfaces
│ └── integrations-adapters-{cf,node}/ # D1 / KV / Workers + Postgres / FS implementations
├── docs/ # Internal design RFCs (not the user-facing site)
├── examples/ # Copy-paste agent/environment configs + ready-to-use Docker images
├── test/ # Unit + integration tests
└── scripts/ # Deployment + maintenance scripts
The variables that gate boot and at-rest safety:
| Variable | Required | Description |
|---|---|---|
PLATFORM_ROOT_SECRET |
Yes | AES-GCM key for credentials.auth, model_cards.api_key_cipher, and integration tokens. Workers refuse to start without it. Back this up — losing it makes every encrypted row unreadable. Generate with openssl rand -base64 32. |
BETTER_AUTH_SECRET |
Yes (prod) | better-auth session signing key. Sessions don't survive restart if missing. Generate with openssl rand -hex 32. |
API_KEY |
Yes | Bootstrap key for the REST API in dev / first-run. Once the Console is up, prefer per-tenant API keys minted from there. |
INTEGRATIONS_INTERNAL_SECRET |
Yes (if apps/integrations runs) |
Shared secret between apps/main and apps/integrations. |
ANTHROPIC_API_KEY |
No | Fallback LLM credential used when a tenant has not added a Model Card. In production, add a Model Card per tenant from the Console — the key is encrypted at rest under PLATFORM_ROOT_SECRET, scoped to the tenant, and rotatable without redeploy. |
ANTHROPIC_BASE_URL |
No | Override for Anthropic-compatible proxies. |
PUBLIC_BASE_URL |
No (dev) / Yes (prod) | Cookie domain + OAuth redirect base. Defaults to * trusted-origins — only safe for local dev. |
SANDBOX_PROVIDER |
No | Fallback default when an environment has no explicit provider selection. See multi-provider docs below for per-environment config.sandbox_provider and BYOK registration. |
TAVILY_API_KEY |
No | Only needed for the web_search_tavily tool-type variant — web_search defaults to free DuckDuckGo with no key required. |
DAYTONA_API_KEY |
No | Enables Daytona sandbox provider (seeded at startup). |
E2B_API_KEY |
No | Enables E2B sandbox provider (seeded at startup). |
BOXRUN_URL |
No | Enables BoxRun sandbox provider (seeded at startup). |
OMA_TELEMETRY_DISABLED |
No | Set to 1 to fully opt out of anonymous install telemetry (see Telemetry). OMA_TELEMETRY=0 and DO_NOT_TRACK=1 are honored too. |
OMA_DEPLOYMENT_KIND |
No | Overrides the reported deployment kind (cloudflare / node-docker / k8s). |
OMA_TELEMETRY_ENDPOINT |
No | Override the collector URL (default https://app.oma.duyet.net). Point at your own instance to keep telemetry private. |
Multi-provider sandbox: providers can be seeded from env vars (system) or added
via POST /v1/sandbox_providers (BYOK). Environments select a provider by ID
via config.sandbox_provider — fallback chain: per-environment ID → legacy
config.type → SANDBOX_PROVIDER env → subprocess.
Full list (integrations OAuth credentials, Postgres URL, sandbox tunables, memory-bucket config, Google sign-in, etc.) — see docs.oma.duyet.net/reference/configuration and .env.example / .dev.vars.example.
OMA collects anonymous, opt-out usage telemetry to guide development. A self-hosted install phones home every 6 hours with aggregate counts only — the public numbers are visible at oma.duyet.net/stats.
What is collected (and nothing else):
- A random instance UUID generated + persisted locally on first run (no hostname, username, IP, or any PII).
- The OMA version and deployment kind (
cloudflare/node-docker/k8s). - Aggregate counts: total/active agents, total/running sessions, total & average session duration, sandbox launches grouped by provider kind, and the set of model ids in use (names only).
What is never collected: prompts, messages, tool inputs/outputs, file contents or paths, agent/tenant names, credentials, or any user data.
Opt out at any time — honored everywhere (self-host Node, Cloudflare, and the CLI):
export OMA_TELEMETRY_DISABLED=1 # or OMA_TELEMETRY=0, or DO_NOT_TRACK=1The ingest endpoint is POST /v1/telemetry/ingest (public, unauthenticated,
rate-limited, zod-validated); the schema and aggregation live in
packages/http-routes/src/telemetry/. Point OMA_TELEMETRY_ENDPOINT at your
own instance to keep the data entirely private to your deployment.
Per-tenant LLM credentials. An agent references one by setting agent.model = "<model_id>" — the worker looks up the card and signs the outbound request with its api_key, base_url, and headers. This is the canonical replacement for the global ANTHROPIC_API_KEY env var.
Providers (wire tag → request shape):
| tag | shape | typical use |
|---|---|---|
ant |
Anthropic /v1/messages |
Claude on api.anthropic.com |
ant-compatible |
Anthropic shape, custom base_url |
Bedrock proxy, self-hosted Anthropic-compatible |
oai |
OpenAI /v1/chat/completions |
OpenAI, Azure OpenAI |
oai-compatible |
OpenAI shape, custom base_url |
vLLM, OpenRouter, Groq, etc. |
Add one from Console → Model Cards, or via CLI:
oma models create \
--model-id claude-prod \
--provider ant \
--model claude-sonnet-4-6 \
--api-key sk-ant-...
oma models listREST: POST /v1/model_cards, GET /v1/model_cards, POST /v1/model_cards/:id (rotate), DELETE /v1/model_cards/:id. Create runs a 6-second probe so a bad key fails loudly, not at first turn.
Keys are AES-256-GCM-encrypted at rest under PLATFORM_ROOT_SECRET (label model.cards.keys); list responses surface only the last-4 preview. Rotate by POSTing a new api_key — no redeploy, no key versioning (re-run the backfill script if you rotate PLATFORM_ROOT_SECRET itself).
npm test # unit + integration suite
npm run typecheck # zero errorsThe user-facing docs site lives at apps/docs (Astro Starlight) and is published to docs.oma.duyet.net.
pnpm dev:docs # local preview at http://localhost:4321
pnpm build:docs # static build into apps/docs/dist/
pnpm deploy:docs # build + wrangler deploy (Cloudflare Worker static assets)The docs/ folder at the repo root contains internal design RFCs — not the user-facing site.
- Fork the repository
- Create your feature branch (
git checkout -b feat/amazing-feature) - Run tests (
npm test && npm run typecheck) - Commit your changes
- Open a Pull Request
Forked from openma-ai/open-managed-agents.



