Skip to content

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:

Core has only domain-free controls (rebind, hold/release, scope reconcile).

These are the documented steps, which TestDaemonFreshInstallGitHubIssue runs end to end:

Terminal window
open-conduit init github --repo acme/web --scope web --checkout ~/Code/web # or --no-workspace; --holds; --token REF
open-conduit secret set github-token # only when the token is not cmd:gh auth token
open-conduit config set /connections/github/secret_refs/token '"keyring:github-token"'
open-conduit config check
open-conduit serve # settings.webhooks for webhooks, --dry-run to watch
open-conduit doctor --check-secrets
open-conduit tui
  • init lists the setups the registered extensions offer, and init <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 github connection (default secret cmd:gh auth token), an agent binding on claude-code with the GitHub tools, the github route, and the github-hold binding and route with --holds.
    • It adds a git work resource only when there is a checkout, and then also grants the binding and the scope github.git.credential (WorkspaceOperations): git calls it through the credential helper a connection with git bot installs, so the owner never has to know to add it. --no-workspace writes 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-run prints the result without writing it.
    • It refuses a token literal: --token takes only a reference.
  • config path|show|get|set|unset|check work on JSON pointers:
    • Array elements are addressed by id or name, for example /scopes/web/bindings/agent/config/model.
    • Edits keep the owner’s key order and untouched text, including handwritten routes.
    • show and get redact secret_refs.
    • set refuses a value that is not a reference under secret_refs.
    • Every edit is validated offline by bootstrap.ValidateConfig before saving: the same registrations as serve, without a daemon, storage or secret resolution. An installed package checks its own entries’ config: it is started for validate alone, without a coordination store and on a throwaway state directory (packaging). An invalid edit leaves the file unchanged. A running daemon changes only through config apply, so a disk edit never alters it.
  • config include add [FOLDER] --parent SCOPE and config include remove [FOLDER] add or remove an included folder in the config file’s includes. FOLDER defaults to the current directory and is written as an absolute path. Both validate before saving, like set.
  • 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-conduit service: on macOS through security -i, so the value is not in any argv; on Linux through secret-tool.
    • Otherwise it falls back, and says so, to the owner-only secrets.json beside the config.
    • It prints only the reference. --store keyring|file forces one store.
    • The daemon’s resolver reads file: references from that file.
  • init --with EXECUTOR starts an executor’s own CLI on the setup playbook in the owner’s terminal, through the executor’s interactive launch (extension.InteractiveLauncher, protocol control interactive_launch, method executor.interactive_launch). The core names no CLI: --with takes any installed or bundled executor that declares the control, by its name or by one of the launch_names its manifest declares (--with claude is 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.md in the setup package, which the release bundles in extensions/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 argument session (the setup binding’s args) 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 running config set and config 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 path and init TYPE --dry-run for 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 with OPEN_CONDUIT_CONFIG set and the toolbelt credentials (OS_TOKEN, OS_ENDPOINT, OS_TOKEN_FILE) removed, plus the executor’s variables. A launch that would set an OS_* variable or OPEN_CONDUIT_CONFIG is 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.
  • init github --executor codex writes the agent binding on the Codex executor instead of claude-code, with the same playbook, tools, branch and workspace. Its playbook comes from the host’s playbook directory, so the binding needs no path. The github-capability check 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.

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.

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’s claim action;
  • actions list (--scope S | --work W) and actions 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:

  • c lists the scopes’ actions (claim among them); a number picks one, then its prompt or a y/n;
  • R runs or retries after a y/n prompt, since a retry starts a new turn;
  • H reads the Work’s current hold: held Work is released, otherwise it asks for a reason (enter alone records operator (tui)) and holds with it, so history says why;
  • a attaches, through tea.Exec with the same hold/release flow;
  • i interrupts a running attempt after a y/n prompt, keeping the Work and its queued input for the next attempt;
  • x cancels the attempt and X the Work, each after a y/n prompt;
  • l opens a run’s log; on a running run it shows the output so far and follows it;
  • t opens 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 serve uses them:

    "settings": {"webhooks": {"addr": "127.0.0.1:8787", "tunnel": {"provider": "ngrok", "config": {"domain": "hooks.example.ngrok.app"}}}}
    • addr is required. Then either tunnel or public_url, not both:
      • tunnel is an object. provider is a tunnel type that an installed or bundled package declares (manifest tunnels): ngrok, cloudflared or any other. config is that package’s tunnel config. The host knows nothing of its fields: config check and config apply refuse a type no package declares (unknown_registration), and they ask the package to check the config (validate with kind tunnel, invalid_config when it refuses). serve passes the config to tunnel.run exactly as written. open-conduit adapters <type> lists each type’s fields.
      • public_url alone 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, a tunnel string, static) are refused with moved_field, and each diagnostic gives its replacement.
    • serve --webhook-addr, --tunnel TYPE with --tunnel-config '<JSON object>', and --public-url override 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-hostname and --tunnel-binary are refused with their replacement (the binary is the tunnel config’s binary). A tunnel given by flags is checked by its package when the run starts: a config it refuses stops with tunnel_invalid.
    • The listener starts with serve, so a change made with config apply takes effect at the next restart (doctor reports restart_required until then).
  • Listener. settings.webhooks.addr (or serve --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, serve exits 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 a webhook_secret;
    • POST /slack/<scope>/<entry> for each Slack entry with receiver: http (Slack extension); the default Slack receiver is Socket Mode, which needs no listener or tunnel;
    • POST /linear/…, POST /jira/… and POST /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, manifest tunnels): ngrok (config domain for a reserved domain, binary) and cloudflared (a quick tunnel, or config name with hostname for a named one, and binary). An installed package’s type (a Tailscale Funnel package, say) is selected the same way. public_url runs 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.run open for the daemon’s life, giving it the listener’s address and the tunnel config, and takes each public URL the package reports through tunnel.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 webhooks check 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 as NGROK_AUTHTOKEN reaches it only through the operator’s grant, settings.extensions.ngrok.pass_env (extension packaging).
  • Managed hooks. An entry with "manage_webhook": true (which requires webhook_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. Without manage_webhook the 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 with github.pr.review, resolving review threads with github.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, plus admin:repo_hook for manage_webhook.
  • Bot App (bot_app_id). It performs the writes and needs only the write permissions.
  • Reviewer App (a binding’s own github connection). 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 with github.pr.resolve, which GitHub refuses an App with Contents read.

serve --dry-run makes no external writes:

  • Every GitHub entry runs with projection.dry_run, so labels, comments, reactions and the GitHub tools’ writes report dry_run.
  • Managed hooks are only reported (dry_run).
  • Claude Code turns run as stubs on a cheap model (claudecode.Options.DryRun, model haiku), 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.

  • The host loop skips idle ready Work (handed back, with no input) quietly rather than reporting it as invalid_state on every pass.
  • runtime.AttemptStateInTx exposes an attempt’s pinned executor registration to composition and management.

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-run and --with are 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 a LookPath. It runs a helper command attached to the terminal. StoreSecret uses the secret set store (keyring, else the owner-only secrets.json) and returns only the reference. A dry run stores nothing and returns the reference it would use.
  • Merge and save. MergeSetup keeps 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.

  • Trackers and code hosts. init linear and init jira (each with an optional --bitbucket code 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 check and auth recover are described in recovery. The state commands take the daemon lock and refuse while a daemon owns the directory.
Terminal window
go test -race ./internal/cli ./internal/tui ./extensions/tunnel ./extensions/github/... ./extensions/claudecode
go test -race -run 'TestManagementOperatorControls|TestOfflineValidation|TestSetupCommand|TestSetupGuide|TestDaemonFreshInstall|TestDaemonGitHubWebhookListener|TestDaemonGitHubDryRun' ./integration
make check && GOTOOLCHAIN=go1.24.2 make check && make cross-build && make drill
  • Fresh installation. TestDaemonFreshInstallGitHubIssue runs the following steps against the composed daemon, the GitHub fake and a fake claude:
    1. init, secret set (file store, never echoed), config set and config check.
    2. Daemon start, and doctor with --check-secrets passing (github-auth verified, claude-code installed, github-capability ok, webhooks reconcile_only), with no reference or value in the report.
    3. The issue claimed and worked, with labels converging and annotations shown.
    4. work attach holding during the session and releasing after, both as core.manual Events in the Work’s history.
    5. A manual claim of a skip-labelled issue, and a scope reconcile.
    6. A revoked token diagnosed as reconcile_failing and credential_rejected.
  • Webhooks. TestDaemonGitHubWebhookListener checks the listener on an ephemeral port, a static public URL creating the managed hook, a signed issues delivery 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. TestDaemonGitHubDryRun checks that the issue is still claimed and worked while GitHub records no writes and no hook, and that doctor reports the dry run.
  • Controls. TestManagementOperatorControls runs 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 blocking Start and 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.go installs the pinned setup package beside a config file, as extension install would, and checks that offline validation accepts the guide’s example and every scope init writes and rejects an unknown executor, and that the setup command builds the right plugin, arguments and environment.
  • Tunnels. TestDaemonPackagedTunnel runs 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/config behavior, 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 Code Version, Usage against 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.

  • Root command. A bare open-conduit prints help; it does not choose between the daemon and the TUI.
  • Unenforced setting. settings.admission_limit is validated but not enforced; config check warns where a config sets it, and init no 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.
  • 03-05 made the GitHub path operable for the first deployment: init github, the config and secret commands, 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 idle ready Work as invalid_state and exposed runtime.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_limit was 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 with receiver: http, and set out the intended setup flow: a generic optional extension Setup hook that core init <type> dispatches to, with core supplying only the setup host.
  • 04-02 moved setup out of the core CLI into the extension-owned extension.Setup contract, with init github keeping 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 and runs log, was added alongside them.
  • 06-02 added the offline recovery commands.
  • 07-01 renamed the attach refusal no_session to nothing_to_attach.
  • Issue #235 moved the github and github_auth checks out of internal/bootstrap into the GitHub extension (gh.Extension, on the in-process list). Their results are unchanged; github_auth now reports credential_unavailable for an entry whose connection the current generation no longer names, where before it signed in with the registration’s connection.
  • Issue #240 moved github_capability and git_bot into the GitHub extension too, as Offline checks on the host’s configuration view, so internal/bootstrap has no GitHub doctor code left. Their results are unchanged. An installed package’s checks, until then folded into the extensions check, are listed under their own names and receive the same view, and config check has its packages validate their own entries without a daemon.
  • Issue #461 had init github grant toolsets (@github.author, @github.workspace with a git workspace, @github.holds with --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’s grants check.
  • Issue #303 had init github and migrate grant github.git.credential to a binding with a git workspace, and git_bot warn (git_credential_not_granted) about one in a git bot scope that is not granted it. Before, a scope setup wrote could not push as the bot.
  • Issue #506 made settings.webhooks.tunnel an object, {"provider": …, "config": …}, naming any tunnel type a package declares, with a config only that package reads: it checks the config through validate kind tunnel, and the host passes it to tunnel.run as 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_url alone replaces static, and service install no longer looks for the tunnel’s program.