Reliable bridge service for Codex thread orchestration, lane/session lifecycle management, and event/webhook fanout.
codex-bridge runs a Bun + Elysia HTTP service (oai-app-proxy) that:
- tracks lanes and persistent lane sessions
- creates/takes over Codex threads
- drives turns (
start,steer,interrupt) - emits structured lane events to SSE and webhooks
- handles approval workflows
- exposes OpenAPI docs for the full API surface
- Core Concepts
- Quickstart
- Runtime + Integration Notes
- API Overview
- Schema Reference
- Endpoint Reference
- Webhook Guide (Global, Single-Target, Multi-Target)
- Common Operator Flows
- Dashboard HUD
- Verification
- Repo Layout
A Codex conversation context returned by Codex APIs (thread/*). Threads have metadata like:
idtitlecwdsource/sourceKindupdatedAt
Threads can have lineage (parent/child) through sub-agent spawn sources.
A bridge-managed control plane record for a unit of work. Lanes optionally attach to a Codex thread and track:
- lifecycle status (
idle,starting,running,needs_approval,blocked,verifying,done,failed,interrupted) - workspace context (
projectId,repoRoot,branch,worktreePath) - runtime intent (
acceptanceCriteria,verificationCommands) - session behavior (
readinessMode)
A persistent app-server process channel used for thread-turn operations on a lane.
- one active session per lane
- supports structured request/response and notification handling
- session state includes readiness deferral fields used in takeover scenarios
A lane-scoped approval request emitted during guarded workflows.
- requested via runtime/app-server path
- resolved with
approveordeny - can be bridged back to app-server request IDs when session is active
A destination + event matcher + optional scope filter for outbound event delivery.
Supports hybrid targeting:
- global (no scope set)
- lane-scoped (
laneId/laneIds) - thread-scoped (
threadId/threadIds) - session-scoped (
sessionId/sessionIds) - combined scope (AND semantics across dimensions)
bun install
PORT=8787 bun run index.tsHealth check:
curl -s http://127.0.0.1:8787/health | jqOpenAPI:
open http://127.0.0.1:8787/openapihack up
hack status
hack logs appTeardown:
hack down- Transport is HTTP for bridge API, with lane sessions backed by Codex app-server RPC.
- Service version and app-server wiring are visible in
GET /health. - In containerized environments,
GET /codex/runningfalls back to inferred sessions from recent thread activity when host process inspection cannot see host PIDs.
- Base URL:
http://127.0.0.1:8787(local) or yourhackhost - Content type:
application/json - Auth: none by default (assume trusted internal network or front with auth proxy)
- Validation errors: HTTP
400withvalidation_error - Not found: HTTP
404with route-specific error code (e.g.lane_not_found)
Live stream:
GET /events/streamreturns SSE events for lane event feed consumers
Exact machine-readable schemas are always in OpenAPI:
GET /openapiGET /openapi/json
The following are operator-focused summaries.
{
"laneId": "lane-uuid-or-custom",
"projectId": "optional-project",
"repoRoot": "/abs/path",
"threadId": "optional-thread-id",
"status": "running",
"activeTurnId": "optional-turn-id",
"readinessMode": "strict|lenient",
"preferredSurface": "mac-app|vscode|cli|any",
"surface": "mac-app|vscode|cli|unknown",
"acceptanceCriteria": ["..."],
"verificationCommands": ["..."],
"createdAt": "ISO-8601",
"updatedAt": "ISO-8601"
}{
"running": true,
"threadId": "thread-id-or-null",
"startedAt": "ISO-8601|null",
"pendingRequests": 0,
"activeTurnId": "turn-id-or-null",
"readinessMode": "strict|lenient",
"readinessDeferred": false,
"readinessReason": null
}{
"approvalId": "apr_...",
"laneId": "lane-...",
"source": "app-server|runtime",
"reason": "string",
"status": "pending|approved|denied",
"createdAt": "ISO-8601",
"resolvedAt": "ISO-8601|null"
}{
"id": "wh_...",
"url": "https://destination.example/webhook",
"events": ["lane.completed", "lane.approval.requested", "*"],
"enabled": true,
"scope": {
"laneIds": ["lane-a", "lane-b"],
"threadIds": ["thread-x"],
"sessionIds": ["lane-a"]
},
"scopeKinds": ["lane", "thread", "session"],
"secret": "optional-shared-secret",
"createdAt": "ISO-8601",
"updatedAt": "ISO-8601",
"lastDeliveryAt": "ISO-8601|null",
"lastError": "optional-last-error"
}Bridge emits lane events internally, then fans matching events to webhooks:
{
"id": "delivery-id",
"ts": "ISO-8601",
"source": "oai-app-proxy",
"eventKey": "subscriptionId:eventId",
"eventId": "lane-event-id",
"subscriptionId": "wh_...",
"eventType": "lane.completed",
"laneId": "lane-...",
"threadId": "thread-...|null",
"sessionId": "lane-...|null",
"scope": { "laneIds": [], "threadIds": [], "sessionIds": [] },
"payload": {},
"event": {}
}All endpoints below are available via OpenAPI tags (health, watch, webhooks, codex, lanes, approvals, debug).
GET /health- returns service/runtime status including codex binary/app-server metadata
GET /events/stream- SSE stream of lane events
GET /watch/status- returns watcher state
POST /watch/start- body:
{ "intervalSec": 15 }
- body:
POST /watch/stopPOST /watch/tick
GET /webhooks- list subscriptions
POST /webhooks- create subscription
- body:
{ "url": "https://example.com/hook", "events": ["lane.completed", "lane.failed"], "enabled": true, "secret": "optional-secret", "scope": { "laneId": "lane-1", "laneIds": ["lane-1", "lane-2"], "threadId": "thread-1", "threadIds": ["thread-1", "thread-2"], "sessionId": "lane-1", "sessionIds": ["lane-1", "lane-2"] } }
PATCH /webhooks/{id}- partial update of
url,events,enabled,secret,scope
- partial update of
DELETE /webhooks/{id}POST /webhooks/{id}/test- emits
webhook.testthrough normal dispatch path
- emits
POST /codex/rpc- raw passthrough to any Codex RPC method
- body:
{ "method": "thread/list", "params": {} }
GET /codex/app/listGET /codex/skills/listGET /codex/model/list
Threads:
GET /codex/threads- query:
limit(default 50, max 200)sourceKinds(CSV)
- query:
GET /codex/threads/{threadId}GET /codex/threads/loadedPOST /codex/threads/start- body:
{ "model": "optional-model-id" }
- body:
Thread actions:
POST /codex/threads/{threadId}/forkPOST /codex/threads/{threadId}/archivePOST /codex/threads/{threadId}/unarchivePOST /codex/threads/{threadId}/rollbackPOST /codex/threads/{threadId}/compact/startPOST /codex/threads/{threadId}/compact(alias)
Each action accepts optional passthrough body:
{ "anyCodexActionOptions": "..." }Turn control:
POST /codex/threads/{threadId}/turns/{action}action:start | steer | interrupt- body:
{ "input": "required for start/steer", "turnId": "optional for interrupt", "expectedTurnId": "optional optimistic concurrency for steer/start" }
Visibility + activity:
GET /codex/active- query:
windowSecsourcesourceKinds(CSV)surfaceoriginatorpreferOriginatorpreferredSurface
- query:
GET /codex/running- process detection + fallback thread inference mode metadata
Workflow:
POST /codex/workflows/start-visible- creates or takes over a lane + session + thread flow
- body:
{ "laneId": "optional custom lane id", "projectId": "optional project id", "repoRoot": "/abs/path", "title": "optional lane/thread title", "kickoffPrompt": "optional first instruction", "threadId": "optional takeover thread id", "preferredSurface": "mac-app|vscode|cli|any", "requirePreferredSurface": false, "readinessMode": "strict|lenient", "model": "optional model id", "acceptanceCriteria": ["..."], "verificationCommands": ["..."] }
Lane CRUD:
GET /lanesPOST /lanes- body:
{ "laneId": "optional", "projectId": "optional", "repoRoot": "optional", "threadId": "optional", "readinessMode": "strict|lenient", "acceptanceCriteria": ["..."], "verificationCommands": ["..."] }
- body:
GET /lanes/{laneId}DELETE /lanes/{laneId}
Attach + sync:
POST /lanes/attach- attach existing thread to new lane
- body includes:
- required:
threadId - optional:
laneId,projectId,repoRoot,preferredSurface,requirePreferredSurface,readinessMode,acceptanceCriteria,verificationCommands
- required:
POST /lanes/{laneId}/sync-from-codex- refreshes lane metadata from attached thread
Session lifecycle:
GET /lanes/sessions(all lane session snapshots)GET /lanes/{laneId}/session/statusPOST /lanes/{laneId}/session/startPOST /lanes/{laneId}/session/stop
Lane turn actions:
POST /lanes/{laneId}/{action}action:prompt | steer | interrupt- body:
{ "input": "required for prompt/steer" }
Lane events:
GET /lanes/{laneId}/events
Maintenance:
POST /lanes/cleanup- bulk lane cleanup with filters
- body:
{ "statuses": ["blocked", "done", "failed", "interrupted"], "olderThanHours": 24, "syntheticOnly": false, "limit": 100, "dryRun": true }
Approvals:
GET /lanes/{laneId}/approvalsPOST /lanes/{laneId}/approvals/{approvalId}/{action}action:approve | deny
POST /debug/request-approval- body:
{ "laneId": "lane-id", "source": "app-server|runtime", "reason": "optional reason" }
- body:
POST /debug/complete- body:
{ "laneId": "lane-id", "note": "optional debug note" }
- body:
POST /debug/reconcile-stale- body:
{ "clearSyntheticRunningLanes": true, "clearOrphanedActiveTurns": true }
- body:
Event matching is:
- subscription is enabled
- event type matches one of
eventspatterns (supports*) - scope matches lane/thread/session context (if scope is present)
Scope dimensions are ANDed when multiple are set:
laneIdsmust include event lanethreadIdsmust include event threadsessionIdsmust include event session
Use for global observability and warehouse ingestion.
curl -s -X POST http://127.0.0.1:8787/webhooks \
-H 'content-type: application/json' \
-d '{
"url":"https://ops.example/global",
"events":["*"],
"enabled":true
}' | jqUse when one external worker owns one lane.
curl -s -X POST http://127.0.0.1:8787/webhooks \
-H 'content-type: application/json' \
-d '{
"url":"https://worker.example/lane-123",
"events":["lane.completed","lane.approval.requested"],
"scope":{"laneId":"lane-123"}
}' | jqUse for queue fan-in patterns.
curl -s -X POST http://127.0.0.1:8787/webhooks \
-H 'content-type: application/json' \
-d '{
"url":"https://worker.example/multi",
"events":["lane.*"],
"scope":{"laneIds":["lane-a","lane-b","lane-c"]}
}' | jqUse when thread identity is the durable owner key.
curl -s -X POST http://127.0.0.1:8787/webhooks \
-H 'content-type: application/json' \
-d '{
"url":"https://worker.example/thread-owner",
"events":["lane.turn.completed","lane.completed"],
"scope":{"threadId":"thread_abc"}
}' | jqUse when you need strict narrowing.
curl -s -X POST http://127.0.0.1:8787/webhooks \
-H 'content-type: application/json' \
-d '{
"url":"https://worker.example/hybrid",
"events":["lane.turn.completed"],
"scope":{
"laneIds":["lane-xyz"],
"threadIds":["thread-xyz"],
"sessionIds":["lane-xyz"]
}
}' | jq- Global: monitoring, analytics, general event bus
- Lane single/multi: task orchestration and worker ownership
- Thread scope: conversation-centric ownership
- Session scope: transient runtime-specific listeners
- Hybrid: strict routing for high-signal, low-noise automation
curl -s -X POST http://127.0.0.1:8787/codex/workflows/start-visible \
-H 'content-type: application/json' \
-d '{
"laneId":"lane-new",
"title":"Bridge managed lane",
"kickoffPrompt":"Review current branch and summarize status",
"preferredSurface":"any",
"requirePreferredSurface":false,
"readinessMode":"lenient"
}' | jqcurl -s -X POST http://127.0.0.1:8787/lanes/attach \
-H 'content-type: application/json' \
-d '{
"laneId":"lane-takeover",
"threadId":"<thread-id>",
"preferredSurface":"any",
"requirePreferredSurface":false,
"readinessMode":"strict"
}' | jqStart lane session:
curl -s -X POST http://127.0.0.1:8787/lanes/lane-takeover/session/start | jq# prompt
curl -s -X POST http://127.0.0.1:8787/lanes/lane-takeover/prompt \
-H 'content-type: application/json' \
-d '{"input":"continue execution"}' | jq
# steer active turn
curl -s -X POST http://127.0.0.1:8787/lanes/lane-takeover/steer \
-H 'content-type: application/json' \
-d '{"input":"narrow to failing tests"}' | jq
# interrupt active turn
curl -s -X POST http://127.0.0.1:8787/lanes/lane-takeover/interrupt \
-H 'content-type: application/json' \
-d '{}' | jqcurl -s -X POST http://127.0.0.1:8787/lanes/cleanup \
-H 'content-type: application/json' \
-d '{
"statuses":["blocked","done","failed","interrupted"],
"olderThanHours":12,
"syntheticOnly":false,
"limit":250,
"dryRun":false
}' | jqThe Next.js dashboard (dashboard/) includes:
Overview- top-level operational signal and action shortcutsLanes- lane inventory + controlsThreads- thread lineage tableWebhooks- scoped subscription visibilityTopology- React Flow graph (threads ↔ lanes ↔ sessions with detail drawer)System- runtime metadata and health diagnostics
Run dashboard:
bun run dev:dashboardbun x ultracite check
bun run typecheck
bun test
bun run smoke:e2eDashboard quality gate:
bun run --cwd dashboard build
bun run --cwd dashboard lintindex.ts- bootstrap entrypoint (initializeBridgeRuntime+ Elysia listen)src/app.ts- app composition + OpenAPI pluginsrc/modules/bridge/service.ts- stateful runtime/session orchestrationsrc/modules/*/index.ts- API route modulesdashboard/- operational HUD appschema/- protocol and schema assetsdocs/- runbooks and spec docsscripts/- utilities and diagnosticsdata/- local runtime persistence (gitignored)
Bridge service for reliable, async orchestration of Codex threads and lanes.
codex-bridge runs a Bun + Elysia HTTP service (oai-app-proxy) that:
- tracks lane lifecycle and persistent app-server sessions,
- attaches/starts/steers/interrupts Codex threads,
- emits SSE + webhook events for wake-driven orchestration,
- handles approval requests and completion payloads,
- exposes typed OpenAPI docs.
For lane control-plane operations, the bridge uses one persistent app-server process per active lane:
- initialize once per lane session,
- keep thread/turn operations on that same live session,
- route app-server notifications into lane events and webhook fanout.
Session-oriented endpoints:
POST /lanes/:laneId/session/startPOST /lanes/:laneId/session/stopGET /lanes/:laneId/session/statusPOST /lanes/:laneId/:action(prompt,steer,interrupt)POST /codex/workflows/start-visible(one-shot start/takeover workflow)
Per-lane readiness policy:
readinessMode: "lenient"(default) allows deferred readiness (no rollout found) for takeover flows.readinessMode: "strict"blocks/fails takeover start and turn actions until resume readiness is confirmed.- New thread creation (
thread/starton the same lane session) now uses the app-server happy path directly and does not run cross-session resume polling.
Surfaces are normalized to: mac-app, cli, vscode, unknown.
What you can do today:
- Prefer or filter by surface when discovering threads (
GET /codex/active). - Enforce surface compatibility when taking over an existing thread (
requirePreferredSurface). - Take over any known thread regardless of origin (
/lanes/attachor/codex/workflows/start-visiblewiththreadId).
What you cannot force today:
- Bridge cannot force a brand-new
thread/startto be born on a specific surface. Source/surface are determined by Codex runtime behavior.
bun install
PORT=8787 bun run index.tscurl -s http://127.0.0.1:8787/health | jqOpenAPI docs:
GET /openapi
curl -s -X POST http://127.0.0.1:8787/codex/workflows/start-visible \
-H 'content-type: application/json' \
-d '{
"laneId":"lane-new",
"title":"Bridge managed lane",
"kickoffPrompt":"Review current branch and summarize status",
"preferredSurface":"any",
"requirePreferredSurface":false,
"readinessMode":"lenient"
}' | jqThis performs:
- lane create,
- persistent lane session start,
thread/start,- same-session workflow bootstrap (no cross-session resume poll for new thread),
- optional kickoff
turn/start, - lane status ->
running.
Discover likely desktop-active threads:
curl -s 'http://127.0.0.1:8787/codex/active?windowSec=3600&preferredSurface=mac-app&preferOriginator=Codex%20Desktop' | jqAttach lane:
curl -s -X POST http://127.0.0.1:8787/lanes/attach \
-H 'content-type: application/json' \
-d '{
"laneId":"lane-desktop",
"threadId":"<thread-id>",
"preferredSurface":"mac-app",
"requirePreferredSurface":true,
"readinessMode":"strict"
}' | jqStart persistent session for that lane:
curl -s -X POST http://127.0.0.1:8787/lanes/lane-desktop/session/start | jqcurl -s -X POST http://127.0.0.1:8787/codex/workflows/start-visible \
-H 'content-type: application/json' \
-d '{
"laneId":"lane-takeover",
"threadId":"<thread-id>",
"preferredSurface":"any",
"requirePreferredSurface":false,
"readinessMode":"lenient"
}' | jqUse the same lane ID from any client:
# prompt
curl -s -X POST http://127.0.0.1:8787/lanes/lane-desktop/prompt \
-H 'content-type: application/json' \
-d '{"input":"Continue from latest plan and execute tests"}' | jq
# steer active turn
curl -s -X POST http://127.0.0.1:8787/lanes/lane-desktop/steer \
-H 'content-type: application/json' \
-d '{"input":"Narrow scope to lint/type errors first"}' | jq
# interrupt active turn
curl -s -X POST http://127.0.0.1:8787/lanes/lane-desktop/interrupt \
-H 'content-type: application/json' \
-d '{}' | jqKey lane lifecycle events:
lane.session.startedlane.turn.skipped.readiness_deferred(kickoff intentionally skipped when takeover readiness is deferred)lane.turn.started.notificationlane.item.completed.notificationlane.turn.completed(canonical completion signal for consumers)lane.turn.completed.notification- raw forwarded app-server notification (
turn/completed)
- raw forwarded app-server notification (
lane.completedlane.approval.requestedlane.approval.resolved
Webhook envelope includes top-level:
eventTypeeventKey(stable dedupe key per subscription + event)eventId(lane event id)laneIdpayloadsubscriptionIdts
Example webhook setup:
curl -s -X POST http://127.0.0.1:8787/webhooks \
-H 'content-type: application/json' \
-d '{
"url":"https://example.com/hook",
"events":["lane.session.started","lane.completed","lane.approval.requested"],
"enabled":true
}' | jqApprovals API:
GET /lanes/:laneId/approvalsPOST /lanes/:laneId/approvals/:approvalId/approvePOST /lanes/:laneId/approvals/:approvalId/deny
If new bridge-started threads are not obvious in Desktop, or older ones look stuck:
- pass explicit
repoRoot(maps to threadcwd) so the thread is created in the workspace you are viewing, - prefer
readinessMode: "strict"for takeover lanes that must be fully resumable, - check
GET /lanes/:laneId/session/statusforreadinessDeferredandreadinessReason, - call
POST /lanes/:laneId/session/stopto gracefully interrupt active turns before shutdown (bridge now sendsturn/interrupton stop when possible). - run stale reconciliation to clear synthetic test lanes and orphaned active turn pointers:
POST /debug/reconcile-stale
curl -s -X POST http://127.0.0.1:8787/debug/reconcile-stale \
-H 'content-type: application/json' \
-d '{}' | jqbun x ultracite check
bun run typecheck
bun test
bun run smoke:e2esmoke:e2e validates:
- start-visible workflow and lane session lifecycle,
- webhook delivery and
lastDeliveryAtupdates, - session stop path.
After hack init, run with:
hack up
hack logs codex-bridge
hack downindex.ts- bootstrap entrypoint (runtime init + Elysia listen)src/app.ts- app composition + OpenAPI pluginsrc/modules/bridge/service.ts- stateful runtime/session orchestration logicsrc/modules/*/index.ts- feature route modules (health,watch,webhooks,codex,lanes,debug)schema/- protocol schemasdocs/- specs and runbooksscripts/- utility and verification scriptsdata/- runtime persistence (gitignored)