Skip to content

Configuration, secrets and local management

This page records decision B05: the version 1 configuration format and its inheritance, reload and execution snapshots, secret references and their resolution, and the local management transport, authentication and API. It sits alongside B02, B03 and B04. The daemon implements it in internal/config (loading, validation, reload snapshots and secret resolution) and internal/management (the Unix-socket server and authentication), with the operator client in client; see toolbelt authority, configuration and local management. The JSON Schema specifies version 1 configuration; examples and scripts/check-config-contract.py check its shape and cross-reference/capability rules against a synthetic registry. Each extension package declares its own config types in its manifest, and the host validates their config through the package (validate), offline as well as in the daemon.

The operator binary is open-conduit; the execution toolbelt binary is os. Config defaults to ${XDG_CONFIG_HOME:-$HOME/.config}/open-conduit/config.json; state defaults to ${XDG_STATE_HOME:-$HOME/.local/state}/open-conduit. Both defaults apply on macOS and Linux. The config contains JSON (no YAML implicit types). JSON objects must reject duplicate keys. Config path precedence is --config → OPEN_CONDUIT_CONFIG → default. State path precedence is --data-dir → OPEN_CONDUIT_DATA_DIR → default; config cannot change storage location on reload. Relative explicit paths resolve against the invoking process’s startup directory and are canonicalized once. Extension path fields resolve against the config directory unless their own documented schema explicitly uses an optional resource directory. No implicit checkout search occurs: a folder’s config is read only when the config file includes it (see included folders).

State contains conduit.db, artifacts/, private/, management.sock and runs/ (each execution’s own toolbelt socket and token directory; see execution endpoints). Config/state/private directories are owner-only 0700, config/secret/auth/database files 0600, management socket 0600; reject unsafe ownership, symlinked private files/socket paths and group/world access rather than silently trusting them. Refuse state sharing with another instance; acquire an exclusive daemon lock before touching the socket/database. Only remove a stale socket after the lock and ownership checks. If the default Unix socket path exceeds the platform limit, require an explicit shorter absolute --socket; do not silently bind TCP. CLI flags override OPEN_CONDUIT_SOCKET, then the selected state directory supplies the socket path. Open Conduit supports only Unix local transport, on macOS and Linux; another platform needs an explicit equivalent contract before support is claimed.

Keyring service is open-conduit; file:name indexes the config directory’s secrets.json. OS_ENDPOINT, OS_TOKEN and os are fixed toolbelt identifiers, independent of product naming.

IDs and entry names in config are stable secret-free identifiers matching [a-z][a-z0-9_-]{0,62}. Scope IDs are persisted identity, not paths. Scope name fields are nonempty secret-free display labels (not restricted to the identifier regex) and may change without moving Work. Renaming an ID is remove/add and is subject to live-reference rules below. No ID, route, binding or artifact ID is interpolated into an unchecked filesystem path.

All core objects reject unknown fields; extension/executor config objects are opaque to core and strictly checked by their registered owner. Credentials belong only in secret_refs. Missing required fields, duplicate JSON keys/named list entries, bad types and explicit null are errors. A binding needs only name and executor, a route name, handler, binding and active_policy, and an extension name and type; their requires, operations, secret_names and config default to empty. Each diagnostic names its remedy: an unknown field suggests the nearest allowed name (or lists the allowed ones), an unregistered type, executor or provider lists what is registered, and invalid JSON gives its line and column. No environment interpolation occurs in ordinary strings. An omitted optional field takes the schema default; defaults are materialized before hashing/validation. Arrays retain order; object key order has no semantic meaning. version must be exactly 1. Future unknown versions fail, with conversion handled explicitly.

Object Meaning
settings Concurrency of agent turns (default 4) and, separately, of jobs (job_concurrency, default 4; see Execution scheduling), per-scope ready/running admission limit (default 10), reconcile interval seconds (default 900), execution timeout seconds (default 3600), kept executions per Work (default 20), days a finished Work’s runs keep their content (kept_finished_days, default 14; see Retention), retry delays seconds (default [300,600], yielding three total failure attempts; [300,600,900] explicitly selects four), positive causal depth/rate limits (defaults 32/128 per minute), and usage budgets (none by default; see Execution scheduling). Timeout/retention are one model for all executors.
connections[] Stable id, registered type, opaque nonsecret config, named secret_refs. Credentials never determine source identity.
scopes[] Stable id, display name, optional parent, enabled (default true), optional stricter limits, authorization ceiling, named bindings/routes/extension entries and secret-reference overlays. At least one scope; no implicit root or executor.
authorization Explicit connection IDs, operations (names, an extension’s toolsets as @<name>, and ! denials), cross-scope read_scopes (waiting on another scope’s events) and the optional file_scopes (filing issues in, and commenting on, another scope’s tracker; read_scopes doesn’t grant it). Root scopes default to empty grants. Omitted child authorization inherits; explicit child lists must be subsets of the parent’s effective lists. This is a ceiling, never a grant to every execution. No wildcard or implicit sibling access.
bindings[] Name, registered executor, optional connection (granted by the scope authorization; its executions’ toolbelt operations may act as it, through the extension that owns its type), mechanical requires, exact operation allowlist, names of scope secrets, optional resource descriptor (provider, opaque config) and optional timeout_seconds (1 to 2147483647), which replaces settings.timeout_seconds for its attempts with every executor. Optional grants: what the binding offers a Work beyond that, each a name, an optional label, operations within the scope authorization and executor config whose top-level fields replace the binding config’s own (see binding grants). Executor config is whole-entry replaced; there is no default agent.
routes[] Name, registered deterministic handler, binding, active policy, optional explicit unsupported/error fallback (queue or drop), opaque handler config. No coalesce or policy references/cycles. Drop still has an explicit binding for auditable configuration.
extensions[] Name, registered type, optional connection, opaque config, selected scope secret names. Ingress entries declare source namespace and target scope inside their owner’s schema; that scope must be the containing scope. Resources are optional and acquired only for bindings requesting them.

