Architecture

If you are here to edit a specific area, AGENTS.md has a "working on X? read Y" table.


The shape of it

A TanStack Start application — React 19 UI, server functions, SQLite — that schedules local coding-agent CLIs as child processes.

There is no model API. Open Run never sends a token to Anthropic or OpenAI. It drives the claude, codex, grok, gemini and agy binaries the user is already logged into.

1. A run is a conversation, not a log

The first turn is the automation's prompt. Follow-up turns resume the same agent session (claude --resume, codex exec resume), so context survives across turns. Each turn snapshots git state, which is what makes the diff viewer and "open a PR" possible.

Automations can resume a native CLI chat started outside Open Run in the same workspace folder (Claude, Codex, Grok, Antigravity). That still produces a normal Open Run run — native chats are not listed on /runs. A Once at trigger fires the cron once, then pauses the automation.

2. Every runtime speaks one event vocabulary

Each CLI has a transport: cli parses the binary's own JSON output, acp drives it over the Agent Client Protocol. Either way, what lands in the database is the same ACP-shaped vocabulary (lib/acp.ts) — tool calls with a title, kind, status and file locations; approvals as an options list with an outcome.

That subset is hand-written so lib/ stays dependency-free. lib/acpConformance.ts type-checks it against @agentclientprotocol/sdk.


Request path

routes/*.tsx  →  lib/queries.ts (React Query)  →  fns/index.ts (createServerFn RPC)
                                                        ↓ lazy import()
                                              server/core.ts (facade, boots scheduler)
                                                ↓            ↓                ↓
                                          server/db.ts  server/executor.ts  server/scheduler.ts
                                          (better-sqlite3)  (spawn CLI)      (node-cron)

src/server/** is server-only. UI routes reach it exclusively through src/fns/index.ts, where every handler does await import('../server/core') lazily. That keeps better-sqlite3, node-cron and child_process out of the client bundle. A static import of server/* from a route component breaks the client build.

Live path

executor → server/runLive.ts + server/activityLive.ts   (in-process pub/sub)
         → routes/api/runs/$runId/stream.ts             (SSE, one run)
           routes/api/activity/stream.ts                (SSE, run started/finished)
         → lib/useRunLive.ts + lib/useActivityLive.tsx  (EventSource)
         → lib/applyRunLiveEvent.ts                     (patches the React Query cache)

HTTP polling is the fallback when a stream is down.

Access control

One global request middleware (src/start.ts) sits in front of every server function and API route; the bind address is settled before the socket opens (scripts/start.ts). Both apply the same tested rules from lib/serverAccess.ts. See security.md.


Why lib/ is dependency-free

src/lib/** has no node: imports, no SQLite, no filesystem access. The same rule module runs in the browser and on the server write path, so the UI can disable a button with the exact message the server would have thrown.

That is what the gate modules are — lib/runPrereqGate.ts holds the shared workspace/PATH/prompt checks; lib/enableGate.ts, lib/runNowGate.ts, lib/projectGate.ts and lib/gitActionGate.ts mirror the server's refusals. A new refuse condition goes in the server path and the matching gate.

These are the tested modules: pure logic, no I/O, colocated *.test.ts, node:test.


Data model

Single SQLite file, data/openrun.db, created on first run.

Table Holds
projects, workspaces Repositories and their worktrees
runtimes One row per agent CLI: binary, args template, transport
tasks Automations: runtime + prompt + workspace + cron
runs, messages, turn_events Executions and their transcripts
check_results Post-turn verification
integrations, webhook_deliveries Inbound webhooks
notifiers, notification_deliveries Outbound notifications
run_queue Fires waiting on a busy workspace
devices, device_pairings Paired phones
model_catalog Discovered CLI models
  • Migrations are additive-only. addColumn diffs table_info because SQLite has no ADD COLUMN IF NOT EXISTS.
  • turn_events rows are append-only and forward-compatible. Payload fields are optional. Readers tolerate undefined.

Integrations

GitHub / Jira / Linear push events in. Each provider verifies its signature, then normalises onto a single CanonicalWebhookEvent that the dispatcher matches against enabled automations. Adding a provider means implementing verify and parse.


What is open source

Everything in this repository, under AGPLv3. The commercial planes — fleet dashboard, hosted history, remote runners, SSO, audit, policy — are separate proprietary products that attach through lib/edition.ts. That seam only ever adds surfaces; lib/edition.test.ts fails the build if any local feature starts consulting it.

See COMMERCIAL-LICENSE.md.


Conventions worth knowing early

  • Tests are node:test + node:assert/strict, colocated. No Vitest, no Jest.
  • Value imports in test-covered lib/ modules carry an explicit .ts extension--experimental-strip-types has no bundler resolution.
  • src/routeTree.gen.ts is generated. Never hand-edit it. Prefer pnpm build over pnpm generate-routes (the standalone CLI currently emits a different Register block).
  • The scheduler and both live pub/sub registries are module singletons guarded on globalThis so they survive Vite HMR.
  • changelog.d/ takes one file per shipped change, in a negative-relief voice: "You no longer …".

Known limits

  • Single user, no roles, no audit trail.
  • SQLite secrets are sealed under ~/.openrun/data-key; CLI config files still hold MCP headers in the clear because the CLI reads them.
  • Windows is untested outside WSL2.
  • Runs execute real commands with your credentials — treat run cwd resolution and prompt construction as security-relevant.