- Status: Implemented behind configuration. Off unless a dispatch provider is configured and dispatch is globally armed.
- Companions: contract.md, agent-access.md, server-deployment.md.
What this is
The beads-web UI can File a new issue, Tackle an existing one, and
comment on an issue. Those buttons do not create, claim, close, or write
Beads comment rows. They enqueue a durable control-plane request and launch an
agent through the configured provider. That agent is the worker: it runs bd
in whatever environment the provider gives it.
browser (File / Tackle / board Confirm / comment)
-> beads-web validates, authorizes, persists
-> dispatch provider (today: a webhook runner, POST {base}/agents)
-> beads-web follows the run (live events when the provider has them, else polling)
-> agent on the mapped GitHub checkout, or the runner's default
-> bd against the project's Dolt database
This is the durable agent-worker boundary from the contract. Dispatch is provider-neutral: see Providers for the webhook runner, for turning dispatch off, and for adding a provider.
Providers
BDX_ picks exactly one provider:
| Value | Worker | Needs |
|---|---|---|
webhook |
Any runner implementing the contract below | BDX_, BDX_ (32+ characters) |
none |
Dispatch off: File/Tackle/comment hidden, API refuses | nothing |
Unset means none. A provider missing its configuration is inactive, and the
console says why. Each mission records the provider that launched it; after a
switch, older missions keep their stored state and are never sent to the new
provider. Missions from a provider this build no longer has show their stored
result and refuse cancel and follow-up.
A native provider (for example Overalls microVMs) implements
AgentProvider in src/: launch, latest run,
and run status are required; follow-up, cancel, and live run events are
optional capabilities the console adapts to. It adds its id to
DispatchProviderId, a row to PROVIDER_, and a branch to
getAgentProvider. The queue, policy, checkout maps, prompts, and UI stay as
they are.
Webhook runner contract (version 1)
Paths are relative to BDX_ (https, or http on loopback).
Bodies and responses are JSON.
| Request | Body | Response |
|---|---|---|
POST /agents |
{version, name, prompt, auto_ |
{agent_ |
GET /agents/{id}/latest-run |
— | {run_; 404 when none yet |
GET /agents/{id}/runs/{run} |
— | {status: running|succeeded|cancelled|failed, summary?, duration_ |
POST / (optional) |
{version, prompt} (follow-up) |
{run_ |
POST / (optional) |
— | any 2xx |
Every request has x-beadsmith-contract: 1, x-beadsmith-timestamp (unix
seconds) and x-beadsmith-signature: v1=<hex>: HMAC-SHA256 under the shared
secret of <timestamp>.<METHOD> <path>\n<body> (empty body for GET). Verify
it with a constant-time compare and reject timestamps older than five
minutes. Ids must match [A-Za-z0-9._; agent_ must be https;
issue_ is optional: the bead a File run created. When it is a valid issue
id, a todo routed to this dispatch links to it; it is not
otherwise stored or shown. pr_ is shown only when it is a GitHub pull request URL. Reply 404, 405,
or 501 to an optional endpoint you do not support; Beadsmith then tells the
user follow-up or cancel is unavailable. There are no live events: Beadsmith
polls run status and shows the final result.
The runner checks out the repository (or its own default when the request has
none), runs the / or / instructions in the prompt with bd
against the project's Dolt database, and reports back.
Mapping from /file and /tackle
A private skills repository is the source of the workflow
names. This service cannot read that repository. The launched agent is
told to follow the in-repo skills and then run bd:
.agents/skills/ file/ SKILL.md .agents/skills/ tackle/ SKILL.md .agents/skills/ comment/ SKILL.md
The launch prompt in src/ points at those files
and names the target Dolt database / issue. beads-web never executes bd.
| UI action | Agent job | bd |
Pull request |
|---|---|---|---|
| File | Turn operator notes into well-structured Beads issue(s). Do not implement. | bd create (and deps/labels as needed) |
No |
| Tackle | Claim the issue, implement it if this checkout is the matching codebase, close it when done. | bd show, bd update --claim, bd close |
Yes, when there are code changes |
| Board drop | Same enqueue as Tackle, with optional destination_. Card does not move. |
Destination-specific (--claim, --status, or bd close) |
Only start-work (in_ / omitted) |
| Comment | Add one human comment to an existing issue. Do nothing else. | bd comments add |
No |
Board drag is not a status PATCH. Dropping a card onto
another column opens a confirm modal; Confirm POSTs
{ operation: "tackle", issue_.
Same-column drops are a no-op. pinned and hooked are not drop targets.
If dispatch is disarmed or Tackle is denied for the project, Confirm stays
disabled and the card stays put.
destination_ |
Agent job |
|---|---|
omitted / in_ |
Existing /tackle start-work prompt |
closed |
Check whether work is finished; close only if it is |
blocked |
Investigate blockers; set blocked if appropriate |
deferred |
Park the issue as deferred |
open |
Unclaim / return to the ready queue |
Configuration
None of these values belong in the repository, a dotenv file, or the browser.
| Variable | Meaning |
|---|---|
BDX_DISPATCH_PROVIDER |
webhook or none; unset means none. |
BDX_DISPATCH_WEBHOOK_URL |
Base URL of the webhook runner: https, or http on loopback only. No credentials, query, or fragment. |
BDX_DISPATCH_WEBHOOK_SECRET_FILE |
Preferred path to the shared HMAC secret (32+ characters), e.g. / with Compose. |
BDX_DISPATCH_WEBHOOK_SECRET |
Local/development fallback only. |
The File and Tackle controls stay hidden until a provider is configured
and dispatch is armed (GET / → dispatch.enabled).
key_ (a usable provider is configured) and armed are split so the
operator console can show the kill switch separately from the provider
configuration. A configured-but-unreadable secret file still reports
key_; the launch then fails closed with a sanitized error.
Operators manage catalog coverage from / (and GET /):
every discovered d_ project, whether a Tackle map exists, whether that
map came from env/file (source: env) or the console (source: sqlite),
and whether File and/or Tackle are allowed. Map values shown there are public
GitHub URL / ref / auto_ only — never credential paths or the raw
env JSON.
PUT / {armed} arms or disarms File/Tackle without
touching the provider configuration. Missing SQLite policy defaults to armed with
both operations allowed. PUT / {file, tackle}
sets per-project allow. Extra keys fail closed. Enqueue refuses a disallowed
operation with a sanitized message and does not call the provider. Unmapped Tackle
still fails closed even when Tackle is allowed. File/Tackle POST still cannot
pass armed, file, tackle, a repository, or a prompt template.
PUT / DELETE / write SQLite on this
service. Env/file (BDX_ / _) still overrides a
console row for the same project; the console cannot clear an env map.
Clearing a console map returns that project to fail-closed Tackle without a
restart. File launches without a checkout, so the runner uses its default,
unless a per-project File checkout map is set.
PUT / DELETE / write an optional File
checkout ({url, ref?} only; autoCreatePR is always false). Unset returns
File to the runner's default checkout. A present-but-invalid map fails closed and
does not fall back to the runner's default. Clearing or editing a File map never changes
a Tackle row, and vice versa.
If the env/file JSON is present but unusable, every project is treated as unmapped until that config is fixed.
Where the webhook secret goes (operator)
The shared secret belongs to the Beadsmith server process and the runner,
nowhere else: not in .env, compose.yaml, the runner's agent environment, or
the browser. The agents the runner starts need Dolt access to run bd, not
this secret.
Next to compose.yaml:
(umask 077; openssl rand -hex 32 > secrets/dispatch-webhook-secret)
Then in .env:
BDX_DISPATCH_PROVIDER=webhook
BDX_DISPATCH_WEBHOOK_URL=https://runner.example.com/beadsmith
BDX_DISPATCH_WEBHOOK_SECRET_FILE=/run/secrets/dispatch-webhook-secret
Give the runner the same secret through its own secret store, then apply with
docker compose up -d. The console's Dispatch row shows Webhook agent runner, or why the provider is inactive. Signed in, File and Tackle appear
once dispatch is armed.
Rotation: replace the secret on both sides, then docker compose restart. A
changed file does not update a running process.
Request contract
POST / accepts only:
{ operation: "file", idempotency_key, notes, title?, issue_ type?, priority?, labels?, status? } { operation: "tackle", idempotency_key, issue_ id } { operation: "comment", idempotency_key, issue_ id, text } { operation: "discuss", idempotency_withkey, issue_ id, intent, message? } intentone ofdiscuss,elaborate,close
Discuss is the issue page's agent button (key a): the worker follows
.agents/ and either talks the issue through, rewrites
its description and acceptance criteria, or closes it with a reason, all with
bd. It uses the Tackle checkout when the project has one, never opens a PR,
and continues as a conversation through follow-up. A plain discussion is
allowed when File or Tackle is allowed; elaborate and close need Tackle.
The public record carries intent but never the message.
Comment launches the File checkout (beads-web, no PR) and is allowed when File
or Tackle is allowed for the project. The browser cannot set author; the
control plane copies the Access email (or web). Public GET omits comment
text the same way it omits File notes.
status on File is a StatusKey (open, in_, blocked,
deferred, closed, pinned, hooked). The board column "+ New" action
sends it so the filing agent can bd create then bd update --status.
open is omitted (create default).
The control plane records an opaque request id, actor (Access email when
present, otherwise web), project, operation, payload, and idempotency key
before calling the provider. A replay with the same key and same payload returns
the original record and does not launch a second agent.
GET / returns the sanitized record:
id, project_, operation, status, optional provider, optional
issue_, optional agent_ / agent_, created_, updated_, and
these terminal result fields when the provider supplies them:
result_— bounded, credential-scrubbed final assistant text;summary duration_— the runner's terminal run duration; andms git_— a validatedpr_ url https:/URL./ github.com/ .../ pull/ :number
Failed records may also include a sanitized error_. The response never
contains provider credentials, the launch prompt/payload, actor, idempotency
key, or run id.
GET / is the conversation the agent
console renders: {turns: [{seq, role, text, run_,
where role is operator (the request and each follow-up) or agent (each
finished run's sanitized summary or error, once per run). It is display state
in dispatch_, never the launch prompt or worker logs.
GET / reports dispatch.capabilities for the active provider:
live_, follow_, and cancel. webhook has no live events, so the
console polls run status and shows the reply when the run finishes. Follow-up
and cancel are optional for a webhook runner; when it answers 404 the console
shows that refusal.
The durable status machine is:
queued -> launching -> launched -> running -> succeeded
\-> failed | \-> cancelled
\--------> failed
launching -> failed means the provider could not start the agent. A later
failed means the launched run reported failure; cancelled remains
distinct. A run may move directly from launched to a terminal state when the
first observed status is terminal.
After launch, the Beadsmith server follows the run without blocking the
File/Tackle response: through the provider's live stream when it has one, and
otherwise (or after the stream expires, disconnects, or errors) by
bounded-backoff status polling. Public GET also performs one authoritative run
refresh and restarts the background follower for launched/running records,
so state converges after a process restart. When launch did not return a run
id, the follower asks the provider for the agent's latest run; if there is
still none, the durable status stays launched.
GET / is the live/replay view of
that run. A provider with live events maps them onto a fixed MissionEvent
union (user, assistant, thinking, tool_, result, error)
defined in src/; the browser EventSource consumes those
named events only. Tool args/result (file bodies, PTY) are not forwarded.
Text is credential-scrubbed and stripped of ANSI. When the provider has no
live events or the stream has expired, the proxy emits a result
reconstructed from the durable row.
POST / cancels the active run.
POST .../ with {text} (max 4000 characters, extra keys fail
closed) creates a new run on the same agent. Both need the provider to support
them. The browser cannot set repo, model, or shell. While the durable status
is launched or running, follow-up is refused and the composer is
disabled. Cancel remains available on an in-flight run even when dispatch is
disarmed. Follow-up still requires the global arm and the original File/Tackle
allow.
What still has to be true operationally
- A runner whose agents can run
bd. The agent the provider starts needsbd, network access to your Dolt server, and a Dolt credential held by the runner, never by Beadsmith. A just-in-time agent without that bridge cannot file or claim issues. Prefer a short-lived, least-privilege Dolt user per agent over a shared writer password. - Provider configuration on the Beadsmith server. See Where the webhook secret goes.
- Cross-repo code. Tackle needs a checkout map for the project; without one it fails closed. A Tackle of an issue whose code lives in a repository other than the mapped checkout is told to stop rather than invent unrelated patches.
- Agent-access slice 1 (service-token reads of beads-web) is independent.
File/Tackle agents use
bd, not the public JSON catalog.
Security invariants
- The web process still does not ship or invoke
bd. - Provider credentials (the webhook secret) never enter SQLite, logs, URLs, or API responses.
- Dispatch payloads are a small validated schema. The browser cannot choose a prompt, repository, model, or shell command.
- Idempotency is enforced in service-local SQLite, not by trusting the provider.
- Failed launches stay on the record as
failedwith a generic or already-safe message. - Runner result text is truncated and credential-scrubbed before persistence; only canonical HTTPS GitHub pull-request URLs are stored.
- Mission stream UI renders the canonical event union, not raw runner events.