Parent references must exist and be acyclic. Effective enabled status is the AND of ancestors. Scope limits may only narrow ancestors; global and all ancestor concurrency/admission limits apply together. Bindings inherit by name, nearest whole entry wins. Routes use B04 stable slots: root-to-leaf, whole same-name override in its ancestor slot, new names append; first match wins. Named extension config and scope secret_refs overlay by name, replacing the whole value rather than recursively merging. Connection IDs are global definitions, usable only within the effective ceiling. An inherited extension/binding resolves against the descendant’s effective secret names/ceiling and must revalidate there.

Any named binding/route/extension can instead be { "name": "...", "disabled": true }. A tombstone masks the inherited entry without shifting its slot; descendants can explicitly replace that name again in the original slot. Reject disabling a nonexistent name and routes referencing disabled bindings. A secret overlay value false removes that secret name; a descendant can explicitly restore it. Effective config maps onto domain.Scope (routes/bindings without tombstones; extension/limit/authorization metadata in opaque Config; effective references in SecretRefs). It adds no primitive.

Validation runs without network access, secret resolution or command execution: strict parse/schema → defaults → scope graph → inheritance → registration/owner schema checks → references/authorization → capabilities. Validate extension.ValidateBinding and advertised optional interfaces, transport requirements, active policy controls/fallbacks, resource provider availability and workspace requirement. Workspace requires both mechanical capability and a bound resource provider; no workspace/repo/agent is required otherwise. Missing capability with an explicit queue/drop fallback is valid only for the route’s active policy; an explicit binding requires.inject still fails. Every requested operation must be registered and within the scope ceiling; route/handler requirements must be included in its binding. Connection and secret selection must be explicit. Owner validation must reject undeclared secret fields/literals in opaque config; do not infer secret fields by string prefixes alone.

Owners validate their opaque config at load/apply time and report failures as invalid_config without echoing values: executors through the optional ConfigValidator hook, route handlers through Registry.HandlerConfig (the schedule handler uses it for its job selection and its triggers list: timer, webhook, event, manual; see scheduled jobs), and resource providers by strictly validating a binding’s resource config.

Errors contain safe JSON pointers, stable codes and remedies, for example scopes/0/bindings/0/requires/inject: unsupported_capability: executor shell has no inject; select an inject executor or remove the requirement and set route unsupported_fallback=queue. Return all independent validation failures in deterministic pointer/code order. Never echo field values, secret references’ command text or underlying credential-provider errors.

The config file’s optional includes list names folders, each as { "path": "...", "parent": "<scope id>" }. path is absolute or relative to the config file’s directory (no ~), and parent is a scope defined in the config file itself. Each folder keeps its scopes in <folder>/.open-conduit/config.json, as { "version": 1, "scopes": [...] }. That fragment has no connections or settings (so no package grant), and its scopes carry no secret_refs, and its bindings no pass_env, so its references (and any cmd: they run) and the daemon variables a run may see stay in the config file. The fragment must be a regular file owned by the daemon’s user that no one else can write, and no larger than 1 MiB. It may be readable by others, as a checkout’s files usually are.

