Events¶
SwarmKit tells an application what happened. The application decides who hears about it, on which channel, and in what words.
That division is deliberate. The runtime used to ship Slack, Discord and Telegram providers; they were removed in 1.216.0 because a chat integration is a relationship with a third party, not a piece of agent machinery — the same argument that took stage sequencing out in 1.189.0. See extracting the channels.
Two halves, deliberately unequal¶
┌──────────────────────────────────────────┐
run.started │ GET /events?after=<cursor> DURABLE │
run.ended │ the log an application reconciles │
funnel.gate_opened│ from; nothing here is ever lost │
hitl.requested ├──────────────────────────────────────────┤
hitl.resolved │ events: sinks BEST EFFORT │
… │ a webhook, so an application does │
│ not have to poll │
└──────────────────────────────────────────┘
The pull is the source of truth. The push is a latency optimisation over it.
Reading events¶
Returns events in log order, oldest first — a consumer replays forward from where it stopped.
(GET /audit is the other direction, newest first, for a human reading recent history.)
{
"events": [
{
"cursor": "MjAyNi0wOS0wN1QxMjowMDowMCsw…",
"event_type": "funnel.gate_opened",
"run_id": "b7f1eb46c983",
"topology_id": "ask",
"payload": {"gate_id": "b7f1eb46c983:root"}
}
],
"next_cursor": "MjAyNi0wOS0wN1QxMjowMDowMCsw…",
"has_more": false
}
The cursor is opaque — pass back what you were given. Every event carries its own, so an
application that crashes mid-page resumes without skipping what it never processed. An empty page
echoes your cursor back, so an idle consumer need not remember where it was. A cursor this API did
not issue is a 400, never a replay from the beginning.
Pushing events¶
# workspace.yaml
events:
- sink: webhook
url: https://my-app.internal/swarmkit/events
credentials_ref: app-signing-key # sent as a bearer token
types: [funnel.gate_opened, hitl.requested, run.ended]
- sink: stdout # development
The delivery contract, stated rather than implied:
| guarantee | at-most-once |
| retry | a small fixed number of attempts, then dropped |
4xx |
not retried — the application rejected the body |
| a failing sink | logged; it never fails a run |
| a dropped event | still readable at GET /events?after= |
There is no queue, no dead-letter, no backpressure. An application needing stronger delivery puts a queue between itself and the webhook, which is where a queue belongs.
Asking a human¶
A swarm that needs a person opens a gate; it does not send a message. The runtime surfaces the ask and parks the run — how it is resolved is the application's business:
funnel.gate_opened ──► GET /gates/{gate_id} what is it waiting on?
(ask your user, however you like)
◄── POST /review/{item_id}/resolve
{"outcome": "approve", "comment": "…"}
→ the run resumes automatically
gates.auto_resume: false turns that resumption off for an application that batches;
POST /jobs/{id}/resume works either way.
Three things worth knowing before writing a consumer:
- Acknowledge before working. The event is already durable when it arrives. Answering non-2xx makes the runtime retry and then drop.
- Be idempotent per gate. A gate can be announced more than once — a resuming run re-enters the
gated node before finding the existing decision.
GET /gates/{id}reportsresolved. funnel.advisory_completedis not "no gate". It means a funnel declared an approve layer that could not be enforced — usually a role noRoleRegistrydefines — so the run continued unreviewed. Treating it as ungated silently loses an approval step.
A worked example¶
examples/event-consumer/
is a complete application: it reconciles from a cursor on startup, receives pushes, asks a human on
Telegram, resolves the gate, and imports no swarmkit_runtime module. Its only third-party
dependency is httpx.