App events and webhooks
The typed facts the app publishes about its own state, the per-group event journal on disk and webhooks signed with HMAC-SHA256.
Besides agent events, the app publishes app events: typed facts about its own state. They name ids, never contents, and go to the group's event journal, to the webhooks that chose them and to the extensions subscribed to them.
The record
One JSON object per event:
{"api":1,"at":"2026-11-10T09:15:02.481Z","environment":"main","group":2,"event":{"kind":"agent_started","terminal":7,"harness":"ClaudeCode"}}| Field | Meaning |
|---|---|
api | The version of the event format: 1 |
at | RFC 3339 in UTC with milliseconds |
environment | The environment's name |
group | The group's id; left out for events of no group |
event | The event, tagged by kind |
Readers skip records of a newer api or an unknown kind.
Kinds
kind | Other fields | When |
|---|---|---|
agent_started | terminal, harness (optional) | An agent starts a turn |
agent_waiting | terminal | An agent waits for input |
agent_finished | terminal | An agent finishes |
worktree_created | from, the group it came from | A worktree group opens |
worktree_removed | A worktree is removed | |
session_opened | session | A session opens |
session_closed | session | A session closes |
git_changed | The group's git status changes | |
review_sent | merge_request, verdict (commented, replied, approved) | A review is sent |
ci_failed, ci_passed | merge_request, pipeline | A pipeline of an open merge or pull request ends |
merge_request_opened | merge_request | A merge or pull request opens |
variants_started | run, variants (how many) | A variants run starts |
variants_finished | run | Every variant of a run is done |
variant_winner_chosen | run, variant, outcome | A winner is picked |
ping | The test event Settings sends |
harness here is Shell, ClaudeCode, Codex, Opencode, Gateway or Acp:<id>.
The code host events are found by asking GitLab or GitHub every two minutes about the group's open merge and pull requests, up to 20 per project. The app never opens an address for them to call.
The event journal
Each group's events are appended, one record per line, to:
events/<environment>/group-<id>.jsonl
events/<environment>/environment.jsonl events of no groupA file that would pass 512 KiB rotates to .1.jsonl, .2.jsonl and .3.jsonl, the oldest dropped. The journal is read back skipping any line it cannot read. The config folder is described in Configuration.
Webhooks
Settings › Webhooks adds a webhook: a name, an address, the event kinds it wants and, optionally, a secret. The address uses https://; plain http:// is accepted only for this computer (localhost, 127.x.x.x or ::1), and an address may not carry credentials. The secret is kept in the system keyring, or in an owner-only file in the config folder without one.
For each event it chose, the app sends one POST with the record as the body:
| Header | Value |
|---|---|
content-type | application/json |
user-agent | krad-ade-webhooks |
x-krad-timestamp | When this attempt was sent, RFC 3339 in UTC with milliseconds |
x-krad-signature-256 | With a secret: sha256= and the lowercase hex HMAC-SHA256 of <timestamp>.<body> |
Delivery and retries
| Answer | What happens |
|---|---|
2xx | Delivered |
4xx other than 408 and 429 | Given up at once |
408, 429, 3xx, 5xx, or no answer within 10 seconds | Retried, up to five attempts in all, waiting 2, 4, 8 and 16 seconds between them, never more than 60 |
- A retry goes to the webhook as it is then: edited, it uses the new address; removed, it is dropped.
- A webhook marked as signed whose secret is missing sends nothing.
- The "Test event" button sends one
ping, whatever kinds the webhook chose.
Verifying a signature
Read the raw body
Take the body exactly as received, before any JSON parsing, and the x-krad-timestamp and x-krad-signature-256 headers.
Recompute the HMAC
HMAC-SHA256 with the webhook's secret over the timestamp, a dot and the raw body, as lowercase hex after sha256=.
Compare and check the time
Compare in constant time, and turn away timestamps too far from now to stop replays.
expected="sha256=$(printf '%s.%s' "$timestamp" "$body" \
| openssl dgst -sha256 -hmac "$KRAD_WEBHOOK_SECRET" -hex | sed 's/^.* //')"
[ "$expected" = "$signature" ] && echo validThe shell comparison is not constant time; use it to test a receiver, not to guard one.
The five-minute window in the examples is a choice of the receiver; the app does not set one.