Composition (config.ValidateFile) runs before the validation above and turns the file and its folders into one document:

  • Scopes are appended. A folder scope without a parent gets the include’s parent. One with a parent must name the include’s parent or another scope of the same folder. Every folder scope therefore sits beneath a scope of the config file and inherits its ceiling, connections and secret names, which it can only narrow. A config file scope may not sit under a folder’s scope.
  • Each scope records its folder. The composed document records each folder scope’s folder as source, and the includes list itself is not stored. A stored generation is self-contained: restoring it reads no folder. source is accepted only in a stored generation, never in the config file.
  • Executors adapt folder bindings. An executor registration may implement config.FolderConfigurer (extension.FolderConfigurer); a package’s executor declares it as executors[].folder_config, and the host asks it through validate with kind executor_folder (extension protocol). Composition calls it once for each binding from a folder, and the stored generation keeps the adapted config. A refusal, or a package that can’t answer, rejects the binding config rather than storing it unadapted. The Claude Code executor uses it to run a workspace-free binding in the folder and to resolve relative dir/plugin_dir against it.
  • A missing folder doesn’t invalidate the config. When a folder or its config file doesn’t exist, the committed generation’s scopes from that folder are carried over with enabled: false. Disabling follows the rules below: ingress, claims and timers stop, and Work, waits and history are kept. The rest of the configuration still validates and applies. When the folder returns, its fragment is read again on the next apply. Removing the include drops the scopes, subject to the usual drain rule (scope_in_use). A folder that was never applied, or one that is missing during an offline check, contributes nothing. config check warns about every missing folder.
  • Folder scopes route only their own entries’ Events. The runtime routes an Event in the scope of the extension entry that observed it, with that scope’s own and inherited routes. Inheritance runs from parent to child, so a folder scope’s routes never see a repository whose entry is in the config file’s scope. A folder scope that should act on such a repository (a review bot, say) declares its own entry for it, under its own namespace; for GitHub see which scope routes an entry’s events.
  • Diagnostics point into the include. Problems in a folder’s scopes are reported at includes/<i>/scopes/<j>/..., and problems reading its file at includes/<i>/path.

config validate is side-effect-free and returns normalized, redacted diagnostics plus a candidate hash. POST /config/apply requires the current generation and candidate hash; open-conduit config apply FILE reads the current generation itself unless --generation N asks to apply only over N. Read the entire candidate once; an atomic save uses a same-directory temporary 0600 file, fsync, rename and parent-directory fsync, keeping a last-good backup with the same protections. Disk edits alone never change running behavior, whether to the config file or an included folder’s: explicit apply performs the full validation, composing includes from their folders relative to the daemon’s config file. There is no file watcher; one would have to call the same operation. Requests carry config inside JSON, so a file applied as config is saved in compact form; one applied as config_text, the file’s text as a JSON string, is validated as parsed and saved byte for byte, keeping the operator’s layout and key order. open-conduit config apply FILE sends config_text. Invalid edits leave the last good generation active. Transport/storage paths and registration availability are startup-only; changes return restart_required, not a partial apply.

Publish one immutable generation atomically with its durable activation record; no split generation across scopes, bindings or ingress ownership. A crash before publication retains the old generation; after it, restart restores the committed generation, or, when that generation no longer validates against the running release, publishes the config file over it if the file validates. Failed publication cannot acknowledge success. Retain nonsecret normalized snapshots and references while Work/attempts/decisions need them; never persist resolved secrets. Reconcile cursors stay keyed by stable source namespace, not credential or generation. Move ingress ownership only at the generation boundary, stopping old dispatch and starting one owner; no dual source owner.

New uncommitted routing decisions use the current generation. Committed decisions replay their stored generation and result. A newly scheduled attempt pins binding/executor/resource/config and operation grant from its committed decision; if that pinned configuration is no longer authorized/enabled, defer safely with a diagnostic until explicitly rebound. Operator rebind uses revision checking, only while ready with no live/parked attempt, and records a causally linked event. Work never changes scope. Retries resolve a fresh binding at their new scheduling decision; record that generation explicitly. A parked/resumed or adopted attempt retains its original executor/connection identity and snapshot; removed implementations required for recovery cannot be unloaded.

Normal binding/route/secret changes affect future attempts. Active/parked attempts keep their pinned behavior, but authority only narrows: each call checks the pinned grant intersected with current scope/ancestor ceiling, enabled state and current token/lease validity. Revoked operations/connections/secrets are denied immediately; new grants require a new attempt. Disabling a scope stops ingress delivery, new claims, timer scheduling and execution calls; accepted history/timers remain durable and due events resume through acceptance on re-enable. Read-only management history remains available. Explicit cancellation is required to stop an existing process; disabling fences its token but cannot erase already issued external credentials/actions. On re-enable a fenced lease/token is never revived: recover/reclaim and issue fresh authority. Remove/reparent scopes only after draining nonterminal Work, waits, attempts and ingress ownership; otherwise return scope_in_use. Terminal history retains its scope/snapshot.

A binding’s grants let one Work have more than the binding gives every Work, without a second binding. A route that creates Work names the grants it opts into (extension.RouteDecision.Grants), and the Work keeps them (domain.Work.Grants, set at creation and never changed). For example, the setup scope offers shell and connectors, which the operator ticks when starting a session.

  • Ceiling. The config file is still the ceiling. A grant’s operations must sit within the scope authorization, as the binding’s own do, and the executor checks each grant’s config as the binding with that grant alone applied. A Work can take only what the binding offers, and a name it doesn’t offer is ignored.
  • When an attempt starts. The scheduler applies the Work’s grants to the binding it pins: each grant the binding offers under one of the Work’s names, in the binding’s order, replaces the binding config’s top-level fields with its own (domain.Binding.Granted). The attempt state records the names it applied. The toolbelt grant is then the binding’s operations plus those grants’ operations, intersected with the current ceiling as always. The executor sees only the resulting config and never needs to know about grants.
  • Route checks. A route handler’s binding check (extension.RouteBinding) sees the grants too, so an extension can decide what its route’s binding may offer.

