crowsnest v2.1 Specification
One vocabulary: a client emits a sighting to the receiver on :8127; the dashboard reads the log.
Table of Contents
1. Scope
crowsnest is a local agent-observability system in three roles. This document is the specification – equal-weight across all three roles, not a "client spec" with the others bolted on. The Sighting + Endpoints sections are the narrow bytes-on-the-wire contract; the others are the in-process, browser-side, and operational invariants a conforming implementation also needs.
| Role | What it is | Normative section |
|---|---|---|
| client | a library embedded in an agent/tool; constructs and POSTs sightings | 5 |
| receiver | the :8127 loopback process; ingests, holds the log, serves reads |
6 |
| dashboard | the browser UI at https://wal.sh/tools/crowsnest/; reads the log, renders it | 7 |
The call structure between them (one direction per arrow; loopback only across the entire diagram):
digraph crowsnest_calls {
rankdir=LR;
bgcolor=white;
graph [fontname="Helvetica", fontsize=11, pad=0.3, nodesep=0.45, ranksep=0.55];
node [fontname="Helvetica", fontsize=10, style="rounded,filled", shape=box, penwidth=1.2];
edge [fontname="Helvetica", fontsize=9, color="#888888", fontcolor="#555555"];
// Clients -- blue family (Tailwind 50/100/700)
subgraph cluster_clients {
label="clients (any language, any process)";
fontname="Helvetica"; fontsize=11; style="rounded,filled";
fillcolor="#eff6ff"; color="#1d4ed8"; fontcolor="#1d4ed8";
c_sdk [label="SDK client\n(crowsnest-client-{ts,rust,go,ruby,py})", fillcolor="#dbeafe", color="#1d4ed8", fontcolor="#1d4ed8"];
c_shell [label="shell wrapper\n(curl one-liner)", fillcolor="#dbeafe", color="#1d4ed8", fontcolor="#1d4ed8"];
c_bridge [label="streaming bridge\n(long-running producer)", fillcolor="#dbeafe", color="#1d4ed8", fontcolor="#1d4ed8"];
c_beacon [label="cron heartbeat\n(low-frequency cadence)", fillcolor="#dbeafe", color="#1d4ed8", fontcolor="#1d4ed8"];
}
// Receiver -- amber family; the only port-binder, the ring is the log.
subgraph cluster_receiver {
label="receiver -- loopback :8127 only";
fontname="Helvetica"; fontsize=11; style="rounded,filled";
fillcolor="#fffbeb"; color="#b45309"; fontcolor="#b45309";
rcv [label="receiver process", fillcolor="#fef3c7", color="#b45309", fontcolor="#b45309"];
ring [label="newest-first ring\ncap 100", fillcolor="#fef3c7", color="#b45309", fontcolor="#b45309", shape=cylinder];
rcv -> ring [label="normalize + append", color="#15803d", fontcolor="#15803d"];
ring -> rcv [label="snapshot / broadcast", color="#15803d", fontcolor="#15803d", style=dashed];
}
// Dashboard -- violet family; browser-side, read-only.
subgraph cluster_dashboard {
label="dashboard -- browser at https://wal.sh/tools/crowsnest/";
fontname="Helvetica"; fontsize=11; style="rounded,filled";
fillcolor="#f5f3ff"; color="#6d28d9"; fontcolor="#6d28d9";
dash [label="dashboard SPA", fillcolor="#ede9fe", color="#6d28d9", fontcolor="#6d28d9"];
}
// Client → receiver (ingest)
c_sdk -> rcv [label="POST /sightings"];
c_shell -> rcv [label="POST /sightings"];
c_bridge -> rcv [label="POST /sightings"];
c_beacon -> rcv [label="POST /sightings"];
// Dashboard → receiver (read paths)
dash -> rcv [label="GET /info (probe)"];
dash -> rcv [label="GET /sightings (snapshot)"];
dash -> rcv [label="GET /sightings/stream (SSE live)"];
// Optional discovery surface for non-browser clients
c_sdk -> rcv [label="GET /openapi.json (codegen)", style=dotted];
}
All three roles reference one shared record, 4. A conforming implementation in any language reads this contract and interoperates.
2. Confirmation gate
Before writing code against this contract, the implementer MUST be able to answer these. If any answer is unclear, re-read the cited section first.
- What address and path does a client POST to? (
POST http://127.0.0.1:8127/sightings, loopback only – see 5) - What is the record called, and what is its id field? (a sighting; the id field is
id– nevertrace_id; see 4) - Which field is a number, and which is an enum? (
durationis integer ms;statusis one ofok/error/unknown) - What is the default/coercion target for an unrecognized
status? (unknown– neverok; an unknown outcome MUST NOT render green – see 6.3) - Where do attributes go, and what is the host key? (one bag,
attrs; the hostname isattrs.host– see 4.2) - What does the client do if the receiver is not running? (log once, continue; MUST NOT retry forever, MUST NOT spool – see 5.5)
- Name two things a sighting MUST NOT contain. (credentials of any kind; PII; full prompt text – see 5.6)
3. Quickstart (curl)
The whole contract is exercisable from a shell – no SDK, no CLI, no client library. These commands ARE the client; a conforming receiver in any language answers all of them identically.
# 1. start any conforming receiver (build one from "The Receiver" below) on loopback
<your-receiver> # -> binds 127.0.0.1:8127
# 2. discover: follow links from the root (HATEOAS), incl. the machine contract
curl -s http://127.0.0.1:8127/ # service index + _links (rel -> href/method)
curl -s http://127.0.0.1:8127/openapi.json # OpenAPI 3.1 -- codegen a client from this
# 3. emit one sighting (and a batch is just a JSON array)
curl -s -XPOST http://127.0.0.1:8127/sightings -H 'Content-Type: application/json' \
-d '{"name":"chat.completion","service":"litellm","duration":3400,"status":"ok","attrs":{"host":"mini","model":"claude-opus-4-7"}}'
# -> {"ok":true,"stored":1}
# 4. read the log (snapshot) and the live feed (SSE)
curl -s http://127.0.0.1:8127/sightings # newest-first JSON array
curl -sN http://127.0.0.1:8127/sightings/stream # event: sighting\ndata: {...}
# 5. identity / liveness
curl -s http://127.0.0.1:8127/info
curl -s http://127.0.0.1:8127/healthz
# a malformed emit is a TYPED 400 (never a dropped connection)
curl -s -XPOST http://127.0.0.1:8127/sightings -H 'Content-Type: application/json' -d '{"duration":"x"}'
# -> {"ok":false,"error":{"code":"R3","message":"duration must be a finite number"}}
That is the entire surface. A conforming receiver passes the 8 driven exactly like this.
4. The Sighting
A sighting is one thing the lookout saw: a single agent operation that happened. It is a flat, self-contained event – not a node in a trace tree. The collection of sightings the receiver holds is the log.
4.1. Shape
A conforming sighting is a JSON object:
| Field | Required | Type | Meaning |
|---|---|---|---|
id |
no (defaulted) | string | Opaque sighting id; the dashboard renders the first 9 chars. Clients SHOULD generate one (ULID or a short prefix + 10+ hex: sg-bf5fa97c4) so the rendered slice is distinct. If omitted, the receiver assigns one. Not trace_id – there is no trace. |
name |
yes | string | What happened – the operation label (chat.completion, publish-file, tool.read_file). Defaults to "unknown" (renders, uninformative). |
service |
yes | string | Which agent/program emitted it (gmake, litellm, claude-opus, bb, clj, aq). Defaults to "unknown". |
start |
no | integer | Wall-clock start, epoch milliseconds. MUST be a finite number if present (see 6.7.3). |
duration |
yes | integer | Wall-clock duration in milliseconds. 0 is valid (instant). MUST be numeric AND finite – a string renders blank; NaN~/~Infinity break the dashboard's strict JSON parser, blanking the whole log (see 6.7.3). |
status |
yes | enum | One of "ok", "error", "unknown". Default and coercion target is "unknown" (see 6.3). |
attrs |
no (recommended) | object | The single attribute bag. See 4.2. |
count |
no | integer | Number of sub-operations this sighting summarizes. Defaults to 1 (client-side); MUST be a finite number if present (see 6.7.3). Forward-looking; the dashboard does not yet render it. |
There is no top-level anything else. Units are a schema invariant (milliseconds);
fields carry no _ms suffix.
4.2. Attributes
attrs is the one free-form bag. The dashboard projects a set of reserved keys;
all other keys are stored and round-tripped through GET /sightings but not
rendered. Key style: flat snake_case; a dotted namespace only where a key owns
a genuine sub-structure.
Key in attrs |
Type | Rendered as / meaning |
|---|---|---|
host |
string | the origin column. Short hostname (nexus, mini, laptop), not FQDN. |
sha |
string | code/commit revision of the emitting agent (SHA tooltip / column). |
origin |
string | the emitting agent/tool identity (e.g. git remote/branch). Replaces the old "agent" field. |
user |
string | who initiated the operation. |
model |
string | model name, plain (claude-opus-4-7). For LLM sightings. |
tokens.in |
integer | input/prompt tokens. The one dotted namespace. |
tokens.out |
integer | output/completion tokens. |
cost |
number | cost in USD, numeric (0.0042, not the string "$0.0042"). |
file |
string | primary file the operation touched. |
A client SHOULD use the reserved keys for anything the dashboard will project, and MAY add arbitrary additional keys for its own consumers.
4.3. Example
{
"id": "sg-019ef625a",
"name": "chat.completion",
"service": "litellm",
"start": 1782245999000,
"duration": 3400,
"status": "ok",
"attrs": {
"host": "mini",
"sha": "39a5b338",
"model": "claude-opus-4-7",
"tokens.in": 1820,
"tokens.out": 540,
"cost": 0.0123
}
}
5. The Client
A crowsnest client is any process that constructs sightings and POSTs them to the receiver. A client is a producer, never a listener.
5.1. Emit
POST http://127.0.0.1:8127/sightings with Content-Type: application/json.
The body is either a single sighting object or a JSON array of sightings (a
batch). On success the receiver returns 200 with {"ok": true, "stored": N}.
A client MAY fire-and-forget (ignore the body) but MUST check for HTTP success.
5.2. Network boundary
5.2.1. Loopback only
The client MUST send only to a loopback address – 127.0.0.1, localhost, or
[::1] (the receiver binds 127.0.0.1 per invariant I1 in the boundary audit).
The client MUST NOT send to a routable address, even on the LAN, even if an
operator configured one, and MUST NOT fall back to a remote endpoint on loopback
failure. The "no sightings leave the machine" guarantee is symmetric with the
dashboard's "no credentials leave the browser."
5.2.2. Port
The receiver default is 8127. A client MAY accept CROWSNEST_PORT (env or
flag) to override but MUST default to 8127. The port is fixed by the dashboard
bundle; ?agent=N on the dashboard URL is a debug override, not a production knob.
5.2.3. No bind, no listen
A client MUST NOT bind a socket. Long-running producers (e.g. a streaming bridge) are still producers – they POST per event; they do not accept connections.
5.3. Identity
The client SHOULD set attrs.host on every sighting – the single most useful
identity field for a multi-machine fleet (it becomes the origin column), and
trivial to fill (hostname, os.uname().nodename, (.. java.net.InetAddress
getLocalHost getHostName)). A client emitting across machines (a streaming bridge)
MUST set attrs.host accurately; misattribution corrupts every downstream audit
derived from the log. attrs.user and attrs.origin are recommended where known.
5.4. Batch
A client MAY POST an array of sightings in one request; the receiver normalizes
each independently and prepends all to the newest-first log. The log cap is
100; a larger batch is accepted but only the most recent 100 stay visible.
Chunk batches to 20–50 for predictable behavior under load.
5.5. Client failure modes
The receiver is a developer-controlled local service, not a managed dependency. The client MUST handle each case gracefully:
- Receiver absent (ECONNREFUSED/EHOSTUNREACH). Log once per session (or per
configured interval) and continue. MUST NOT block waiting for it. MUST NOT
spool to disk for replay unless the operator opts in (separate flag).
Rationale: crowsnest is a visibility layer, not an audit log – the hash-chain
ledger (
.verify/chain.jsonl) is the audit primitive; crowsnest is opportunistic. - Timeout. Apply a short timeout (≤3s; default
3000ms,1500ms fine for high-throughput). MUST NOT block primary work on a slow receiver. - 4xx. The body is malformed. MUST NOT retry the same body. A
400from a compliant receiver means the client violated this contract – fix the client. - 5xx. Internal receiver error. MAY retry once with backoff (≥1s, then give up). MUST NOT retry in a tight loop.
- Concurrent emitters. Multiple clients sharing one receiver is the expected case; the log is single-process newest-first, POSTs interleave naturally, no client-side coordination needed.
5.6. Client security
A sighting carries operational telemetry, never payload. The client MUST NOT
include – top-level or in attrs – any of:
- Credentials of any kind: API keys, bearer tokens, session cookies, DB
passwords, SSH private keys, PATs, LiteLLM virtual keys, cloud keys. The log is
in-process memory, but the dashboard runs in the browser on
https://wal.sh– a leaked credential becomes a credential in a page loaded over the internet (boundary audit §6 F1: a compromised same-origin script can read all ofGET /sightings). - PII about end-users. Instrument the operation ("ran N ms, status S"), not the data it processed.
- Full prompt/response text. LLM sightings SHOULD record
model,tokens.in,tokens.out,cost,duration,status; a shortconversation_idis fine, the contents are not. - File contents. File-processing sightings record
attrs.file(a path), never the bytes.
6. The Receiver
The receiver is the :8127 loopback process. It ingests sightings, normalizes
them, holds the log (a newest-first ring, cap 100), and serves reads. Reference
implementation: src/wal_sh/cn/server.clj.
6.1. Endpoints
| Method | Path | Purpose |
|---|---|---|
GET |
/ |
service index – HATEOAS _links to every endpoint, including /openapi.json (see 6.2) |
POST |
/sightings |
ingest one sighting or a batch; 200 {"ok":true,"stored":N}, else a typed 400 (see 6.4) |
GET |
/sightings |
snapshot read – the current log as a JSON array, newest-first |
GET |
/sightings/stream |
live read – SSE; one event: sighting per new ingest |
GET |
/info |
receiver identity (see below) |
GET |
/healthz |
liveness; 200 {"status":"ok"} |
GET |
/openapi.json |
machine-readable OpenAPI 3.1 of this contract – for client codegen/validation |
GET /sightings is the production read path (the dashboard's snapshot + cold
start). GET /sightings/stream is the canonical live path; the dashboard is
stream-first with poll fallback.
Every path also answers OPTIONS (CORS preflight → 204), and every response
carries the CORS headers (Access-Control-Allow-{Origin,Methods,Headers,Private-Network}
Vary: Origin) per 6.6.
Both the preflight and those headers are declared in /openapi.json, not just
here – a browser-client author sees the policy from the machine contract.
6.2. Service index
GET / returns a small hypermedia index – identity plus a _links map
(rel → {href, method, title}) pointing at every endpoint, /openapi.json
included. It is the discovery entry point: a client (or a human with curl) can
hit / and follow links rather than hard-code paths.
{
"service": "crowsnest-receiver",
"role": "receiver",
"version": "2.0.0",
"spec": "https://wal.sh/tools/crowsnest/spec",
"_links": {
"self": {"href": "/", "method": "GET"},
"openapi": {"href": "/openapi.json", "method": "GET", "title": "machine-readable contract (OpenAPI 3.1)"},
"ingest": {"href": "/sightings", "method": "POST", "title": "ingest a sighting or batch"},
"snapshot": {"href": "/sightings", "method": "GET", "title": "the log, newest-first"},
"stream": {"href": "/sightings/stream", "method": "GET", "title": "live SSE feed"},
"info": {"href": "/info", "method": "GET", "title": "receiver identity"},
"health": {"href": "/healthz", "method": "GET", "title": "liveness"}
}
}
6.3. Normalization
On ingest the receiver normalizes each sighting:
- fill
id(generate if absent),name~/~service(→"unknown").count~/~startpass through unchanged when present (countdefaults to1client-side). status: if not exactly"ok"or"error", coerce to"unknown"– never"ok". An errored or malformed outcome MUST NOT be rendered as success. This is a correctness rule, not a style choice.duration, andstart~/~countwhen present: MUST be a finite number, else400(see 6.7.3).attrs: if absent →{}; if present but not an object →400(see 6.7.1).
6.4. Error response
Every error response (4xx/5xx) carries a typed, coded body – not a bare string – so clients and codegen can branch on a stable discriminator:
{ "ok": false,
"error": { "code": "R3", "message": "duration must be a finite number" } }
error.code |
HTTP | Meaning |
|---|---|---|
R1 |
400 | body, or attrs, is not the right JSON shape (see 6.7.1) |
R2 |
400 | attrs nested past the depth cap (see 6.7.2) |
R3 |
400 | a numeric field (duration~/~start~/~count) is non-finite or non-numeric (see 6.7.3) |
BODY_TOO_LARGE |
400 | request body over the size cap (RECOMMENDED 1 MiB) |
PARSE |
400 | body is not valid JSON (includes literal NaN~/~Infinity tokens, which a strict parser rejects) |
NOT_FOUND |
404 | no route matches the method + path |
INTERNAL |
500 | unexpected receiver fault (still trapped – the receiver always answers) |
error.message is human-readable and MAY change; error.code is the stable
contract. A client MAY branch on ok alone (success bodies are ok: true)
and MUST treat an unrecognized error.code as a generic failure – new codes MAY
be added in a minor revision. The machine-readable schema is the Error
component served at /openapi.json.
6.5. /info
The receiver describes itself. It is the receiver, not the agent.
{
"service": "crowsnest-receiver",
"role": "receiver",
"version": "<semver>",
"port": 8127,
"sightings": 1234,
"uptime_s": 4521
}
6.6. CORS
The receiver keeps an exact-string origin allowlist (configurable via env,
e.g. CROWSNEST_ORIGINS, comma-separated). Default:
https://wal.sh http://localhost:8799 http://127.0.0.1:8799
On every response (and the OPTIONS preflight, 204):
- if request
Originmatches the allowlist,Access-Control-Allow-OriginMUST reflect that exact origin; - on a miss,
Access-Control-Allow-OriginMUST be omitted entirely (never*, never the first-allowlisted entry as a silent fallback – both create asymmetric browser-vs-curl behavior); Vary: OriginMUST be set on every CORS-affected response;Access-Control-Allow-Private-Network: trueMUST be set (for browsers that prompt on public→loopback under Local Network Access).
The full CORS contract is declared in /openapi.json (components/headers) so
browser-client authors read the policy from the machine contract, not just here.
6.7. Receiver robustness
These rules harden the receiver against malformed input. They were surfaced by property-based testing (Hypothesis, round 3) which found three crash classes the fixture tests missed. A receiver that violates these turns one client's bug into a DoS or a log-blanking event for the whole fleet. The governing rule: trap all exceptions on body parse and normalize; always return a response.
6.7.1. R1 – well-formed body
A compliant receiver MUST return 400 when:
- the body parses to anything other than a JSON object or a JSON array of objects;
attrsis present but is not a JSON object (int, string, bool, null, array →400).
The receiver MUST NOT raise an uncaught exception in the request thread or close the connection without a response.
6.7.2. R2 – attrs nesting cap
A compliant receiver MUST cap attrs nesting at a finite depth (RECOMMENDED
64) and return 400 when exceeded. The depth check itself MUST be bounded so
it cannot overflow on a hostile payload (a 10KB deeply-nested object MUST NOT
push the receiver into a stack overflow). Trap parse-time errors -- including
recursion/stack errors -- and return ~400.
6.7.3. R3 – finite numbers
A compliant receiver MUST reject NaN, Infinity, -Infinity on ingest (400)
AND MUST NOT emit them in GET /sightings (equivalent to strict RFC-8259 /
allow_nan=False). A single "duration": NaN in the log blanks the entire
dashboard, because the browser's r.json() rejects the whole document – one bad
ingest takes down visibility for everyone.
This applies to every numeric field that can reach the wire – duration, and
start~/~count when present – not just duration: any one of them is enough to
blank the log. A non-numeric value (e.g. a string "abc") is rejected the same
way, with error.code = R3. A literal NaN~/~Infinity token is rejected
earlier, by the JSON parser itself, as error.code = PARSE – either way, 400,
and the log stays strict-parseable.
7. The Dashboard
The dashboard is the browser UI. It probes GET /info to detect a live
receiver, reads the log, and renders it. Reference: src/wal_sh/tools/crowsnest.cljs.
- Probe / liveness / version. GET
/infowithin a short window (2s). Three outcomes, three distinct states (see 10): no/failed response → demo (no receiver; synthetic sightings, badged); a response whoseversionis compatible (same MAJOR) → live; a response with an incompatible MAJOR → version mismatch – badge the receiver's version and the one the dashboard needs, and do NOT silently fall to demo. - Read. Stream-first: subscribe to
/sightings/stream(SSE,event: sighting), prepend each; fall back to pollingGET /sightingsfor the snapshot / cold start. - Render columns.
id(first 9 chars),name,service,duration(ms→ms/s/m),status, and theattrsprojections (hostas origin,sha). - Status colour – load-bearing.
ok→ green,error→ red,unknown→ amber/grey.unknownMUST NOT render green; that is the visible end of the normalization correctness rule.
8. Conformance fixtures
A compliant implementation MUST pass these against any compliant receiver
(src/wal_sh/cn/server.clj ships an in-process --selftest that runs them).
8.1. F1 – minimal sighting
Emit: {"name":"hello","service":"selftest","duration":100,"status":"ok"} →
GET /sightings returns it at index 0; name/service/duration/status render, id
auto-assigned, origin empty (no attrs.host).
8.2. F2 – host attribution
Emit: {"name":"ingest","service":"agent","duration":42,"status":"ok","attrs":{"host":"nexus","sha":"39a5b338"}} →
the dashboard renders nexus in the origin column.
8.3. F3 – status coercion to unknown
A misbehaving client emits "status":"warning" → the receiver coerces to
"unknown" (NOT "ok"), and the dashboard renders it amber/grey, not green.
(Tests the receiver's defensive behavior; a compliant client never sends this.)
8.4. F4 – malformed input rejected (R1/R2/R3)
Each MUST return 400 and leave the receiver responsive: a bare scalar body
(5); JSON null (error.code R1, see 6.7.1); "attrs": 1; attrs nested past the cap; "duration": NaN /
"duration": "abc"; a non-finite start~/~count. GET /sightings MUST remain
strict-RFC-8259-parseable afterward. The 400 body MUST be the typed
6.4 ({ok:false, error:{code, message}}): a string "duration":
"abc" yields error.code = R3; a literal "duration": NaN token yields
PARSE.
8.5. F5 – receiver-absent graceful degrade
Receiver down; client attempts an emit → logs once, returns to primary work, does NOT throw, block, or retry indefinitely.
9. Refutation conditions
If any of these holds, this contract is wrong and MUST be updated:
- A sighting with
"status":"ok"and"duration":100does NOT render within the probe window (2s default). - The dashboard renders a
statusofunknownas green (it MUST be amber/grey). - The receiver accepts
POST /sightingsfrom a non-loopback origin. - A compliant client sees its sighting at
GET /sightingsbut the dashboard does not render it. - Two compliant clients emitting concurrently lose sightings below the ring cap (100).
- A malformed body (non-object, non-object
attrs, over-deepattrs, non-finiteduration) crashes the receiver, drops the connection, or returns 5xx instead of400(see 6.7). GET /sightingsever serves a body strict RFC-8259 parsers reject (NaN/Infinity – R3).
10. Versioning
This is v2.1. The version is the document (machine-readable as
#+VERSION: in the org source); there is no _spec_version field on the
wire. A wire-breaking change MUST introduce _spec_version: 3 on every
sighting and a receiver transition period that accepts both. Adding an optional
field or a reserved attrs key does not bump MAJOR.
10.1. History
| Version | Adds (all additive over previous) |
|---|---|
v2.0 |
from-scratch contract: sighting / /sightings / attrs / typed errors / R1·R2·R3 / status enum {ok, error, unknown} |
v2.1 |
HATEOAS service index at GET / + receiver-served GET /openapi.json (both READ-only, both additive – a v2.0 receiver that lacks them is still wire-conformant; clients SHOULD prefer the discovery surface when present) |
10.2. Compatibility
The compatibility surface is the receiver's /info.version (semver). The
dashboard MUST treat the receiver as compatible iff MAJOR matches; on mismatch
it MUST render an explicit version mismatch state, distinct from demo and
live. A client SHOULD warn on MAJOR mismatch but MAY still emit (the receiver
is the authority on what it accepts). A MINOR mismatch is always compatible.
11. Building from this spec (agent guide)
This is a meta-prompt. If you are an agent, you need only two URLs – this spec
(https://wal.sh/tools/crowsnest/spec) and the production dashboard
(https://wal.sh/tools/crowsnest/). No repository, no SDK, no CLI, nothing else.
Everything below is derivable from the sections above.
11.1. Task
- Build a receiver from 6: bind
127.0.0.1:8127; serve the 6.1 (including the 6.2 at/and/openapi.json); apply 6.3; return the typed 6.4; enforce R1/R2/R3 (6.7); set CORS per 6.6 – the allowlist MUST includehttps://wal.shso the production dashboard can read your receiver, and MUST sendAccess-Control-Allow-Private-Network: true. - Build a client from 5: emit a single sighting and a batch to
POST /sightings; loopback-only; fire-and-forget but check HTTP success; degrade gracefully when the receiver is absent (5.5). - Run a simulation:
5–10 sightings/sec from varied templates (build / llm / deploy / review shapes; ≈80% ~ok, ≈13%error, ≈7% other →unknown) for ~60s.
11.2. Verify – do not declare done until every check passes
- Drive every command in 3 against your receiver; each
returns exactly as documented, including the typed
400. - Pass the 8 F1–F5.
- Open
https://wal.sh/tools/crowsnest/in Chrome or Firefox (the CSP allows the loopback fetch): your live sightings render, and status colours are correct –unknownis amber, never green. This is the real acceptance test: the production dashboard reading your from-scratch receiver. - Optional: codegen a client from your
/openapi.jsonand confirm it round-trips.
11.3. Done
Receiver up on :8127, client emitting the simulation, fixtures green, and the
production dashboard showing your sightings live. Report the fixture results and a
sample of what the dashboard rendered.
12. Machine-readable contract (/openapi.json)
The prose above is the normative contract. This is the same contract as OpenAPI
3.1 – exactly what the receiver serves at GET /openapi.json – so a client in
any language (the Python receiver and clients, plus Go/Rust/TS/Bun) can codegen
and validate against it without re-reading the prose. The Sighting, Error,
IngestResult, and Info schemas here are the receiver, client, and dashboard's
shared shapes.
The canonical source is the live endpoint and the openapi def in
src/wal_sh/cn/server.clj; the block below is a snapshot of one running v2
receiver. Do not hand-edit it – regenerate from a receiver so it cannot drift
from what the server actually serves (sorted keys → byte-stable, clean diffs):
curl -s http://127.0.0.1:8127/openapi.json \
| python3 -c 'import sys,json;print(json.dumps(json.load(sys.stdin),indent=2,sort_keys=True,ensure_ascii=False))'
{
"components": {
"schemas": {
"Error": {
"description": "Returned with any 4xx/5xx. The discriminator is error.code; message is human-readable detail. Clients MUST treat an unrecognized code as a generic failure.",
"properties": {
"error": {
"properties": {
"code": {
"description": "R1 = non-object body/attrs (400); R2 = attrs nested too deep >64 (400); R3 = non-finite duration/start/count (400); BODY_TOO_LARGE = body over 1 MiB (400); PARSE = invalid JSON (400); NOT_FOUND = no matching route (404); INTERNAL = receiver fault (500)",
"enum": [
"R1",
"R2",
"R3",
"BODY_TOO_LARGE",
"PARSE",
"NOT_FOUND",
"INTERNAL"
],
"type": "string"
},
"message": {
"description": "human-readable detail",
"type": "string"
}
},
"required": [
"code",
"message"
],
"type": "object"
},
"ok": {
"enum": [
false
],
"type": "boolean"
}
},
"required": [
"ok",
"error"
],
"type": "object"
},
"Info": {
"properties": {
"port": {
"type": "integer"
},
"role": {
"type": "string"
},
"service": {
"type": "string"
},
"sightings": {
"type": "integer"
},
"uptime_s": {
"type": "integer"
},
"version": {
"type": "string"
}
},
"type": "object"
},
"IngestResult": {
"properties": {
"ok": {
"type": "boolean"
},
"stored": {
"type": "integer"
}
},
"type": "object"
},
"ServiceIndex": {
"description": "GET / hypermedia index -- identity plus a _links map to every endpoint.",
"properties": {
"_links": {
"description": "rel -> {href, method, title}; includes self, openapi, ingest, snapshot, stream, info, health",
"type": "object"
},
"role": {
"type": "string"
},
"service": {
"type": "string"
},
"spec": {
"format": "uri",
"type": "string"
},
"version": {
"type": "string"
}
},
"type": "object"
},
"Sighting": {
"properties": {
"attrs": {
"description": "free-form bag; reserved keys are projected by the dashboard",
"properties": {
"cost": {
"type": "number"
},
"file": {
"type": "string"
},
"host": {
"type": "string"
},
"model": {
"type": "string"
},
"origin": {
"type": "string"
},
"sha": {
"type": "string"
},
"tokens.in": {
"type": "integer"
},
"tokens.out": {
"type": "integer"
},
"user": {
"type": "string"
}
},
"type": "object"
},
"count": {
"description": "sub-operation count; client-defaults to 1; MUST be finite if present (R3)",
"type": "integer"
},
"duration": {
"description": "ms; MUST be finite (NaN/Infinity -> 400, R3)",
"type": "integer"
},
"id": {
"description": "opaque id; receiver assigns if absent; dashboard renders first 9 chars (NOT a trace_id)",
"type": "string"
},
"name": {
"description": "operation label (chat.completion, publish-file)",
"type": "string"
},
"service": {
"description": "emitting program (litellm, gmake, clj, claude-opus)",
"type": "string"
},
"start": {
"description": "wall-clock start, epoch ms",
"type": "integer"
},
"status": {
"description": "unrecognized values coerce to 'unknown' (never 'ok'); unknown renders amber, not green",
"enum": [
"ok",
"error",
"unknown"
],
"type": "string"
}
},
"required": [
"name",
"service",
"duration",
"status"
],
"type": "object"
}
}
},
"info": {
"description": "Local agent-observability receiver. Clients POST sightings; the dashboard reads the log. Full wire contract: https://wal.sh/tools/crowsnest/spec",
"title": "crowsnest receiver",
"version": "2.0.0"
},
"openapi": "3.1.0",
"paths": {
"/": {
"get": {
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ServiceIndex"
}
}
},
"description": "service index",
"headers": {
"Access-Control-Allow-Headers": {
"description": "Content-Type, Accept, Origin",
"schema": {
"type": "string"
}
},
"Access-Control-Allow-Methods": {
"description": "GET, POST, OPTIONS",
"schema": {
"type": "string"
}
},
"Access-Control-Allow-Origin": {
"description": "reflected exact origin on an allowlist hit; OMITTED on a miss (C4) -- never '*'",
"schema": {
"type": "string"
}
},
"Access-Control-Allow-Private-Network": {
"description": "'true' -- answers the Local Network Access (PNA) preflight for loopback from https://wal.sh",
"schema": {
"type": "string"
}
},
"Vary": {
"description": "Origin -- caches MUST NOT mix the two origins' responses",
"schema": {
"type": "string"
}
}
}
}
},
"summary": "Service index -- HATEOAS links to every endpoint, incl. /openapi.json"
},
"options": {
"responses": {
"204": {
"description": "preflight OK",
"headers": {
"Access-Control-Allow-Headers": {
"description": "Content-Type, Accept, Origin",
"schema": {
"type": "string"
}
},
"Access-Control-Allow-Methods": {
"description": "GET, POST, OPTIONS",
"schema": {
"type": "string"
}
},
"Access-Control-Allow-Origin": {
"description": "reflected exact origin on an allowlist hit; OMITTED on a miss (C4) -- never '*'",
"schema": {
"type": "string"
}
},
"Access-Control-Allow-Private-Network": {
"description": "'true' -- answers the Local Network Access (PNA) preflight for loopback from https://wal.sh",
"schema": {
"type": "string"
}
},
"Vary": {
"description": "Origin -- caches MUST NOT mix the two origins' responses",
"schema": {
"type": "string"
}
}
}
}
},
"summary": "CORS preflight (no body)"
}
},
"/healthz": {
"get": {
"responses": {
"200": {
"description": "{\"status\":\"ok\"}",
"headers": {
"Access-Control-Allow-Headers": {
"description": "Content-Type, Accept, Origin",
"schema": {
"type": "string"
}
},
"Access-Control-Allow-Methods": {
"description": "GET, POST, OPTIONS",
"schema": {
"type": "string"
}
},
"Access-Control-Allow-Origin": {
"description": "reflected exact origin on an allowlist hit; OMITTED on a miss (C4) -- never '*'",
"schema": {
"type": "string"
}
},
"Access-Control-Allow-Private-Network": {
"description": "'true' -- answers the Local Network Access (PNA) preflight for loopback from https://wal.sh",
"schema": {
"type": "string"
}
},
"Vary": {
"description": "Origin -- caches MUST NOT mix the two origins' responses",
"schema": {
"type": "string"
}
}
}
}
},
"summary": "Liveness"
},
"options": {
"responses": {
"204": {
"description": "preflight OK",
"headers": {
"Access-Control-Allow-Headers": {
"description": "Content-Type, Accept, Origin",
"schema": {
"type": "string"
}
},
"Access-Control-Allow-Methods": {
"description": "GET, POST, OPTIONS",
"schema": {
"type": "string"
}
},
"Access-Control-Allow-Origin": {
"description": "reflected exact origin on an allowlist hit; OMITTED on a miss (C4) -- never '*'",
"schema": {
"type": "string"
}
},
"Access-Control-Allow-Private-Network": {
"description": "'true' -- answers the Local Network Access (PNA) preflight for loopback from https://wal.sh",
"schema": {
"type": "string"
}
},
"Vary": {
"description": "Origin -- caches MUST NOT mix the two origins' responses",
"schema": {
"type": "string"
}
}
}
}
},
"summary": "CORS preflight (no body)"
}
},
"/info": {
"get": {
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Info"
}
}
},
"description": "identity",
"headers": {
"Access-Control-Allow-Headers": {
"description": "Content-Type, Accept, Origin",
"schema": {
"type": "string"
}
},
"Access-Control-Allow-Methods": {
"description": "GET, POST, OPTIONS",
"schema": {
"type": "string"
}
},
"Access-Control-Allow-Origin": {
"description": "reflected exact origin on an allowlist hit; OMITTED on a miss (C4) -- never '*'",
"schema": {
"type": "string"
}
},
"Access-Control-Allow-Private-Network": {
"description": "'true' -- answers the Local Network Access (PNA) preflight for loopback from https://wal.sh",
"schema": {
"type": "string"
}
},
"Vary": {
"description": "Origin -- caches MUST NOT mix the two origins' responses",
"schema": {
"type": "string"
}
}
}
}
},
"summary": "Receiver identity"
},
"options": {
"responses": {
"204": {
"description": "preflight OK",
"headers": {
"Access-Control-Allow-Headers": {
"description": "Content-Type, Accept, Origin",
"schema": {
"type": "string"
}
},
"Access-Control-Allow-Methods": {
"description": "GET, POST, OPTIONS",
"schema": {
"type": "string"
}
},
"Access-Control-Allow-Origin": {
"description": "reflected exact origin on an allowlist hit; OMITTED on a miss (C4) -- never '*'",
"schema": {
"type": "string"
}
},
"Access-Control-Allow-Private-Network": {
"description": "'true' -- answers the Local Network Access (PNA) preflight for loopback from https://wal.sh",
"schema": {
"type": "string"
}
},
"Vary": {
"description": "Origin -- caches MUST NOT mix the two origins' responses",
"schema": {
"type": "string"
}
}
}
}
},
"summary": "CORS preflight (no body)"
}
},
"/openapi.json": {
"get": {
"responses": {
"200": {
"description": "OpenAPI 3.1",
"headers": {
"Access-Control-Allow-Headers": {
"description": "Content-Type, Accept, Origin",
"schema": {
"type": "string"
}
},
"Access-Control-Allow-Methods": {
"description": "GET, POST, OPTIONS",
"schema": {
"type": "string"
}
},
"Access-Control-Allow-Origin": {
"description": "reflected exact origin on an allowlist hit; OMITTED on a miss (C4) -- never '*'",
"schema": {
"type": "string"
}
},
"Access-Control-Allow-Private-Network": {
"description": "'true' -- answers the Local Network Access (PNA) preflight for loopback from https://wal.sh",
"schema": {
"type": "string"
}
},
"Vary": {
"description": "Origin -- caches MUST NOT mix the two origins' responses",
"schema": {
"type": "string"
}
}
}
}
},
"summary": "This document"
},
"options": {
"responses": {
"204": {
"description": "preflight OK",
"headers": {
"Access-Control-Allow-Headers": {
"description": "Content-Type, Accept, Origin",
"schema": {
"type": "string"
}
},
"Access-Control-Allow-Methods": {
"description": "GET, POST, OPTIONS",
"schema": {
"type": "string"
}
},
"Access-Control-Allow-Origin": {
"description": "reflected exact origin on an allowlist hit; OMITTED on a miss (C4) -- never '*'",
"schema": {
"type": "string"
}
},
"Access-Control-Allow-Private-Network": {
"description": "'true' -- answers the Local Network Access (PNA) preflight for loopback from https://wal.sh",
"schema": {
"type": "string"
}
},
"Vary": {
"description": "Origin -- caches MUST NOT mix the two origins' responses",
"schema": {
"type": "string"
}
}
}
}
},
"summary": "CORS preflight (no body)"
}
},
"/sightings": {
"get": {
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"items": {
"$ref": "#/components/schemas/Sighting"
},
"type": "array"
}
}
},
"description": "the log",
"headers": {
"Access-Control-Allow-Headers": {
"description": "Content-Type, Accept, Origin",
"schema": {
"type": "string"
}
},
"Access-Control-Allow-Methods": {
"description": "GET, POST, OPTIONS",
"schema": {
"type": "string"
}
},
"Access-Control-Allow-Origin": {
"description": "reflected exact origin on an allowlist hit; OMITTED on a miss (C4) -- never '*'",
"schema": {
"type": "string"
}
},
"Access-Control-Allow-Private-Network": {
"description": "'true' -- answers the Local Network Access (PNA) preflight for loopback from https://wal.sh",
"schema": {
"type": "string"
}
},
"Vary": {
"description": "Origin -- caches MUST NOT mix the two origins' responses",
"schema": {
"type": "string"
}
}
}
}
},
"summary": "Snapshot read -- the log, newest-first"
},
"options": {
"responses": {
"204": {
"description": "preflight OK",
"headers": {
"Access-Control-Allow-Headers": {
"description": "Content-Type, Accept, Origin",
"schema": {
"type": "string"
}
},
"Access-Control-Allow-Methods": {
"description": "GET, POST, OPTIONS",
"schema": {
"type": "string"
}
},
"Access-Control-Allow-Origin": {
"description": "reflected exact origin on an allowlist hit; OMITTED on a miss (C4) -- never '*'",
"schema": {
"type": "string"
}
},
"Access-Control-Allow-Private-Network": {
"description": "'true' -- answers the Local Network Access (PNA) preflight for loopback from https://wal.sh",
"schema": {
"type": "string"
}
},
"Vary": {
"description": "Origin -- caches MUST NOT mix the two origins' responses",
"schema": {
"type": "string"
}
}
}
}
},
"summary": "CORS preflight (no body)"
},
"post": {
"requestBody": {
"content": {
"application/json": {
"schema": {
"oneOf": [
{
"$ref": "#/components/schemas/Sighting"
},
{
"items": {
"$ref": "#/components/schemas/Sighting"
},
"type": "array"
}
]
}
}
},
"required": true
},
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/IngestResult"
}
}
},
"description": "stored",
"headers": {
"Access-Control-Allow-Headers": {
"description": "Content-Type, Accept, Origin",
"schema": {
"type": "string"
}
},
"Access-Control-Allow-Methods": {
"description": "GET, POST, OPTIONS",
"schema": {
"type": "string"
}
},
"Access-Control-Allow-Origin": {
"description": "reflected exact origin on an allowlist hit; OMITTED on a miss (C4) -- never '*'",
"schema": {
"type": "string"
}
},
"Access-Control-Allow-Private-Network": {
"description": "'true' -- answers the Local Network Access (PNA) preflight for loopback from https://wal.sh",
"schema": {
"type": "string"
}
},
"Vary": {
"description": "Origin -- caches MUST NOT mix the two origins' responses",
"schema": {
"type": "string"
}
}
}
},
"400": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
},
"description": "malformed body -- typed error; the rule that fired is in error.code (R1/R2/R3/BODY_TOO_LARGE/PARSE)",
"headers": {
"Access-Control-Allow-Headers": {
"description": "Content-Type, Accept, Origin",
"schema": {
"type": "string"
}
},
"Access-Control-Allow-Methods": {
"description": "GET, POST, OPTIONS",
"schema": {
"type": "string"
}
},
"Access-Control-Allow-Origin": {
"description": "reflected exact origin on an allowlist hit; OMITTED on a miss (C4) -- never '*'",
"schema": {
"type": "string"
}
},
"Access-Control-Allow-Private-Network": {
"description": "'true' -- answers the Local Network Access (PNA) preflight for loopback from https://wal.sh",
"schema": {
"type": "string"
}
},
"Vary": {
"description": "Origin -- caches MUST NOT mix the two origins' responses",
"schema": {
"type": "string"
}
}
}
}
},
"summary": "Ingest one sighting or a batch (array)"
}
},
"/sightings/stream": {
"get": {
"responses": {
"200": {
"content": {
"text/event-stream": {}
},
"description": "SSE stream",
"headers": {
"Access-Control-Allow-Headers": {
"description": "Content-Type, Accept, Origin",
"schema": {
"type": "string"
}
},
"Access-Control-Allow-Methods": {
"description": "GET, POST, OPTIONS",
"schema": {
"type": "string"
}
},
"Access-Control-Allow-Origin": {
"description": "reflected exact origin on an allowlist hit; OMITTED on a miss (C4) -- never '*'",
"schema": {
"type": "string"
}
},
"Access-Control-Allow-Private-Network": {
"description": "'true' -- answers the Local Network Access (PNA) preflight for loopback from https://wal.sh",
"schema": {
"type": "string"
}
},
"Vary": {
"description": "Origin -- caches MUST NOT mix the two origins' responses",
"schema": {
"type": "string"
}
}
}
}
},
"summary": "Live read -- Server-Sent Events; one `event: sighting` per ingest"
},
"options": {
"responses": {
"204": {
"description": "preflight OK",
"headers": {
"Access-Control-Allow-Headers": {
"description": "Content-Type, Accept, Origin",
"schema": {
"type": "string"
}
},
"Access-Control-Allow-Methods": {
"description": "GET, POST, OPTIONS",
"schema": {
"type": "string"
}
},
"Access-Control-Allow-Origin": {
"description": "reflected exact origin on an allowlist hit; OMITTED on a miss (C4) -- never '*'",
"schema": {
"type": "string"
}
},
"Access-Control-Allow-Private-Network": {
"description": "'true' -- answers the Local Network Access (PNA) preflight for loopback from https://wal.sh",
"schema": {
"type": "string"
}
},
"Vary": {
"description": "Origin -- caches MUST NOT mix the two origins' responses",
"schema": {
"type": "string"
}
}
}
}
},
"summary": "CORS preflight (no body)"
}
}
},
"servers": [
{
"description": "loopback only (invariant I1)",
"url": "http://127.0.0.1:8127"
}
]
}