Operator setup, doctor and engineering controls
This page covers what an operator uses to set up and run Open Conduit on a repository: the init, config and secret commands, the GitHub and Claude Code doctor checks, the Work controls in the management API, the CLI and the TUI, the webhook listener with its tunnels, and dry run. All of it uses public surfaces:
- the management API (B05);
- the extension contracts;
- composition in
internal/bootstrap.
Core has only domain-free controls (rebind, hold/release, scope reconcile).
Setup from nothing
Section titled “Setup from nothing”These are the documented steps, which TestDaemonFreshInstallGitHubIssue runs end to end:
open-conduit init github --repo acme/web --scope web --checkout ~/Code/web # or --no-workspace; --holds; --token REFopen-conduit secret set github-token # only when the token is not cmd:gh auth tokenopen-conduit config set /connections/github/secret_refs/token '"keyring:github-token"'open-conduit config checkopen-conduit serve # settings.webhooks for webhooks, --dry-run to watchopen-conduit doctor --check-secretsopen-conduit tuiinitlists the setups the registered extensions offer, andinit <type>runs one (setup is extension-owned; see extension-owned setup).init github:- It detects the current directory’s GitHub remote.
- It adds one scope for a repository: a
githubconnection (default secretcmd:gh auth token), an agent binding onclaude-codewith the GitHub tools, thegithubroute, and thegithub-holdbinding and route with--holds. - It adds a
gitwork resource only when there is a checkout, and then also grants the binding and the scopegithub.git.credential(WorkspaceOperations): git calls it through the credential helper a connection withgitbotinstalls, so the owner never has to know to add it.--no-workspacewrites a workspace-free scope whose turns run in a private directory. - It merges into an existing config and never replaces a scope without
--force. --dry-runprints the result without writing it.- It refuses a token literal:
--tokentakes only a reference.
config path|show|get|set|unset|checkwork on JSON pointers:- Array elements are addressed by
idorname, for example/scopes/web/bindings/agent/config/model. - Edits keep the owner’s key order and untouched text, including handwritten routes.
showandgetredactsecret_refs.setrefuses a value that is not a reference undersecret_refs.- Every edit is validated offline by
bootstrap.ValidateConfigbefore saving: the same registrations asserve, without a daemon, storage or secret resolution. An installed package checks its own entries’ config: it is started forvalidatealone, without a coordination store and on a throwaway state directory (packaging). An invalid edit leaves the file unchanged. A running daemon changes only throughconfig apply, so a disk edit never alters it.
- Array elements are addressed by
config include add [FOLDER] --parent SCOPEandconfig include remove [FOLDER]add or remove an included folder in the config file’sincludes. FOLDER defaults to the current directory and is written as an absolute path. Both validate before saving, likeset.secret set NAME:- It reads the value from stdin, with no echo on a terminal, and never takes it as an argument.
- It stores the value in the platform keyring under the
open-conduitservice: on macOS throughsecurity -i, so the value is not in any argv; on Linux throughsecret-tool. - Otherwise it falls back, and says so, to the owner-only
secrets.jsonbeside the config. - It prints only the reference.
--store keyring|fileforces one store. - The daemon’s resolver reads
file:references from that file.
init --with EXECUTORstarts an executor’s own CLI on the setup playbook in the owner’s terminal, through the executor’s interactive launch (extension.InteractiveLauncher, protocol controlinteractive_launch, methodexecutor.interactive_launch). The core names no CLI:--withtakes any installed or bundled executor that declares the control, by its name or by one of thelaunch_namesits manifest declares (--with claudeis claude-code’s), and the error for any other name lists the executors that offer one. The bundled ones are Claude Code and Codex.- The setup playbook (
playbooks/setup.mdin the setup package, which the release bundles inextensions/setup/beside the binary, or an installed copy in<config dir>/extensions/setup) is rendered with{{bin}}as the agent runs open-conduit. The daemon also adds it to the turn plugin, as it does every package’s playbooks, so a setup session’s binding (playbook: setup) starts on it. With the argumentsession(the setup binding’sargs) the playbook runs in session mode: it probes instead of using a shell and proposes changes for the operator to approve and apply instead of runningconfig setandconfig apply(setup sessions). - The host decides what has authority and sends it in the request: the playbook’s text and name, the commands the session may run without asking (
open-conduit config,doctor,health,secret pathandinit TYPE --dry-runfor each registered or installed setup other than the first-run one), the working directory and<state>/playbooks/<name>as a directory for the files the launch needs. The executor only expresses them in its CLI’s terms and answers the argv, the directory and any variables to add to the environment. It never sees the operator’s environment. An executor whose CLI has no list of commands to allow leaves them to ask, which is the safe side. - The host starts the executor’s package for the one call, with the grant the config gives it, like any other package process, then checks the answer’s program is on
PATH. The session’s environment is the operator’s withOPEN_CONDUIT_CONFIGset and the toolbelt credentials (OS_TOKEN,OS_ENDPOINT,OS_TOKEN_FILE) removed, plus the executor’s variables. A launch that would set anOS_*variable orOPEN_CONDUIT_CONFIGis refused. - The guide teaches
init,config set,secret set, webhooks, permissions, dry run and the TUI controls. Its example config still validates against the daemon registry.
- The setup playbook (
init github --executor codexwrites the agent binding on the Codex executor instead ofclaude-code, with the same playbook, tools, branch and workspace. Its playbook comes from the host’s playbook directory, so the binding needs no path. Thegithub-capabilitycheck treats either executor’s agent binding the same.
config edit opens a full-screen editor with the adapters’ documented fields, validated before saving (installed integrations). The commands above cover the same outcomes from a script: validated edits, add/remove bindings, references for secrets and handwritten routes kept.
Doctor
Section titled “Doctor”GET /v1/doctor (open-conduit doctor [--check-secrets]) runs the core checks (doctor) and the extensions’ checks, each listed under its own name: first-party ones and an installed package’s declared checks alike (check.run). The extensions check reports only the packages’ own health. Every extension check runs on the host’s read-only view of the configuration (protocol.CheckConfig): the scopes that use the extension, their routes and bindings, its own entries and the connections of its types they name, never secret text.
The GitHub extension contributes github, github-capability, github-git-bot and github-auth through inproc.Checker. github reports the entries the extension registered (registration, rate limit, projection, each entry’s webhook route and managed hook, dry run), and github-auth resolves the credentials it verifies through the host’s scoped resolver (inproc.Host.Secrets). github-capability and github-git-bot read nothing but the configuration view and the machine, so they are Offline checks: open-conduit doctor runs them without a daemon too. Details carry codes, identifiers and counts, never references or values.
| Check | Status and codes |
|---|---|
github |
Per entry: registration failures (fail, for example github_connection_required), rate-limit pauses, reconcile state (reconcile_failing with the scan error code and failure count, first scan pending, or the age of the last complete scan), projection failures (projection_failing) and dry run. No service calls. |
github-auth |
Only with check_secrets=true. Resolves each entry’s credentials privately, authenticates the reader and any bot writer, and reads the repository (Client.Verify), through the client the registered entry runs with, not one built from the current config. When the two differ in who writes go out as (Client.Writer) or whether git works as the bot, it warns restart_required and names both; each other github connection a binding in the entry’s scope names is verified the same way, as its own item. Codes: verified; credential_unavailable, credential_rejected, repo_unreachable or rate_limited (fail); read_only when the identity cannot push and projections are live; hook_admin_missing when manage_webhook lacks hook admin; base_unprotected when the base branch (base_branch, else the default branch) has neither branch protection nor a ruleset rule, read from the same branch and rules reads as the required checks (a refused read says nothing), since github.git.credential mints contents: write and nothing on GitHub would stop a push to it; public_unconfined when the repository is public, the entry sets no labels.require and a binding a github route starts runs with no sandbox or container (protocol.CheckBinding.Isolation empty); permission_missing when a binding in the entry’s scope is granted an operation whose permissions (opPermissions: Pull requests write for github.pr.create, github.pr.edit, github.pr.reply and github.pr.review, and Contents write too for github.pr.resolve) the GitHub App that operation writes as lacks on the code repository (the binding’s own connection’s for github.pr.reply, github.pr.review and github.pr.resolve when it names one, the entry’s otherwise; github.pr.create and github.pr.edit always go out as the entry’s), read afresh from GET /repos/<repo>/installation; git_permission_missing when a binding granted github.git.credential is granted operations that add a permission to its git token (gitOpPermissions: Issues write for github.issue.comment and github.issue.create, Pull requests write for the PR write operations) that the App the token is minted from (the binding’s own connection’s when it names one, else the entry’s) lacks, so the token goes without it and a pushed closing keyword doesn’t close its issue; a token connection, whose permissions GitHub doesn’t report, adds nothing. Reports pull/push/admin when GitHub returns them. |
github-capability |
Config gaps the validator accepts but that stop GitHub work: no github route, a route whose binding has no toolbelt, a claude-code binding without github.context/declare_outcome, a hold route that does not bind github-hold or comes after github, and (info) scheduled issues not claimed without a hold route. |
github-git-bot |
For each git workspace binding in a scope whose GitHub connection has git bot: whether it is granted github.git.credential and whether its push remote goes through the toolbelt’s credential helper. git_credential_not_granted (warn) when the binding lacks the operation, so every push is refused; ssh_remote (warn) when the remote is SSH (ssh:// or git@host:path), whose pushes use the operator’s own key; not_configured (info) without one. Runs offline too. |
grants |
Operation grants that leave an agent short (config.GrantWarnings, toolsets): a binding whose playbook uses operations the binding or its scope doesn’t grant, naming the scope, binding and operations, and an operations list that names an earlier version of a toolset’s operations instead of the toolset, with the config set that adopts it. granted (ok) or drift (warn), each finding an item. Runs offline too. |
webhooks |
reconcile_only (info) with no listener; otherwise the bound address, the tunnel and public URL (tunnel_down while it has none yet; tunnel_failed, with why the run stopped and how often it has, while the host restarts it; tunnel_invalid or tunnel_unavailable as fail when the provider refused the config or no package provides the type, and the host stopped trying), where the address and the tunnel came from (serve flags or settings.webhooks), restart_required when an applied generation changed settings.webhooks since serve started. Each extension’s own check lists its entries’ routes, a missing webhook_secret (not mounted) and the managed hook state (ok, dry_run, hook_permission_denied, …). |
ngrok, cloudflared |
The tunnel package’s own check: the configured provider’s version (ok), or tunnel_unavailable as fail when it doesn’t run; not_configured (info) when the webhook tunnel is another type. Runs offline too, on settings.webhooks. |
srt |
The srt package’s check, when a binding declares an srt sandbox: srt <version> at <path> (ok, or untested_version as warn); srt_unavailable, userns_unavailable, seccomp_unavailable or preflight_failed as fail, when sandboxed bindings run nothing (B09). |
docker |
The docker package’s check, when a binding runs in a docker container: for each context and image, the daemon reachable (docker_unreachable, naming settings.extensions.docker.pass_env when the CLI may lack a variable), Engine 28 or later (docker_too_old), the shared path (shared_path_failed), the gateway image (gateway_unset, gateway_unpinned, gateway_missing) and the network preflight (preflight_failed, ipv6_gateway), each fail, when docker container bindings run nothing (containers). |
ssh |
The ssh package’s check, when a binding runs on a remote: for each remote, reachable with its pinned key (remote_unreachable, naming settings.extensions.ssh.pass_env when no agent is granted and the key was refused), the tools a run needs (remote_missing_tool) and the state directory’s path writable there (remote_unwritable), each fail (the SSH provider). |
claude-code |
The claude-code executor’s own check (named claude_code before it moved into the executor), which offline doctor runs too. Runs --version for each claude-code binding’s binary (installed, or claude_missing as fail); reports dry run. For a binding that sets usage: true it also reads the subscription’s 5-hour and 7-day usage windows (the undocumented OAuth endpoint, with that binding’s Claude login, its CLAUDE_CONFIG_DIR or the default) and warns at 90%. A read failure is “usage unknown”, never a fault. Usage reading is opt-in because it reads the login token; serve --claude-usage, which turned it on for the whole daemon, is retired and only warns. |
Operator controls
Section titled “Operator controls”Every control is a management call with the operator credential and an Idempotency-Key. It commits an accepted core.manual.* control Event, marked routed as control, in the same transaction as its state change. History, events chain and telemetry therefore explain it. None launches an executor or forces a wake.
A control’s expected_revision (of the Work, the execution or the scope) is optional. Given, the control acts only if the record is still at that revision (409 revision_conflict otherwise). Omitted, it acts on the record’s current revision when it commits (runtime.CurrentRevision), as the CLI does without --revision. Either way the control Event records the revision it acted on. A value that is not an unsigned decimal string is refused as 400 malformed, naming the field.
Beside these, POST /work/{id}/cancel ends the Work, POST /executions/{id}/cancel ends one attempt and holds the Work for an explicit retry, and POST /work/{id}/interrupt (below) ends the running attempt and keeps the Work going.
| Endpoint | Semantics |
|---|---|
POST /work/{id}/rebind {expected_revision,binding,reason} |
B05 rebind. Ready Work with no live or parked attempt only. The binding must be an enabled binding of the Work’s scope. Moves every queued input (and the retry state) to it. core.manual.rebind records the binding, the previous bindings and the scheduler’s generation. Never moves scope. |
POST /work/{id}/hold, /release {expected_revision,reason} |
A domain-free scheduling hold (core.manual.hold/release), held in its own KV record. While held, Start refuses (not_eligible), including a parked attempt’s resume, but input keeps queueing and Waits keep resolving. Release lets the next attempt see everything that arrived. Allowed on nonterminal Work with no pending or running attempt. GET /work/{id} shows hold. |
POST /work/{id}/interrupt {expected_revision,reason} |
Stops running Work’s current attempt without ending the Work (runtime.Control.InterruptWork), for an operator who wants a turn to stop and read what has queued for it. It commits core.manual.interrupt (with the attempt’s execution_id) and an interrupt intent against the running attempt and its lease epoch, then dispatches it through the same path as a route with policy interrupt (active-event policies): the attempt is fenced (cancelled), the Work is left ready with its queued input kept, and the executor’s Interrupt is called. The next attempt takes that input (for claude-code, a --resume turn); with none queued, the Work waits ready for its next input. The run records the Event that ended it as interrupt_event_id. Only running Work (state_conflict otherwise). Fails closed: when the running attempt was not pinned with the interrupt capability, or its executor doesn’t implement it, the call is refused as interrupt_unsupported and nothing is stopped; there is no fallback to cancel, and a failed dispatch never takes the binding’s control_error_fallback. Returns 202 with event_id, work_id and execution_id. |
POST /work/{id}/attach {} |
Held Work with no pending or running attempt. Returns the attach command (argv, dir) of the executor registration that ran the latest attempt, through the optional public extension.AttachExecutor (attach_unsupported, nothing_to_attach). Work any of whose segments ran in a sandbox is refused as attach_sandboxed before the executor is asked, since the command runs on the host, outside the sandbox (process sandboxes). Work that ran in a container gets open-conduit container attach <work> from its executor, and any other command is refused as attach_sandboxed (attach in a container). For claude-code this is claude --resume <session> in the workspace, and the handoff note is cleared, so the next turn continues the attached session. No environment is returned: the operator’s own shell supplies it. |
POST /work/{id}/container-attach {} |
Held Work with no pending or running attempt whose segments ran in a container (not_contained otherwise; attach_sandboxed if any ran in a process sandbox). Makes an attach container under the binding’s current container policy for what the executor’s ContainerAttach says, records its pin with the latest attempt, and returns its ref and the argv that starts it attached (container_unavailable when the container can’t be made). open-conduit container attach and work attach call it. |
POST /work/{id}/container-detach {"ref"} |
Removes one of the Work’s attach containers, with its gateway and networks. |
POST /scopes/{id}/reconcile {expected_revision} |
B05 operator reconcile. Accepts core.manual.reconcile in the scope and makes every ingress source registered for the scope due now. The scan is the normal asynchronous reconcile. A generation already under way when the request commits (a page in flight, or part way through its pages) completes and leaves the source due, so a fresh generation always starts after the request. Returns 202 with the Event and the source names. |
GET /work/{id} also carries annotations: display-only lines an extension contributes. Management asks every annotating extension at once, each bounded like an action provider’s call, and skips one that keeps timing out until it answers again, so a slow annotator costs a read at most its timeout and doctor’s providers check names it. For GitHub these are the issue, its URL, a held issue’s state, projection dry run and the counts of corrections and problems the agent logged (github.Annotate, durable state only).
CLI. The CLI adds:
work rebind|hold|release|interrupt;work attach ID [--keep-held], which holds (refetching the revision when the host moved the Work), attaches, runs the command with the terminal and releases the hold it placed;scopes reconcile ID;claim [SCOPE#]N [--entry NAME], which invokes the scope’sclaimaction;actions list (--scope S | --work W)andactions run PROVIDER ID (--scope S | --work W) [--input TEXT].
Extension actions. An extension declares the actions available on a scope or Work now (extension.ActionProvider, or work.actions and work.act for a package), each with a stable ID, a label and an optional input prompt. An action is awaited when the Work is waiting for a person to take it (a question it asked, an approval it needs); one a person may take at any time, such as sending a message to an open thread, is not. Only the provider knows which it is, so it says, and the web view’s Needs you lists waiting Work only for an awaited action: Work that offers only standing actions shows under Waiting with its Wait’s description. GET /v1/scopes/{id}/actions and GET /v1/work/{id}/actions list them by provider (a GitHub entry or a package). Each listing call to a provider has a 2 second timeout, and all of a request’s calls 4 seconds; a provider that fails or times out contributes nothing (GET /v1/work?with=actions,panels lists them for a page of Work). POST …/actions/{provider}/{action} with {"input": "…"} and an Idempotency-Key invokes one. The call carries the caller ({kind: "operator", id: "local"} on the management socket). The provider authorises it and either refuses with its own code and message, leaving no Event, or returns the one Event to accept. The host accepts that Event under the source core.action, as the root of its own chain, with delivery provider/action/key. An Event that names a correlation key is addressed by that key. Otherwise an action on Work produces an Event for that Work. Only this path assigns core.action, so posting an Event of the same family and key through POST /v1/events grants none of an action’s authority.
Acting for people on other surfaces. An extension whose surface has real identities (a GitHub repository, a Slack workspace) can invoke an action for a person it identified there: inproc.Host.Act in process, or action.submit for a package with the action.submit permission. The request names the scope, the Work (if any), the action, optionally its provider (otherwise the one provider offering that action on the target now), the input, the person’s ID and an idempotency key. The host always makes the caller {kind: <the extension's name>, id: <the person's ID>}, so an extension vouches only for identities on its own surface: GitHub’s callers are github:<login>, never operator:local. operator is a reserved extension name, and the host refuses an operator kind outright, so nothing can act as the local operator this way. The provider then authorises that caller as it would any other, and the Event goes down the same path under core.action, with the key kept apart from other callers’ as <kind>:<key>. A request with a key already used returns the first Event (added false) without asking the provider again, so a retried answer is the same answer, not a refused second one. The management API’s Idempotency-Key replays the same way. A request nothing offers is refused with no_action, and two providers offering it with ambiguous. To show a person what they can do, the extension lists what every provider offers on the target now (inproc.Host.Actions, or action.list under the same permission), each with its provider, ID, label, prompt, choices and detail; Slack posts the detail with the choices as buttons. An action’s Detail is the provider’s plain-text account of what a person is deciding (a question, or the call an approval is for), for surfaces that show the action away from the Work’s panels.
A manual claim is the GitHub entry’s claim scope action. It refuses any caller but the local operator, input that is not an issue number, and an issue that already has Work. Otherwise it produces github.claim.requested keyed to the entry’s issue/N (schema github.claim.v1). The github route creates github.issue Work for it whatever the labels. The route only accepts the Event from core.action, never feeds existing Work, ignores generation keys, and renders it for the agent as “claimed by hand”.
A human entry offers answer on Work parked on one of its asks, only while the ask is open (see human decisions). It refuses a caller outside the entry’s responders and input that matches none of the ask’s options. Otherwise it produces human.answer (schema human.answer.v1) on the ask’s own key, human/ask/<id>, which no Work owns. The ask’s wait depends on that key, with a filter that counts only this provider’s core.action answer, so the asking Work wakes on the alias human.answered. The asker’s renderer reads the answer through the alias’s causation. The ask records live in the coordination namespace human.asks, and a daemon pass settles them as answered (the first answer accepted, with later ones recorded as late), expired or withdrawn.
Extension panels. An action provider may also show panels (extension.PanelProvider, or view.render with manifest panels for a package). A panel is a constrained document: a title and sections of text, key/value fields, a table, http(s) links and buttons, each button naming one of the provider’s action IDs. GET /v1/scopes/{id}/panels and GET /v1/work/{id}/panels return them by provider. The host leaves out a panel that fails extension.Panel.Validate (size limits, control characters, a link that is not http or https) and resolves each button to the action as listed on the same target now, dropping one that is not listed, so a panel offers nothing the action path would refuse. The web view and the TUI render the same document, so no extension code runs in the browser; a button invokes its action through the actions route like any other. The conformance.CheckPanels case certifies a provider. The human entry shows a question panel on Work with an open ask: the question, its context, the options as a table, who may answer, its status and deadline, and the answer button while the ask can be answered. Its annotation is then just the question, for lists and the CLI.
TUI. The shared view keeps one run model for every executor. With a controlling source (tui.Operator) it adds these keys:
clists the scopes’ actions (claim among them); a number picks one, then its prompt or a y/n;Rruns or retries after a y/n prompt, since a retry starts a new turn;Hreads the Work’s current hold: held Work is released, otherwise it asks for a reason (enteralone recordsoperator (tui)) and holds with it, so history says why;aattaches, throughtea.Execwith the same hold/release flow;iinterrupts a running attempt after a y/n prompt, keeping the Work and its queued input for the next attempt;xcancels the attempt andXthe Work, each after a y/n prompt;lopens a run’s log; on a running run it shows the output so far and follows it;topens a run’s transcript (live or final) from the list or detail, and in the log view steps to the next output (log, stream-json transcript, stderr).
In a detail or log, q goes back like esc; it quits only from the list (ctrl+c quits anywhere). The footers and the key table in the operating guide both render from tui.Keys, and TestDocsTUIKeys (part of make docs-check) fails when the guide’s table differs.
Detail shows the Work’s correlation keys, hold, annotations and active or woken Waits as data, and its actions numbered 1–9, with no executor or domain branch. A refused action shows the provider’s code and message. A read-only source shows none of this.
Webhooks, tunnels and reconcile-only operation
Section titled “Webhooks, tunnels and reconcile-only operation”Webhooks are an accelerator: reconcile always runs, so with no listener the daemon is reconcile-only and loses only latency (settings.reconcile_seconds, default 15 minutes). serve --no-tunnel forces this.
-
Stored settings. The owner records the listener and tunnel once in the config, and a bare
open-conduit serveuses them:"settings": {"webhooks": {"addr": "127.0.0.1:8787", "tunnel": {"provider": "ngrok", "config": {"domain": "hooks.example.ngrok.app"}}}}addris required. Then eithertunnelorpublic_url, not both:tunnelis an object.provideris a tunnel type that an installed or bundled package declares (manifesttunnels):ngrok,cloudflaredor any other.configis that package’s tunnel config. The host knows nothing of its fields:config checkandconfig applyrefuse a type no package declares (unknown_registration), and they ask the package to check the config (validatewith kindtunnel,invalid_configwhen it refuses).servepasses the config totunnel.runexactly as written.open-conduit adapters <type>lists each type’s fields.public_urlalone is a URL the operator already routes to the listener (a reverse proxy, say). It is an http(s) URL with no credentials, and no tunnel runs for it.
- The flat per-type fields of earlier releases (
tunnel_domain,tunnel_name,tunnel_hostname, atunnelstring,static) are refused withmoved_field, and each diagnostic gives its replacement. serve --webhook-addr,--tunnel TYPEwith--tunnel-config '<JSON object>', and--public-urloverride it for one run. The address and the tunnel override separately, and any tunnel flag replaces the whole stored tunnel, config included. The retired--tunnel-domain,--tunnel-name,--tunnel-hostnameand--tunnel-binaryare refused with their replacement (the binary is the tunnel config’sbinary). A tunnel given by flags is checked by its package when the run starts: a config it refuses stops withtunnel_invalid.- The listener starts with
serve, so a change made withconfig applytakes effect at the next restart (doctor reportsrestart_requireduntil then).
-
Listener.
settings.webhooks.addr(orserve --webhook-addr HOST:PORT) binds a separate HTTP server on its own TCP address. Any free local port works (the docs use 8787 as an example); if the bind fails,serveexits saying why (for example, the address is already in use) and suggests another port. It serves only the routes that registered entries name:POST /github/<scope>/<entry>for each GitHub entry that names awebhook_secret;POST /slack/<scope>/<entry>for each Slack entry withreceiver: http(Slack extension); the default Slack receiver is Socket Mode, which needs no listener or tunnel;POST /linear/…,POST /jira/…andPOST /bitbucket/<scope>/<entry>for tracker and code-host entries (installed integrations);POST /schedule/<scope>/<entry>/<job>/<token>, a schedule job’s signed URL (scheduled jobs);- the paths an installed extension package registers when it is configured (extension packaging).
Anything else is 404, including
/v1/.... It never sees management credentials and never forwards to the Unix socket.- Each request is verified by its entry’s adapter against the entry’s secret from the current generation before anything is parsed or accepted. For GitHub that is
github.WebhookHandler(HMAC before parsing, 401 on a bad signature). - A delivery is pushed with
Ingress.Push, and 202 is returned only after durable acceptance. A refusal is 503: GitHub may redeliver, and reconcile recovers the state either way.
-
Tunnel. A package provides each tunnel type, and a bundled one gets no privileges an installed one lacks. Two are bundled beside the binary, both from
extensions/tunnel(extension protocol, manifesttunnels):ngrok(configdomainfor a reserved domain,binary) andcloudflared(a quick tunnel, or confignamewithhostnamefor a named one, andbinary). An installed package’s type (a Tailscale Funnel package, say) is selected the same way.public_urlruns nothing: the operator already routes that URL to the listener, so the host uses it as the public URL.- The host keeps the listener and decides what it serves. It holds the package’s
tunnel.runopen for the daemon’s life, giving it the listener’s address and the tunnel config, and takes each public URL the package reports throughtunnel.url. - A run that stops (ngrok or cloudflared exited, or the package restarted) is started again with a growing wait (5 seconds, doubling to 5 minutes, back to 5 seconds once a run reports a URL). Each start, URL and stop is in the daemon’s log, and doctor’s
webhookscheck shows the last failure. A config the provider refuses, or a type no package provides, is not started again. - The provider forwards only to the webhook listener. Its process sees only the sanitized baseline environment, including
HOME, so ngrok’s and cloudflared’s own config files still apply; a variable such asNGROK_AUTHTOKENreaches it only through the operator’s grant,settings.extensions.ngrok.pass_env(extension packaging).
- The host keeps the listener and decides what it serves. It holds the package’s
-
Managed hooks. An entry with
"manage_webhook": true(which requireswebhook_secret) has its repository webhook created or re-pointed at<public URL>/github/<scope>/<entry>, with the secret and the events the normalizer understands (github.HookEvents). The host finds the hook by its path suffix after a restart, makes no write when it already matches, and retries failures after a minute. Withoutmanage_webhookthe owner adds the webhook by hand.
Minimum GitHub permissions. These apply to the connection identity on each repository:
- Reads. Issues, pull requests, checks, commit statuses, contents and metadata, for reconcile, push enrichment and the agent’s reads.
- Writes. Issues and pull requests (labels, comments, reactions, opening PRs and editing them with
github.pr.edit, submitting reviews withgithub.pr.review, resolving review threads withgithub.pr.resolve, which also needs Contents write), and contents (attachment uploads). A review is submitted as the writer (the bot App, or else the token’s account), which must not be the account that opened the PR to approve it or request changes (whose review it is). - Hooks. Administration only for
manage_webhook.
That maps to these grants:
- Fine-grained token. Issues, Pull requests and Contents read/write; Commit statuses, Metadata and Actions read; Webhooks read/write for
manage_webhook. A fine-grained token cannot be granted Checks. CI is then read from Actions workflow runs and commit statuses, and doctor says so. - GitHub App. The same, plus Checks read.
- Classic token.
repo, plusadmin:repo_hookformanage_webhook. - Bot App (
bot_app_id). It performs the writes and needs only the write permissions. - Reviewer App (a binding’s own
githubconnection). It posts that binding’s comments, replies and reviews: Issues and Pull requests read/write on each repository it reviews, and Contents read/write to resolve review threads withgithub.pr.resolve, which GitHub refuses an App with Contents read.
Dry run
Section titled “Dry run”serve --dry-run makes no external writes:
- Every GitHub entry runs with
projection.dry_run, so labels, comments, reactions and the GitHub tools’ writes reportdry_run. - Managed hooks are only reported (
dry_run). - Claude Code turns run as stubs on a cheap model (
claudecode.Options.DryRun, modelhaiku), told to do nothing and call no tools.
Continuity, outcomes and history behave as usual. Shell executions are not altered, since they are the operator’s own commands. Jobs follow their own dry-run rules (scheduled jobs). Doctor reports dry run on github and claude-code.
Host loop and attempt state
Section titled “Host loop and attempt state”- The host loop skips idle
readyWork (handed back, with no input) quietly rather than reporting it asinvalid_stateon every pass. runtime.AttemptStateInTxexposes an attempt’s pinned executor registration to composition and management.
Extension-owned setup
Section titled “Extension-owned setup”Setup belongs to extensions, not the core CLI. The public, optional extension.Setup contract has two methods. SetupInfo gives the type, a summary and a usage line. Setup(ctx, host, args) returns a SetupResult: connections, one scope, summary lines and next steps.
open-conduit init lists the registered setups. init <type> [flags] runs one. Core keeps only the host:
- Its own flags.
--config,--force,--dry-runand--withare removed wherever they appear; every other argument goes to the extension. - The
SetupHost. It offers prompts (a secret prompt does not echo on a terminal), a numbered choice, a browser opener and aLookPath. It runs a helper command attached to the terminal.StoreSecretuses thesecret setstore (keyring, else the owner-onlysecrets.json) and returns only the reference. A dry run stores nothing and returns the reference it would use. - Merge and save.
MergeSetupkeeps an existing connection with the same id and replaces a scope only with--force. The result is validated offline against the daemon registry, then saved 0600 (or printed with--dry-run).
An extension.ErrSetupUsage refusal is a usage error (exit 2).
The setups are the packages’ own (manifest setup): GitHub, Slack, Linear, Jira and setup are bundled, and an installed package can add one. The entrypoint finds them through cli.Env.Extensions.Setups, and init TYPE runs the flow in its package over setup.run, with the host answering its prompts, secret storage and commands (setup.*). An extension served through inprocsdk offers its flow by implementing inproc.SetupFlower. init setup [--scope ID] writes the scope a setup session runs in, the same scope the daemon runs on when it starts with no config file. internal/cli imports no extension. The B01 checker forbids it, and scripts/check-boundaries has explicit direct and transitive cases for core CLI and TUI. The GitHub init (remote detection, ScopeConfig, the toolsets it grants) lives in extensions/github/setup.go as init github, with the same flags and behavior. Slack’s setup is described in the Slack extension.
Other setup and recovery commands
Section titled “Other setup and recovery commands”- Trackers and code hosts.
init linearandinit jira(each with an optional--bitbucketcode host),adapters [TYPE],route KEY,config edit, and the tracker, Bitbucket and PostHog doctor checks and webhook routes are described in installed integrations. - Offline recovery.
backup create|verify|restore,state checkandauth recoverare described in recovery. The state commands take the daemon lock and refuse while a daemon owns the directory.
Verification
Section titled “Verification”go test -race ./internal/cli ./internal/tui ./extensions/tunnel ./extensions/github/... ./extensions/claudecodego test -race -run 'TestManagementOperatorControls|TestOfflineValidation|TestSetupCommand|TestSetupGuide|TestDaemonFreshInstall|TestDaemonGitHubWebhookListener|TestDaemonGitHubDryRun' ./integrationmake check && GOTOOLCHAIN=go1.24.2 make check && make cross-build && make drill- Fresh installation.
TestDaemonFreshInstallGitHubIssueruns the following steps against the composed daemon, the GitHub fake and a fake claude:init,secret set(file store, never echoed),config setandconfig check.- Daemon start, and doctor with
--check-secretspassing (github-auth verified,claude-code installed,github-capability ok,webhooks reconcile_only), with no reference or value in the report. - The issue claimed and worked, with labels converging and annotations shown.
work attachholding during the session and releasing after, both ascore.manualEvents in the Work’s history.- A manual claim of a skip-labelled issue, and a scope reconcile.
- A revoked token diagnosed as
reconcile_failingandcredential_rejected.
- Webhooks.
TestDaemonGitHubWebhookListenerchecks the listener on an ephemeral port, a static public URL creating the managed hook, a signedissuesdelivery accepted (202) and worked well before the next reconcile, and refusals for a bad signature (401), an unknown route and/v1/health(404). - Dry run.
TestDaemonGitHubDryRunchecks that the issue is still claimed and worked while GitHub records no writes and no hook, and that doctor reports the dry run. - Controls.
TestManagementOperatorControlsruns on SQLite through the real server and CLI. It covers rebind (refused for an unknown binding or a stale revision, queued input moved, the Event payload), hold blockingStartand release allowing it, attach refused without executor support and leaving an existing hold in place, and scope reconcile making a source due. - Offline validation and setup.
setup_guide_test.goinstalls the pinned setup package beside a config file, asextension installwould, and checks that offline validation accepts the guide’s example and every scopeinitwrites and rejects an unknown executor, and that the setup command builds the right plugin, arguments and environment. - Tunnels.
TestDaemonPackagedTunnelruns the bundled cloudflared package against a fake cloudflared and checks that the URL it reports reaches doctor and the managed hook, and that the package’s check passes. - Unit tests. They cover JSON pointer editing,
init/secret/configbehavior, the tunnel providers (fake ngrok and cloudflared processes, the URL over the wire, a refused config) and the host’s supervision of a run (restart after a stop, no restart after a refusal or with no provider),Verify,EnsureWebhook(create, unchanged, re-point by suffix, dry run), the manual claim route, annotations, Claude CodeVersion,Usageagainst a fake endpoint, the dry-run turn, and TUI controls with confirmation, the claim prompt and Work context.
LoginToken is not exercised in tests, because on macOS it reads the real Keychain.
Limitations
Section titled “Limitations”- Root command. A bare
open-conduitprints help; it does not choose between the daemon and the TUI. - Unenforced setting.
settings.admission_limitis validated but not enforced;config checkwarns where a config sets it, andinitno longer writes it. - Rebind and reload. A rebind takes effect at the next scheduling decision, which pins the binding from the generation the scheduler runs.
History
Section titled “History”- 03-05 made the GitHub path operable for the first deployment:
init github, theconfigandsecretcommands,init --with claude, the GitHub and Claude Code doctor checks, the operator controls, the webhook listener and tunnels, and dry run. Before it the daemon’s resolver had no secrets file configured. It also stopped the host loop reporting idlereadyWork asinvalid_stateand exposedruntime.AttemptStateInTx. - At 03-05 no real GitHub write, webhook, tunnel process or Claude Code session ran; all evidence used fixtures. Real evidence (a real repository, a real tunnel and a real agent turn) was left to 03-06.
- At 03-05 the schema-driven interactive config editor was not ported (deferred to 06-01), nor a rendered transcript and activity view (raw transcripts were reachable as artifacts with
t), nor automatic headless/TUI selection on the root command (left to 06-03 packaging).settings.admission_limitwas a 02-09 open item. - 03-06 added the fine-grained-token fallback to Actions workflow runs and commit statuses, and doctor’s note about it.
- 04-01 mounted
POST /slack/<scope>/<entry>on the webhook listener for Slack entries withreceiver: http, and set out the intended setup flow: a generic optional extensionSetuphook that coreinit <type>dispatches to, with core supplying only the setup host. - 04-02 moved setup out of the core CLI into the extension-owned
extension.Setupcontract, withinit githubkeeping the same flags and behaviour. At that point the injected setups were GitHub and Slack. - 05-04 fixed scope reconcile: before it, a request that arrived while a generation was under way was absorbed, and the next scan waited a full interval.
- 06-01 added
init linear,init jira,adapters,route,config edit(the full-screen editor) and the tracker, Bitbucket and PostHog doctor checks and webhook routes. A rendered activity artifact for Claude Code turns, shown by the TUI andruns log, was added alongside them. - 06-02 added the offline recovery commands.
- 07-01 renamed the attach refusal
no_sessiontonothing_to_attach. - Issue #235 moved the
githubandgithub_authchecks out ofinternal/bootstrapinto the GitHub extension (gh.Extension, on the in-process list). Their results are unchanged;github_authnow reportscredential_unavailablefor an entry whose connection the current generation no longer names, where before it signed in with the registration’s connection. - Issue #240 moved
github_capabilityandgit_botinto the GitHub extension too, asOfflinechecks on the host’s configuration view, sointernal/bootstraphas no GitHub doctor code left. Their results are unchanged. An installed package’s checks, until then folded into theextensionscheck, are listed under their own names and receive the same view, andconfig checkhas its packages validate their own entries without a daemon. - Issue #461 had
init githubgrant toolsets (@github.author,@github.workspacewith a git workspace,@github.holdswith--holds) instead of lists of operation names, so a scope it wrote gets the operations a later release adds to those roles, and added doctor’sgrantscheck. - Issue #303 had
init githubandmigrategrantgithub.git.credentialto a binding with a git workspace, andgit_botwarn (git_credential_not_granted) about one in a gitbotscope that is not granted it. Before, a scope setup wrote could not push as the bot. - Issue #506 made
settings.webhooks.tunnelan object,{"provider": …, "config": …}, naming any tunnel type a package declares, with a config only that package reads: it checks the config throughvalidatekindtunnel, and the host passes it totunnel.runas written. Before this the core listed the types (ngrok,cloudflared,static) and each one’s fields (tunnel_domain,tunnel_name,tunnel_hostname,--tunnel-binary), so an installed tunnel package couldn’t be selected. Those fields and flags are refused now, and each diagnostic names its replacement.public_urlalone replacesstatic, andservice installno longer looks for the tunnel’s program.