Only reference forms keyring:name, file:name, env:NAME and cmd:command are accepted in declared secret slots; literals/empty names/unknown schemes fail. keyring and file names use safe configured secret IDs; env uses [A-Za-z_][A-Za-z0-9_]*. file:name is a key in owner-only secrets.json, never an arbitrary path. cmd: explicitly executes sh -c under the daemon account with a 10-second deadline, bounded output (64 KiB), a minimal environment (PATH is the system directories then the usual Homebrew prefixes /opt/homebrew/bin, /usr/local/bin and /home/linuxbrew/.linuxbrew/bin) and no toolbelt token. A command the shell cannot find fails command_not_found; doctor names the program (never its arguments) and the PATH searched, and config check/init warn when a cmd: reference’s program is not on that PATH. Config is trusted local executable input; requests cannot supply arbitrary secret references or commands. Trim one trailing LF/CRLF from command output, preserve other whitespace, reject empty results; discard stderr and sanitize failures. Reference text is treated as sensitive in views, errors and telemetry.

Resolve lazily for the authorized consumer: ingress gets only its connection and selected extension secrets; an extension also gets the connections the bindings in a scope with one of its entries name, when they are of one of its connection types, so its toolbelt operations can act as the calling binding’s connection (the operation’s authority names it); executor gets only its connection and binding-selected scope names; resource providers get only their declared subset. Resolve immediately before new Run/Recover/Resume or ingress-client refresh, outside coordination transactions. No resolution during shape validation; doctor --check-secrets explicitly requests scoped provider access. Missing/failed secrets block that consumer with a safe diagnostic, never fall back to a literal/empty value or another scope. Connection credentials are not automatically copied into an executor’s environment.

Cache only within one consumer acquisition/attempt epoch, never globally across generations. Apply/rotation invalidates future resolution; current processes may retain already supplied values until cancellation/restart. Revocation prevents new resolution/use through core, but external credentials must also be revoked at their provider when immediate external invalidation is needed. Setup stores values in the open-conduit keyring or explicitly reports a 0600 file fallback; it persists only the resulting reference. Secret writes invalidate relevant consumer caches.

Redacted config/history omits reference text, returns secret names with [redacted], and never returns resolved values. Register resolved values with B03 redaction before logging/launch; sanitize errors at source. Artifacts require producer-side redaction and cannot be assumed safe merely because management is authenticated. No HTTP response includes executor-private recovery metadata or raw tokens except one-time token issuance over the local authenticated channel.

Shell environment order is sanitized process baseline → optional resource-provider environment (including explicitly opted-in checkout .env) → explicit binding config.env → binding secret_names mapped by executor schema → trusted OS_ENDPOINT/OS_TOKEN. Reserved toolbelt variables cannot be overridden at any earlier layer; none receives neither. Process baseline removes unrelated credentials and toolbelt variables; expose only executor-declared safe variables. No workspace means no .env layer. A binding’s pass_env names variables of the daemon’s own environment its runs see (a proxy, say). The host owns that list, not the executor: it refuses OS_* and OPEN_CONDUIT_* (pass_env_reserved) and any variable an env: secret reference in the document names (pass_env_secret), since secrets reach a run only through secret_names. It then passes the values it has set as ExecutionContext.HostEnv, which an executor adds to its baseline. An executor out of process has a sanitized environment of its own, so this is how the daemon’s variables reach it. A binding’s resource has a pass_env of its own (resource.pass_env), checked by the same rules, for the variables the provider’s commands and setup need (an ssh agent or proxy for git’s fetch). The host passes those as ResourceAcquisition.HostEnv, and they never reach the run, so the operator can let git fetch over an ssh agent without handing the agent to the executor. A package that needs a daemon variable outside any binding (an ssh container provider’s agent, DOCKER_HOST) is granted it in settings.extensions.<package name>.pass_env, checked by the same rules. A name no installed or bundled package has is a warning, so a config survives removing a package. The host adds the variables it has set to that package’s own process environment when it starts the package, and to no other; a running package takes a change at its next start (process environment).

The shell executor applies this order with an explicit safe baseline, config.env, secret_env mapping and reserved OS_* variables. With a workspace, the attempt pins its generation’s resource descriptor and the provider layer sits where this order places it (opted-in checkout .env, then the signing policy, never OS_*); the Git provider resolves no secrets.

Management and toolbelt use HTTP/1.1 JSON over the owner-only Unix socket. There is no management TCP listener, remote management, cookie login or CORS. unix:///absolute/path/management.sock is the endpoint grammar; clients dial that path and use the fixed HTTP host localhost, never proxy environment variables or redirects. JSON Content-Type: application/json; UTC RFC3339Nano times; opaque IDs are strings, sequences/revisions/generations are unsigned decimal strings to avoid JSON precision loss. Durations are integer seconds. Maximum JSON request body 1 MiB (413 above it); individual event payloads retain any stricter B02 limit. Responses are UTF-8. Server rejects unknown request fields/duplicate keys, unsupported content type, queries on mutation routes and path traversal.

