8.4 KiB
Events: the shared contract
The Go server emits these over Server-Sent Events (SSE). The web UI turns them into Rive state machine inputs. Both sides build against this file; change it first, then the code.
Each SSE message: event: <name> and data: <JSON>. Every payload includes list_id and ts (RFC 3339).
| Event | Payload (besides list_id, ts) | Meaning |
|---|---|---|
list.started |
items: [{item_id, text}], requester |
Flock accepted a list |
item.triaged |
item_id, mode (solo/split/murder), task_type, subtasks[], reason, needs_approval (bool) |
Sentry made its call |
crow.flying |
item_id, crow |
A crow (or Sentry, Elder) took off |
crow.landed |
item_id, crow, ok (bool), tokens, error? |
A crow came back, or didn't |
item.answered |
item_id, answer, crows[], needs_approval (bool) |
Result ready |
verdict |
item_id, target (triage/output), verdict (approved/rejected/auto/timeout) |
Judgment recorded |
trust.changed |
holder (crow name or "flock"), task_type, from, to (0 stranger, 1 regular, 2 trusted) |
Level up or down |
item.skipped |
item_id, reason |
Item dropped (rejected triage, budget, failure) |
requester.pranked |
message, cooldown_s |
Abuse response |
list.finished |
tokens_used, token_budget |
Done |
HTTP API
| Method | Path | Body / query | Returns |
|---|---|---|---|
| POST | /api/lists |
{"text": "<todo list>", "requester": "<id>"} |
{"list_id"}, or 429 with {"message","cooldown_s"} when pranked |
| GET | /api/events |
?list_id= |
SSE stream |
| POST | /api/verdicts |
{"item_id", "target": "triage"|"output", "approve": bool} |
204 |
| GET | /api/trust |
ledger JSON |
Pending approvals that get no answer within 10 minutes count as rejected (verdict: timeout). Fail safe.
Nest messages
A nest is a conversation. Members (the owner and crows) post messages of a kind; the harness tags each kind and, when a message calls for a response, invokes exactly one crow (never an idle loop). Nest events stream over SSE keyed by nest_id, separate from the per-list stream.
Kinds
| Kind | Who posts it | What it does |
|---|---|---|
say |
any member | Ordinary talk. No response required. The default when nothing else fits. |
request |
any member | Asks a specific member (or the room) for work. Needs a claim from an addressed crow. |
claim |
a crow | Takes ownership of a request. solo/split: first claim wins. conspiracy: multiple claims are expected, each a distinct lens. |
deliver |
a crow | The result, addressed back to whoever requested. |
refer |
any member | Points to an artifact by name instead of pasting it. |
nudge |
harness only | A Jev verdict handed to one crow to proceed on a missed expectation. Not a chat message from a crow. |
flag |
Sentry only | A suspected contract breach. Quarantines the crow and opens an ask_owner. |
Payload
Every nest event carries nest_id, ts (RFC 3339) and one message record. The record:
| Field | Type | Meaning |
|---|---|---|
msg_id |
string | <nest_id>-<n>, numbered from 1 in nest order. |
kind |
string | One of the seven kinds above. |
author |
string | "owner", a crow name, "harness" (nudge) or "Sentry" (flag). |
text |
string | The message body. |
to |
string | The @-addressed member, or "". deliver/claim default to the requester they answer. |
refers_to |
string | The msg_id this responds to (claim/deliver/nudge point at their request/expectation), or "". |
tagged_by |
string | How kind was decided: "jev", "header", "disagreement" (Jev overrode the crow's header) or "default" (nothing fit, became say). |
SSE event
| Event | Meaning |
|---|---|
nest.message |
A message was posted to the nest. Payload is the full record above. |
Reconnect works the same as the list stream: each SSE message carries an id: line, clients send Last-Event-ID, and the server replays missed records in msg_id order.
HTTP API
| Method | Path | Body / query | Returns |
|---|---|---|---|
| POST | /api/nests/{nest_id}/messages |
{"author", "text", "to"?, "kind"? (crow header), "refers_to"?} |
201 with the stored record (server-assigned kind, tagged_by, msg_id). 404 unknown nest; 400 with {"message"} for bad input; 429 with {"message","cooldown_s"} when the poster is rate-limited. |
| GET | /api/events |
?nest_id=<id> (parallel to ?list_id=) |
SSE stream of nest.message events for that nest. |
Tagging [decided 2026-10-27]
Jev choice-tags every posted message (kind question). Above its high band, Jev's tag stands (tagged_by: "jev"). If Jev is unsure, the crow's own kind header is used ("header"). If both are present and disagree, Jev wins and it is recorded as "disagreement". If neither yields a kind, the message is say ("default"). Every tag is logged with its probabilities so the calibration experiment has data.
Guardrails
text1–2,000 characters after trimming (same cap as a list item).authormust be"owner"or a member of the nest; a non-member posts through the assistant or a member (room membership is a boundary).kind, when a crow supplies a header, must be one of the crow-postable kinds (say,request,claim,deliver,refer). Crows may not postnudgeorflag.- Requests per minute and cooldown reuse the requester strikes in the ledger, keyed by
author.
Settled details
item_idis<list_id>-<n>, numbered from 1 in list order.POST /api/listsreturns 202. Bad input returns 400 with{"message"}.POST /api/verdictsreturns 404 when nothing is waiting (including a second answer), 400 for a bad body.approveis required, so a missing value is never read as a no.- When trust skips approval, the server still emits
verdict: "auto", so every target gets exactly one verdict. crow.flyingandcrow.landedfire for the Sentry and Elder too.requester.prankedgoes to every list of that requester's still flying; if none, only the 429 is sent.- Each SSE message carries an
id:line, so clients can reconnect withLast-Event-ID. Heartbeat comment every 15 seconds. - The server listens on localhost only by default. There is no login yet, and requester names are self-asserted.
Nests
A nest is a room where a team of crows works together. You create a nest and pick its core crows; when a job needs more crows than the core has, the Sentry borrows from the rest of the flock. The Sentry and Elder serve every nest and are never core members.
- The default nest is
todo, with an empty core: its crows come from the whole flock, so nothing there is ever "borrowed". - Crows are picked core-first (rotating within the core), then borrowed from outside it (rotating), still capped by the flock guardrails.
- Nests persist on the server (
.crows/nests.json).
| Method | Path | Body | Returns |
|---|---|---|---|
| GET | /api/nests |
{"roster": ["Hugin", ...], "nests": [{"nest_id", "name", "crows": [...]}]}; roster is the 8 crows (not Sentry or Elder) |
|
| POST | /api/nests |
{"name": "Launch copy", "crows": ["Pepper", "Inky"]} |
201 with the nest; 400 with {"message"} if invalid |
| POST | /api/lists |
adds optional "nest_id" (default todo) |
404 with {"message"} for an unknown nest |
Guardrails: name 1–40 characters after trimming; nest_id is a slug of the name (lowercase, a-z0-9-), unique (409 with {"message"} on a clash); crows must be roster names, no duplicates, 1–8 of them; at most 20 nests.
Event changes:
list.startedaddsnest_id.crow.flyingaddsborrowed(bool): true when the crow is not in the nest's core. Always false for Sentry, Elder, and thetodonest.
Personas and banter
| Method | Path | Returns |
|---|---|---|
| GET | /api/crows |
[{"name", "epithet", "strength", "backstory"}] for all ten crows |
| Event | Payload (besides list_id, ts) | Meaning |
|---|---|---|
crow.banter |
item_id, crow (speaker), to (the crow it's addressed to), text (one line) |
A generated aside between crows. Not part of any answer. |
Banter is generated: the server retrieves the involved crows' backstories, their relationship and recent flock events, then the speaking crow writes one line in character. At most 2 per list, counted against the list budget; banter never blocks work, and a failed or unusable line is dropped. list.finished waits up to 20 seconds for in-flight banter. See PERSONAS.md.