CI desk
Everything else in Sparkyard starts with a person typing. This does not. A desk can be given a webhook, and when a build fails it wakes up, reads the failing job’s logs, and posts what broke — in the channel the team already watches, on its own machine, as its own GitHub identity.
A suite that goes red at 02:00 has nobody in the loop until morning. That is the gap this closes.
What it does, and what it deliberately does not
Section titled “What it does, and what it deliberately does not”It triages. Most red builds are not fixable by an agent — a flake, an expired credential, a package published four hours ago, a runner image that changed underneath you. The desk’s job is to say which of those it is, with evidence you can check in under a minute: the run ids it compared, the file and line it read, the package and version. It is required to show its work, and “I could not tell, here is what I ruled out” is an answer it is allowed to give.
It can also fix things, if you turn that on. Repair is off when a hook is created. That is a separate command because it is a genuinely different thing: a desk that talks holds read-only tokens, and a desk that repairs holds push credentials and reacts to the internet with nobody watching.
Setting one up
Section titled “Setting one up”Before you start
Section titled “Before you start”-
A channel bound to a desk.
/sparkyard share <desk> as ciin the channel first — the hook runs on that desk’s machine and reports into that channel, and there is nowhere else for it to go. See What a desk is.as ciis not required, but it is the point: it gives the desk the CI persona, which is what it opens every one of these turns with. Anything you type after it — which pipelines it owns, what it must never touch — goes in underneath and is read at 02:00 along with it. A desk with no type still works and still triages; it just does so as a desk with no job description. -
The Sparkyard GitHub App installed on the repository. On GitHub: your App → Install App → the gear icon → the repository must be in the selected list. A token reaches exactly that list, enforced by GitHub rather than by us.
-
Actions: Read-onlyon the App. This is the step people miss, and it fails in the least obvious way possible — see when the desk cannot read the logs. -
Admin. Only whoever installed Sparkyard in the workspace can create a hook.
1. Create the hook, in Slack
Section titled “1. Create the hook, in Slack”In the bound channel:
/sparkyard hooks add githubThe reply is ephemeral — only you see it — and carries a payload URL and a secret.
The secret is shown once. There is no command that reads it back, by design: a webhook secret you can re-read out of a chat client is a webhook secret that lives in a chat client. If it is lost, remove the hook and add another; it takes fifteen seconds.
2. Add the webhook, in GitHub
Section titled “2. Add the webhook, in GitHub”Repository → Settings → Webhooks → Add webhook.
| Field | Value |
|---|---|
| Payload URL | the URL from step 1 |
| Content type | application/json |
| Secret | the secret from step 1 |
| SSL verification | Enable — the default. Never disable it |
| Which events? | Let me select individual events → Workflow runs, nothing else |
| Active | checked |
Workflow runs, not workflow jobs. A workflow with five failing jobs produces five
workflow_job deliveries and one workflow_run — subscribing to jobs would mean five messages
and five turns about one red build.
Leave successes in. They arrive on the same event, and a green build is what closes the thread and resets the repair attempt count. Filtering to failures only would leave every thread ending in red forever.
3. Check it worked
Section titled “3. Check it worked”GitHub sends a ping the moment you press Add webhook. Open the hook → Recent Deliveries:
| What you see | What it means |
|---|---|
✅ 200 with {"ok":true,"sparkyard":"hook verified"} | the URL, the id and the secret are all right — you are done |
❌ 401 | the secret does not match. Nothing else produces a 401 |
❌ 404 | wrong hook id, or the hook has been removed |
| ❌ timeout | the request is not reaching the gateway — an ingress problem, not a Sparkyard one |
That ping is a real end-to-end test of the part that actually goes wrong, which is the secret.
Redeliver re-sends a delivery with its original id — and since that id is the idempotency key,
redelivering a real failure answers 200 and deliberately does nothing. That is the replay
protection working. To try it for real, push something that fails.
When the desk cannot read the logs
Section titled “When the desk cannot read the logs”If the desk posts that it could not read the run, or you see a 403 from gh run view, the App
does not have Actions: Read-only.
The reason it is worth calling out separately is that nothing fails loudly. Permissions only ever scope down — asking for one the App was never granted does not error, GitHub silently omits it — so the desk gets a valid token, boots a healthy machine, starts a real turn, and only then finds it cannot read a single line. Everything looks fine until the last step.
Fix it in two places, because one is not enough:
- The App’s settings → Permissions → Repository permissions → Actions: Read-only.
- Accept it on the installation. GitHub holds a widened permission pending until an owner reviews it — Install App → the gear icon, or the email GitHub sends.
Operators can confirm what was actually granted with
./infra/check-github-app.sh <desk-tenant-id>, which prints the grants on the token it mints.
Letting it fix things
Section titled “Letting it fix things”/sparkyard hooks set <id> repair onFrom then on, when the desk finds a defect it can correct with a small, self-contained change:
| where the build failed | what the desk does |
|---|---|
a protected branch (master, main, anything in protected-branches) | branches, commits, opens a pull request — a human merges |
| a pull request’s own branch | pushes one commit to that branch, so the fix lands in the review already open and CI re-runs on it |
| a branch with no pull request open | opens a pull request against it, rather than pushing into somebody’s work in progress |
| a fork | triage only, always — the code is outside the trust boundary and the desk’s token does not reach it |
It is told, in the prompt, never to make a build pass by making the check weaker: no deleting or
skipping tests, no loosening an assertion until it accepts the wrong answer, no widening a type to
any, no bumping a timeout to hide a race. Changing a test is allowed when the test is the thing
that is wrong — and then it has to say in the commit message why the old expectation was wrong.
That sentence is the difference between a fix and a cover-up.
Turning repair on widens the desk’s GitHub tokens to include contents: write and
pull_requests: write. Turning it off takes them back.
The three things that keep it from being a menace
Section titled “The three things that keep it from being a menace”It will not loop. A desk that pushes a fix, is woken by its own red build, and pushes again is the obvious failure mode. So repair attempts are counted per branch — not per commit, which would reset every time the desk pushed and never trip — and after three the desk stops, posts what it tried, and asks for a person. It will not touch that branch again until a build on it passes, by its hand or anyone’s, at which point the count starts over.
/sparkyard hooks set <id> tries 2 # stop soonerIt will not spend forever. Every build the desk picks up is a real turn on real hardware. A flaky suite that goes red six times on one commit gets one turn: the repeats become a reply in the thread that already exists. Beyond twenty builds in a rolling day the desk stops entirely and says so — once, not once per build. It stops rather than throttles, because slowing a loop down spends the same money more slowly.
/sparkyard hooks set <id> daily 50It says when it is green again. A passing build closes the thread that reported the failure and resets the attempt count, so the channel reads as outcomes rather than as a wall of red.
Every command
Section titled “Every command”| Command | What it does |
|---|---|
/sparkyard hooks | what is wired up in this channel |
/sparkyard hooks add github | create one — prints the URL and the secret, once |
/sparkyard hooks set <id> repair on · off | let it push a fix, or only explain |
/sparkyard hooks set <id> daily <n> | builds a day before it stops (default 20) |
/sparkyard hooks set <id> tries <n> | repair attempts on one branch before it hands over (default 3) |
/sparkyard hooks remove <id> | stop it — deliveries are refused from now on |
All of them are admin-only, and all of them need a channel bound to a desk. A hook is a URL on the internet that starts a turn with credentials; pointed at somebody’s personal workspace it would let anyone who learned the id spend their Claude budget while they slept. It runs on a shared desk or it does not run.
What to expect in the channel
Section titled “What to expect in the channel”A failure opens a thread:
🔴 CI failed on
masterinacme/apirun 4242 ·80814d5Reading the logs. If the fix is small and obvious I will open a pull request.
…and the desk replies in it with the verdict, the evidence, and what it did. Reply in that thread like any other — it is an ordinary session on the desk’s machine, and it still has the logs open.
Removing it
Section titled “Removing it”/sparkyard hooks remove <id> stops it immediately. The row is kept rather than deleted, so what
the hook did is still readable afterwards — which is the thing you want on the day you are asking
why a desk did something surprising. Deleting the webhook in GitHub works too, and leaves nothing
to read.
Security
Section titled “Security”The whole of it is in the security model; the short version:
- The signature is the authentication, not the URL. The hook id is public by design — it is
printed in a channel and pasted into a settings page. Every delivery is verified with
HMAC-SHA256 against the per-hook secret before anything is read, written, posted or spent.
Anything that fails is a
401and a log line. - A verified delivery is not a fresh one. GitHub does not sign a timestamp, so a captured delivery stays verifiable. Replays are stopped by the delivery id instead: a redelivered id claims nothing and starts nothing.
- The tenant comes from the row, never from the payload. Which machine runs the work and which channel hears about it are decided by the admin when the hook is bound.
- This does not change what a turn may do. The microVM and the egress rules are still what contain a desk. Autonomy widens what can start a turn, not what a turn may reach.
Operators wiring this up, and the six things to check when nothing arrives, are in
docs/runbooks/ci-desk-webhook.md.