open-conduit clients discover endpoint via --endpoint → OPEN_CONDUIT_ENDPOINT → socket discovery above. Operator token via --token-file → OPEN_CONDUIT_TOKEN_FILE → state/private/operator.token; no token argument or query string. Bootstrap under exclusive lock creates a random 256-bit operator bearer token, writes its private file atomically and persists only a cryptographic digest/ID plus generation. Never replace an existing credential implicitly. Bootstrap recovery/rotation is a local locked CLI action if the credential is lost; the old token becomes invalid atomically. Lost bootstrap/rotation responses recover through the exclusive local CLI, which revokes any ambiguous replacement before generating and atomically writing a fresh credential. Token file is a UTF-8 JSON object { "token_id": "opaque-id", "token": "base64url-token" } with one final newline, no other fields; tokens are unpadded base64url encoding of 32 random bytes and never derived from paths/usernames.

Every endpoint, including health and reads, authenticates Authorization: Bearer .... Operator credentials authorize explicit control-plane APIs. Per-execution credentials authorize only /v1/toolbelt/call (and its guidance and serving reads) on their execution’s own toolbelt.sock; the management socket is operator-only, so an execution credential is 403 wrong_credential_class on every route there, and operator credentials cannot masquerade as an execution. CLI/MCP/SDK all call the same B03 authority path; SDK has no nil-Guard shortcut. The management server checks credential class before dispatch, then validates scope/Work/Execution, exact operation, token revocation/expiry and current lease epoch on every toolbelt call. Root manual events are only an explicit operator operation; execution emission always derives trusted ancestry. A webhook listener, including tunnels, is a separate TCP server that serves only registered ingress routes. It never mounts /v1 or forwards management credentials.

Operator rotation atomically revokes the previous operator token, issues one replacement and returns it once (CLI saves private file atomically). Revocation is immediate on every request, including streaming reconnect; streams revalidate periodically and close on revocation. Explicit execution-token revocation uses its ID and fences only that attempt; fresh tokens require a current lease claim/recovery/resume, never a generic operator-supplied target. Execution credentials expire no later than lease authority; B03 invalidation on park/release/outcome still applies. Already running external commands cannot be rolled back by token revocation.

All paths below are under /v1. A success is { "data": ... }; list data is { "items": [...], "next_cursor": "..." }. Empty cursor means end. GET is read-only. No payload contains secrets, bearer tokens or recovery blobs. Wire fields use snake_case with the B02 primitive field names; typed endpoint-specific request shapes below reject all other fields. Optional fields are omitted, not null. Outcomes are B03’s structured kind/summary/data; Event data, Work context and owner config remain opaque JSON. GET /schema returns the exact registered request/config/extension schemas and operation declarations; private values are never part of schema discovery.

