Automations
Cron, signed webhooks and forge events on the desktop daemon, bound to a prompt or a recipe — with routing across daemons, and the web app's separate catalog.
An automation is a trigger bound to work a daemon should start: a prompt, or a Floor recipe. The automations that fire are rows on kairokud, the daemon on your machine — the scheduler, the signed HTTP listener and the forge wake all live there, not in the cloud. The desktop app shows them under Settings → Connections → Automations.
The web app has an Automations page too, under Settings → Developers → Automations, but it keeps its own catalog on your Kairoku account rather than editing your daemon's rows, and nothing fires from it in this release — see The web app's catalog.
This release's recipe targets are Solo and Build and verify only. Phase team, Plan, Research and Custom stay out of the target picker until those recipes ship.
Webhook signing secrets, forge tokens, and any HMAC key never belong in a repository, a brief, a transcript, or these docs. Hand them only to the daemon's automation.create / automation.update, which store them on the daemon. Examples below use clearly fake placeholders such as whsec_EXAMPLE_not_a_real_secret and hmac-key.EXAMPLE.
What you will do
- Create a cron automation — schedule → enable → confirm it can fire when the daemon is up.
- Add a signed webhook — copy the daemon path, store the signing secret, reject bad signatures.
- Bind the forge event class — no forge token involved; unrelated classes stay ignored.
- Pick a target — a prompt, or a recipe (
solo/build-verify) with a brief.
Prerequisites
- Kairoku desktop able to reach the daemon on the same machine (or a reachable host).
- Daemon running for cron ticks, webhook delivery, and forge wake — a sleeping box misses schedules until it is back.
- For forge wakes: something that feeds the event class to the daemon — a fixture in dogfood; the daemon does not watch GitHub or GitLab itself in this release.
- For recipe targets: a
workspaceIdon the automation, naming the desktop workspace the dispatch runner should start its session in.
Automations never mark human-gated plan items Done and never mint remote Jira/Confluence structure — that stays a human Sync push. They may write local plan rows through existing app tools when linked.
Targets
| Target | In this release |
|---|---|
prompt — prompt text, no recipe | Yes |
recipe → Floor Team Solo (solo) | Yes |
recipe → Floor Team Build and verify (build-verify) | Yes |
| Phase team / Plan / Research / Custom | No — wait for those recipes |
There is no Teammate target on the wire. A Teammate is named by a playbook's teammate node instead; an automation can reference a saved playbook through playbookId, but the playbook's graph runs from playbook.run, not from the automation firing.
What a fire starts depends on the target. A prompt-target fire is recorded as a wake and announced as automation:fired with the prompt; nothing more starts from it. A recipe-target fire also writes an automation_dispatches row on the daemon and announces automation:dispatched. The daemon's dispatch runner drains that queue every 30 seconds: it claims the row and starts an ordinary agent session in the row's workspaceId, with the brief as the first message. That is a chat session, not a Floor run — it does not drive the Solo or Build and verify recipe, and nothing appears on the Floor. A row without a workspaceId stays queued with a reason saying so. KAIROKUD_DISPATCH_RUNNER=0 turns the timed drain off. To run Solo or Build and verify on a plan item, start it from the plan.
These are the fields automation.create and automation.update accept, and the ones list / get return:
| Field | Applies to | Notes |
|---|---|---|
name | all | Required |
trigger | all | cron, webhook, or forge |
cronExpr | cron | Standard five-field cron — there is no seconds field |
webhookPath | webhook | A slug of letters, digits, -, _ |
webhookSecret | webhook | Stored on the daemon, never returned by list or get |
webhookUrlPath | webhook | Read-only: /hooks/automation/<path> |
forgeEventClass | forge | pull_request.synchronized (alias pr.updated) is the only class this release ships |
enabled | all | Defaults to true |
target | all | prompt or recipe |
prompt | prompt target | The text the wake starts from |
recipe | recipe target | solo or build-verify |
brief | recipe target | Required and non-empty |
workspaceId, playbookId | optional | playbookId references a saved playbook; its brief can stand in for the automation's |
routingPolicy, routingDaemonId | all | See Routing across daemons |
lastRouteOutcome | read-only | Stamped by the daemon; null until a wake has been routed |
The desktop form picks a Target — Prompt, or Recipe with Solo or Build and verify and a brief — but has no routing policy and no workspace. Its Forge event picker offers pull_request.opened and push, neither of which the daemon matches in this release. Routing policies, workspaceId, playbookId and the shipped forge class come from automation.create / automation.update on the daemon directly.
1. Create a cron automation
Open the desktop app, then Settings → Connections, then the Automations section.
Create automation. Give it a Name you will recognize in the event list (for example Nightly solo smoke) and set Trigger type to Cron.
Schedule. Fill the Cron schedule (e.g. 0 9 * * 1-5) field with a standard five-field expression. There is no seconds field. Each automation.tick evaluates every enabled cron automation against the daemon host's wall clock — do not assume the laptop showing the UI is the box that runs the scheduler.
Target. Choose Prompt and fill the Prompt field, or choose Recipe, pick Solo or Build and verify, and fill Brief for the recipe.
Enable. Press Create — a new automation starts enabled. The status line reads Enabled — fires when the trigger matches; Disable switches it to Disabled — silent until enabled, and Enable turns it back on. With the daemon up, each matching tick announces automation:fired and records the wake. A recipe target also writes an automation_dispatches row and announces automation:dispatched, which the dispatch runner turns into an agent session as described above.
Cron does not replace human gates on Manual/Flow test items. A fired automation that reaches a gated plan item still stops for a person.
2. Signed webhook (HMAC)
Use a signed webhook when an external system should wake the automation over HTTP. The listener is the daemon's own — the same one the desktop app talks to — so the URL is local to that machine, never a Vercel route and never the Slack event URL from Slack.
Set the path. Choose Signed webhook as the trigger and fill Webhook path (e.g. /hooks/deploy). What the row stores is a slug of letters, digits, - and _; the daemon serves it at /hooks/automation/<slug> and shows the full Webhook URL after you press Create.
Store a signing secret. Generate a high-entropy secret — treat whsec_EXAMPLE_not_a_real_secret as shape only — and pass it as webhookSecret — the daemon refuses a webhook automation without one, and the desktop form has no secret field in this release, so set it through automation.create or automation.update. It is stored on the daemon and shown masked afterwards; list and get never return it.
Sign every request. POST the raw body to /hooks/automation/<slug> with X-Hub-Signature-256: sha256=<hex>, where <hex> is HMAC-SHA256 of the raw request body under the shared secret. The daemon also reads X-Kairoku-Signature, and accepts a bare hex digest without the sha256= prefix.
Confirm reject-on-bad-sig. Send one request with a wrong or missing signature: expect 401 and no wake. Send a good signature to a disabled automation: expect 403 and no wake. A good signature on an enabled automation fires once and replies { ok: true, automationId, fired }. The daemon logs neither the secret nor the signature. To rotate, update the secret on the row — the old digest stops verifying immediately.
Security checklist
| Rule | Expect |
|---|---|
| Bad or missing signature | 401; no wake |
| Good signature + enabled | Fires once; { ok: true, automationId, fired } |
| Good signature + disabled | 403; no fire |
| Secret in logs / transcripts | Never — the daemon logs neither secret nor signature; placeholders only in docs |
| Routing | Non-local or offline target → skipped, outcome stamped; never a silent local fire |
3. Forge event class
A forge automation is woken by an event class the daemon is told about. The daemon does not poll GitHub or GitLab itself in this release, so no forge credential is involved — not the web app's Connections and not a catalog entry. Whatever watches the forge on your behalf feeds the class in (see the last step).
Set the class. Set forgeEventClass to pull_request.synchronized, or its alias pr.updated. That is the only class this release ships; an automation bound to anything else sits idle — including the pull_request.opened and push options the desktop picker offers.
Know what matching means. The daemon matches on the event class alone. It accepts an event payload but does not inspect it in this release, so every enabled forge automation whose class matches fires — there is no repository, branch, or author filter to set. Keep one automation per class and put any narrowing in the prompt or brief.
Prove the wake. Feed the class in through the daemon's automation.ingestForgeEvent method — a fixture does this in dogfood, and in this release that method is the only way a forge event reaches the daemon; nothing inside it watches a forge. The result names the automations that matched, and reports ignored: true when none did. Feed an unrelated class and expect exactly that silence.
New forge App permissions, if a class needs them beyond today's read scopes, are a human gate in the forge console — agents do not expand App permissions on their own.
The web app's catalog
Settings → Developers → Automations in the web app opens on an Automation catalog — or No automations yet when there are none — with New automation. The editor carries Automation name, a Trigger (cron, webhook or forge) with the one field it needs (Cron expression, Webhook path or Forge event class), a Target (recipe with Recipe and Brief, or prompt with Prompt), and a Routing policy with Named daemon. The trigger is fixed once the automation is created; the rest can be edited, and Delete automation removes it.
These rows are stored on your Kairoku account, scoped to your personal workspace or organization. They are not your daemon's rows: the web app does not read or write kairokud, and the desktop app does not show what you save here. In this release nothing fires a web automation — no scheduler, webhook listener or forge feed reads this catalog, so its route status stays No route yet. The Named daemon list offers the machines from Settings → Developers → Machines.
Use it to keep a record of the automations you intend; configure the ones you want to fire on the daemon.
Routing across daemons
Every daemon has an identity: KAIROKUD_DAEMON_ID if set, otherwise local. Each one also keeps a thin registry of peers — daemon.list returns id, name, online and isLocal for each, and operators seed entries with daemon.registry.upsert.
Before a wake fires, the router decides which daemon owns it, stamps lastRouteOutcome on the row, and announces one of automation:routed, automation:dropped or automation:queued. The stamp is { outcome, daemonId, at, reason } with outcome one of routed, dropped or queued.
routingPolicy | Behaviour |
|---|---|
local-only | Default. The wake fires on this daemon. |
named-daemon | Fires only on the daemon in routingDaemonId, which is required. |
prefer-online | Picks an online daemon from the registry. |
A wake that resolves to a non-local or offline target is skipped on this daemon. It is never quietly fired locally instead. When the named or preferred daemon is offline the outcome defaults to dropped; set KAIROKUD_ROUTING_OFFLINE_OUTCOME=queued on the daemon to record queued there instead.
The registry is a thin, per-daemon list in this release. There is no cloud fan-out of triggers, and the web app's Machines page lists orchestration runners — not these automation peers.
After setup
| Check | Expect |
|---|---|
| Cron enabled | Fires on schedule while the daemon is up; automation:fired announced |
| Cron disabled | Silent |
| Webhook bad sig | 401; no wake |
| Webhook good sig | Wakes once; { ok: true, automationId, fired }; secret never echoed |
| Webhook good sig + disabled | 403; no wake |
| Forge configured class | Wakes on pull_request.synchronized — class alone, payload not inspected; every other class comes back ignored: true |
| Target | Prompt, or recipe solo / build-verify — nothing else on the wire |
| Recipe target fires | automation_dispatches row written, automation:dispatched announced; the dispatch runner starts an agent session in the row's workspace — no Floor run |
| Routing | lastRouteOutcome stamped routed / dropped / queued; non-local or offline target skipped here |
| Where you created it | Desktop form or daemon method → fires on that daemon; web catalog → stored only, never fires |
| Plan Done / remote create | Never automatic from an automation |
Related
- Desktop and daemon — the
kairokudprocess these rows live on - Workflows — playbooks, the
teammatenode, and whatplaybookIdreferences - Teammates — reached through a playbook, not a target on this wire
- Glossary — Teammate vs Floor Team (recipe) vs Crew
- Connections — GitHub and GitLab in the web app, which forge automations do not use
- Slack — a separate signed intake path, not this webhook
- Running work — Solo and Build and verify on the Floor
- Sync — human push for remote plan structure
- Governance — human-only gates; app owns plan structure
Teammates
Named chat personas on the desktop daemon — identity, default model, tool grants, the Crew, and what delegation does for a private Teammate versus an approved cloud seat.
Workflows
Saved playbooks on the desktop daemon, and the graph builder in the web app — node kinds, validation, and what a run does.