This runbook explains how to run Codex threads through codex-bridge, including:
- starting new managed threads,
- taking over existing Desktop/CLI/VS Code threads,
- safely handing control between clients (desktop, phone, remote agent),
- routing approvals and webhooks without polling loops.
- lane: Bridge-owned orchestration record keyed by
laneId. - thread: Codex conversation/thread ID.
- lane session: Persistent
codex app-serverprocess bound to one lane. - surface: Normalized source type:
mac-app,cli,vscode,unknown. - readiness mode:
lenient(default): allows deferred readiness if resume is not immediately available.strict: requires successful readiness resume before continuing.
- One active lane session should control one lane at a time.
- Lane session state is long-lived and streams notifications from app-server.
- Thread turn operations for an attached lane should go through lane endpoints (
/lanes/:laneId/:action), not one-shot RPC. - Webhooks and SSE are downstream consumers of lane events.
Use when no existing thread should be reused.
curl -s -X POST http://127.0.0.1:8787/codex/workflows/start-visible \
-H 'content-type: application/json' \
-d '{
"laneId":"lane-new",
"title":"New managed thread",
"kickoffPrompt":"Start work on issue T-123",
"preferredSurface":"any",
"requirePreferredSurface":false,
"readinessMode":"lenient"
}' | jqWorkflow:
- lane is created.
- lane session is started (
initialize+initialized). thread/startruns inside that session.- for brand-new threads, kickoff uses the same live session directly (no cross-session resume polling).
for takeover flows, readiness gate retries
thread/readandthread/resume(bounded backoff). - optional kickoff
turn/startruns. - lane status moves to
running.
curl -s 'http://127.0.0.1:8787/codex/active?windowSec=3600&preferredSurface=mac-app&preferOriginator=Codex%20Desktop' | jqNotes:
preferredSurfacenarrows if matches exist.preferOriginatorbiases results if matches exist.- If no preferred matches exist, endpoint falls back to full active list.
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"
}' | jqcurl -s -X POST http://127.0.0.1:8787/lanes/lane-desktop/session/start | jqIf you already have a threadId and want attach + session bootstrap in one call:
curl -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 laneId from each client:
curl -s -X POST http://127.0.0.1:8787/lanes/lane-desktop/prompt \
-H 'content-type: application/json' \
-d '{"input":"Continue and post progress"}' | jqcurl -s -X POST http://127.0.0.1:8787/lanes/lane-desktop/steer \
-H 'content-type: application/json' \
-d '{"input":"Focus only on failing tests"}' | jqcurl -s -X POST http://127.0.0.1:8787/lanes/lane-desktop/interrupt \
-H 'content-type: application/json' \
-d '{}' | jqThe lane session remains server-side; clients can come and go.
List approvals:
curl -s http://127.0.0.1:8787/lanes/lane-desktop/approvals | jqApprove:
curl -s -X POST \
http://127.0.0.1:8787/lanes/lane-desktop/approvals/<approval-id>/approve | jqDeny:
curl -s -X POST \
http://127.0.0.1:8787/lanes/lane-desktop/approvals/<approval-id>/deny | jqWatch SSE:
curl -N http://127.0.0.1:8787/events/streamCreate webhook:
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
}' | jqWebhook payload top-level fields:
eventTypeeventKey(stable dedupe key for consumers)eventIdlaneIdpayloadsubscriptionIdts
Completion event semantics:
- Canonical turn completion:
lane.turn.completed - Raw forwarded app-server notification:
lane.turn.completed.notification - Workflow completion:
lane.completed - Kickoff skip signal when takeover readiness is deferred:
lane.turn.skipped.readiness_deferred
Guaranteed:
- You can take over any known thread by ID.
- You can require surface match on takeover (
requirePreferredSurface=true). - You can filter and prefer surfaces when discovering active threads.
Not guaranteed:
- A brand-new
thread/startcannot be forced onto a specific surface by bridge configuration; Codex determines thread source/surface.
Bridge behavior:
- readiness gate retries with bounded backoff,
- in
lenientmode takeover flows can be recorded as deferred readiness and still emit lane session started. - in
strictmode, readiness failures block start/turn actions.
Check:
GET /lanes/:laneId/eventsGET /lanes/:laneId/session/status
Check:
- Ensure
repoRootis passed during start/takeover so threadcwdmaps to the Desktop workspace you are viewing. - Inspect
GET /lanes/:laneId/session/status:readinessDeferred=truemeans session is alive but resume was not confirmed yet.readinessReasoncontains the latest readiness failure reason.
- Stop lingering sessions with
POST /lanes/:laneId/session/stop.- bridge now sends best-effort
turn/interruptbefore process shutdown to reduce “thinking forever” stuck states.
- bridge now sends best-effort
- Reconcile stale bridge state if you ran many local test lanes:
POST /debug/reconcile-stale(blocks syntheticthr_*running lanes and clears orphanedactiveTurnIdvalues).
Common responses:
409 lane_exists404 thread_not_found409 surface_mismatch
bun x ultracite check
bun run typecheck
bun test
bun run smoke:e2eExpected e2e milestones:
lane.session.startedlane.turn.completedlane.completed