Method/path Request / returned data and semantics
GET /health State starting/ready/degraded/stopping, version, instance ID, committed config generation, database/reconcile/worker readiness and safe component diagnostics. Auth required even when degraded.
GET /capacity Each capacity pool (agents, jobs): its setting, limit (zero: unbounded), attempts holding a slot (in_use) and ready Work its limit held back at the last start pass (waiting).
GET /scopes, /scopes/{id} Effective scope plus enabled status, limits, route/binding decisions and redacted config. List accepts parent_id, enabled.
GET /work, /work/{id} Primitive Work and safe context with its source’s display (title, ref, requester, links) when it has one, plus its operator hold, inputs (the routed input queued for its next turn; ready Work with none is idle, waiting for its next event rather than a slot) and annotations (the runtime’s own, such as a capacity wait, and the extensions’); /work/{id}?annotations=runtime leaves out the extensions’, for a view that needs only the hold and queued input (the web view’s world build reads open Work this way). The annotating extensions are asked together, each within the provider timeout and all within the view deadline below, and an annotator that times out is remembered and skipped like an action provider; list accepts scope_id, state (one state or a comma-separated set, such as ready,running,waiting for every open Work), kind, parent_id, source (the Work’s source family: its first correlation key’s source, else its kind, up to the first . or /, such as github), executor (its latest run’s), updated_after (RFC 3339), q (words that must all appear in its ID, kind, scope, state, keys, display or latest outcome summary) and sort (created, the default, oldest first; -created; updated; -updated), and says how many Work match in all as total. The list’s with=actions,panels,runs (any of them) adds to each Work the actions and panels that GET /work/{id}/actions and /panels return for it, and its runs as GET /runs?work_id= returns them (its latest 200, oldest first). Such a page holds at most 50 Work, decorated six at a time. GET /work/facets takes state, scope_id and kind and returns the total that match, their scope_ids and sources, and their latest runs’ executors, for a view’s filter choices (#544). Both read through storage’s indexed Work queries, so their cost follows the Work they answer with; only q is matched on each Work the other filters select (#548). Each provider call has a 2 second timeout and the page’s calls together 4 seconds, after which the Work not yet decorated get empty lists. A provider whose actions or panels call fails or times out is asked for them once per page and contributes none of them to the page. A timeout is also remembered across requests: until the call answers again, every view skips it at once, and one probe off the request asks it again after 30 seconds, doubling to at most 5 minutes while it still times out. Doctor’s providers check names each call timing out now.
GET /events, /events/{id} Accepted Event, acceptance sequence, routing checkpoint/result/generation; list accepts scope_id, work_id, type, after_sequence, and with=route, which adds to each Event the route that GET /events/{id}/route returns for it. /events/{id} accepts with=route alone.
GET /events/{id}/route How an accepted Event was routed: its disposition and the durable route decision (route, binding, generation, policy, reason). For an unmatched Event, refusals lists each route that declined it and the reason its handler gave. For an Event routed as failed, or one still unrouted whose route has failed, error and attempts say why and how often (route failures).
GET /executions, /executions/{id} Shared attempt record, trigger/wake links, lease epoch/state (no token), outcome/usage; list accepts scope_id, work_id, state. Never a separate job history.
GET /waits, /waits/{id} Predicate/boundary, timer/winner link and state, with its owner’s description and its timer’s due_at (wait descriptions); list accepts scope_id, work_id, execution_id, state.
GET /runs, /runs/{id} Unified history: one record per attempt for every executor, with trigger/current trigger, executor/generation, timeout, active segments and active_ms, usage, retry, retention and artifacts. List accepts scope_id, work_id, state, executor_binding, executor, work_kind, outcome.
GET /runs/{id}/live A running run’s outputs so far (tail bytes each) from its executor’s optional LiveExecutor; 409 not_live once it has ended.
GET /doctor Checks (ok/info/warn/fail with safe codes): storage, leases, resource holds, retention, reconcile lag, non-re-derivable families, config, bindings, connections, pinned executors and extension checks. check_secrets=true explicitly resolves references and reports names and codes only, and also runs extension deep checks (for example GitHub credential verification).
GET /config/export Admin role only. The committed generation as config file text (config.ExportFile), with generation, hash and path (the config file the daemon saves to): the file’s own scopes, connections and settings, each included folder’s scopes turned back into the includes entry that brought them in, and secret references as reference text, never values. It composes back to the same generation, so applying it changes nothing. open-conduit config export [FILE] writes it, to the daemon’s config file by default, for a daemon whose file is missing (doctor’s disk_missing) or out of step; it never replaces an existing file, and - prints it.
GET /config The committed generation’s wiring: generation, hash, connections by ID and type, and for each effective scope in order its included folder (source, when it has one), its extensions (name, type, connection, whether it is ingress), its authorization ceiling (connections, operations, read scopes), routes and bindings (name, executor, connection, requires, operations, resource provider, and the grants a binding offers by name, label and operations, never their config). An extension type’s optional ExtensionType.Describe adds entries (a schedule entry’s jobs and when each runs), and a route handler’s optional Registry.HandlerDescribe adds a route’s detail (the jobs and triggers a schedule route takes). An extension entry’s settings, and a binding’s settings (executor config) and resource_settings, show that config through its type’s allowlist: the fields the type declares safe (extension.Setting, from ExtensionType.Settings, an executor registration’s extension.SettingsDescriber or Registry.ResourceSettings) with their effective, typed values, defaults included (Claude Code’s model, git’s checkout), then every other top-level field by name alone with set: true. A type that declares nothing shows only field names, so a command, URL, header or argument is never shown. No other opaque config and no secret references.
GET /artifacts Metadata refs; list accepts work_id, execution_id, kind.
GET /artifacts/{id}/content Authenticated byte stream (application/octet-stream), one optional standard byte Range, 206/416 as appropriate. Resolve via metadata, never caller path; return 404 after retention deletion, preserve metadata/tombstone. No JSON envelope for content.
POST /events {scope_id,type,delivery_id,data,correlation_key?,work_id?,causation_id?}. Operator must select scope. Server supplies ID, received/occurred time, trace/source (core.manual), operator identity and root causal limits; a supplied parent must be accepted/readable and then this is a derived event. No execution_id or forged source. Verify identity agreement/declared event family; commit through normal B02 acceptance before 201, duplicates return 200/original. With work_id and no matching route, the Event is that Work’s input on the binding it runs on (routing, reason addressed).
POST /work {scope_id,kind,context,correlation_keys?,parent_id?,binding}. Explicit user binding required. Atomically create ready Work and accepted core.work.created Event with stable delivery identity; routing/checkpoint/scheduling follows B04. Parent must be authorized and same scope; cross-scope manual root creation omits parent. Return Work/Event IDs after commit; never launch directly.
GET /work/{id}/config-candidate What the Work proposes for the operator to review and apply, from the package that owns its kind (extension.ConfigCandidate, which names a candidate the host holds; a setup session’s base text plus its approved changes): {base_generation,changes,approve_all?,validate?}, each change {id,op,pointer,state,why,before?,after?,problems?,approve?,reject?} with the IDs of the extension’s actions that decide it. Once the approved changes make a candidate it adds {config_text,candidate_hash}, ready for POST /config/apply. Operator-class, since the text and a change’s values may hold reference text; no-store. 404 when the Work proposes nothing.
GET /pages The pages extensions contribute to the web view (extension.Page): {pages:[{extension,id,title,summary,entry,work_kind,start,compose,route}]}.
POST /work/{id}/cancel {expected_revision,reason}. Atomically terminalize Work, cancel waits/timers/pending input, fence attempts and record a control Event; process interrupt follows outside transaction. Return 202 with control Event/Work IDs; inspect execution state to confirm process stop. Duplicate key returns same operation.
POST /work/{id}/interrupt {expected_revision,reason}. Interrupt the running attempt without ending the Work: record a core.manual.interrupt control Event and an interrupt intent against the attempt and epoch, fence it and keep the Work ready with its pending input (B04). 422 interrupt_unsupported unless the attempt’s executor supports interrupt. Return 202 with the control Event, Work and Execution IDs.
POST /events/{id}/skip {reason}. Route an unrouted Event as skipped, so the Work it holds takes its later input; record a core.manual.skip_event control Event. A routed Event is refused (409). Return 202 with the control Event and routed_event_id.
POST /events/{id}/reroute {reason}. Route an Event again: forget an unrouted Event’s route failures so the next pass tries it now, or accept a failed or skipped Event again as a copy (routed_event_id); record a core.manual.reroute_event control Event. Any other routed Event is refused (409). Return 202.
POST /executions/{id}/cancel {expected_revision,reason}. Cancel/fence that attempt and record control Event; leave nonterminal Work ready with scheduling held until an explicit manual retry or new eligible Event clears the hold. Do not automatically retry this operator cancellation. Cancelling parked work also cancels its Wait/timer atomically. Work cancellation uses the Work endpoint. Return 202/control Event.
POST /work/{id}/retry {expected_revision,reason}. Only nonterminal ready Work with no live/parked attempt, after a failed/exhausted or operator-cancelled attempt. Accept core.manual.retry through normal path, clearing retry hold under policy; no direct claim or terminal resurrection. Return 202/control Event; reject duplicate pending retry.
POST /work/{id}/rebind {expected_revision,binding,reason}. Ready/no live or parked attempt only; validate current scope binding, record current generation and causal control Event. No scope move.
POST /work/{id}/hold, /work/{id}/release {expected_revision,reason}. An operator scheduling hold that lets input queue, and its release.
POST /work/{id}/attach {}. Held Work only; returns the executor’s local attach command.
POST /work/{id}/wake {expected_revision,type,delivery_id,data,correlation_key}. Accept an ordinary declared event for this Work. It must match the active Wait to wake; return accepted Event plus match status. No force-state transition, lowered watermark or wall-clock wake.
POST /scopes/{id}/reconcile {expected_revision}. Accept an operator reconcile-request event; asynchronous normal scan, return 202/event ID.
POST /config/validate {config}. No resolution/activation; normalized redacted candidate/hash and safe diagnostics.
POST /config/apply {config,candidate_hash,expected_generation}, or config_text (the file text, saved as given) in place of config. Full revalidation then atomic activation; return generation/hash, or conflict/restart_required. No partial application.
POST /auth/rotate {expected_token_id}. Operator only; immediate old-credential revocation and one-time replacement issuance. This endpoint uses special rotation recovery below.
POST /auth/revoke {token_id,reason}. Operator only; idempotently revoke a known execution token or own operator credential. Return ID/revocation time, never token material.
POST /toolbelt/call {operation,input,request_id?,scope_id?,work_id?,execution_id?}. B03 Call/Authority, optional target fields only narrow authority. Successful response contains operation output; waiting/outcome invalidate authority even if the HTTP response is lost.

Lists accept limit 1–200, default 50, and opaque cursor. Order ascending by acceptance sequence for events and (created_at,id) for other primitives; one traversal has a captured upper bound, so concurrent new items wait for the next traversal. Cursors bind endpoint, normalized filters, bound, last key and expiry (15 minutes), and are authenticated by server; altered/mismatched cursors return invalid_cursor, expired cursors return 410. Traversal uses bounded keyset pagination, not a materialized ID snapshot. Current row states and mutable filter membership may change between pages: a row changing to match after its key was passed can be omitted; a row changing out of the filter can disappear. Clients dedup IDs, use revision fields and restart traversal to converge; only immutable event sequence traversal has stable filtered membership. Unknown/repeated filter parameters are invalid. No unbounded list or global payload dump. Scope filters never widen authority.

All mutating POSTs require Idempotency-Key (1–128 printable ASCII characters, secret-free), except validate. Bind it to credential principal, method, path and canonical request hash; token replacement for the same attempt preserves principal identity but does not bypass fresh auth. Persist mutation result and key atomically with coordination state. Equal retry returns original status/result; changed request returns 409 idempotency_conflict. Retain keys for the lifetime of the corresponding coordination/history record (minimum 30 days), disclose expiry in Idempotency-Expires-At, and never silently repeat a forgotten externally consequential operation. Toolbelt keys map to B03 request/delivery identity; stale credentials still fail even for a duplicate. External writes remain marker/reconcile based, with no exactly-once promise.

Rotation never stores plaintext response in generic idempotency history. Keep no stored replacement token or reversible encrypted response. After a lost response or interrupted credential-file save, the locked local bootstrap command revokes any ambiguous replacement, generates another token and atomically writes its private credential file; server state stores only its digest/ID. An old credential cannot fetch its replacement after revocation. This is the documented recovery path rather than allowing old bearer replay.

Revisioned mutation requests require expected_revision; mismatch is 409 revision_conflict with current revision only. Config uses expected_generation. Reads expose revision/generation and ETag; If-None-Match supports 304. Cancellation/retry races revalidate inside the same fenced transaction. A 202 acknowledges durable acceptance, not execution/process completion. A timed-out/disconnected request may have committed: retry the identical idempotency key, then inspect Event/Work/attempt state. Never report success before commit.

Errors use { "error": { "code": "...", "message": "safe summary", "request_id": "...", "details": [{ "path": "...", "code": "...", "message": "safe remedy" }] } }. Statuses: 400 malformed/unknown fields/cursor, 401 missing/invalid/expired/revoked token, 403 wrong credential class/operation/scope/lease authority, 404 unknown authorized target, 409 revision/idempotency/state/scope-in-use conflict, 410 expired cursor, 413 body limit, 415 media type, 422 semantic/config/unsupported capability/restart-required, 429 causal/rate bound (Retry-After where known), 503 not ready/retryable storage failure. Never disclose unauthorized target existence or raw backend/provider errors. Request IDs are server-generated secret-free IDs; only safe correlation appears in logs.

Setup (init, config path|show|get|set|unset|check, config include add|remove, secret set) edits the config file locally and validates it offline against the daemon registry (operator setup, doctor and engineering controls). The secret store follows this contract: keyring first, then a reported 0600 secrets.json fallback, persisting only the reference. secret set reads a terminal line without echo, or all of piped input (up to 64 KiB, less one trailing newline), so a multi-line value such as a PEM key goes in with secret set NAME < key.pem. The daemon resolves file: references from the config directory’s secrets.json.

  • 01-05 recorded B05 as the implementation contract for stage 02. Its schema, examples and checker were synthetic contract fixtures: no loader, secret resolver, HTTP server or installed integration existed, and no live API, auth or secret-handling correctness was claimed. Unix-only transport was to be reviewed at the 01-06 gate (contracts gate).
  • 01-05 assigned the implementation work: 02-06 for clients, server, auth and config loading, with tests for socket permissions, class separation, lease expiry and recovery, reload failure and crash, response loss and idempotency, pagination and secret-provider isolation; 02-07 for the reserved shell environment and CLI transport; 02-08 for redacted history and artifact access; 03-02 for workspace acquisition; 03-05 for setup, the editor and doctor; 06-02 for explicit conversion. 01-06 included the schema and examples in the contract gate.
  • 02-06 implemented loading, validation, reload, secret resolution, the Unix-socket server, authentication and the operator client (toolbelt authority, configuration and local management). The rebind and scope reconcile endpoints were deferred to 03-05, and the shell environment and CLI transport to 02-07.
  • 02-07 applied the shell environment order in the shell executor and added the ConfigValidator hook.
  • 02-08 added GET /runs unified history and GET /doctor (lifecycle telemetry, run history and doctor).
  • 03-02 delivered workspace acquisition (B07) with strict provider validation of binding resource config.
  • 03-05 implemented POST /work/{id}/rebind and POST /scopes/{id}/reconcile, added the hold, release and attach controls, hold and annotations on GET /work/{id}, extension deep checks in doctor, local setup and editing, the keyring-then-file secret store, daemon resolution of file: references and the separate webhook listener (operator setup).
  • 05-01 added route handler config validation (Registry.HandlerConfig) for the schedule handler.
  • 05-02 added binding timeout_seconds and the schedule route config triggers.
  • #157 added binding grants, which a Work opts into when its route creates it.
  • #119 added included folders: the config file’s includes, folder fragments composed beneath a parent scope, the source each folder scope records in a stored generation, disabled carry-over for a folder that has gone, config.FolderConfigurer, config include add|remove and the scope’s folder in GET /config.
  • #514 carried config.FolderConfigurer over the protocol (executors[].folder_config, validate kind executor_folder). Once Claude Code ran as a package, its registration no longer implemented the hook and folder bindings composed unadapted.
  • #461 let operation lists (scope authorization, binding and grant operations) name toolsets and deny operations, resolved when a generation is validated.
  • #124 added the list forms GET /work?with=actions,panels and GET /events?with=route, a set for the Work list’s state, timeouts on action provider calls, and to GET /config each scope’s authorization ceiling, each binding’s requires and operations, and extension, executor and resource settings through each type’s allowlist.