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];
}

call-structure.png

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.

  1. What address and path does a client POST to? (POST http://127.0.0.1:8127/sightings, loopback only – see 5)
  2. What is the record called, and what is its id field? (a sighting; the id field is id – never trace_id; see 4)
  3. Which field is a number, and which is an enum? (duration is integer ms; status is one of ok / error / unknown)
  4. What is the default/coercion target for an unrecognized status? (unknownnever ok; an unknown outcome MUST NOT render green – see 6.3)
  5. Where do attributes go, and what is the host key? (one bag, attrs; the hostname is attrs.host – see 4.2)
  6. What does the client do if the receiver is not running? (log once, continue; MUST NOT retry forever, MUST NOT spool – see 5.5)
  7. 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 3000 ms, 1500 ms 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 400 from 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 of GET /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 short conversation_id is 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~/~start pass through unchanged when present (count defaults to 1 client-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, and start~/~count when present: MUST be a finite number, else 400 (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 Origin matches the allowlist, Access-Control-Allow-Origin MUST reflect that exact origin;
  • on a miss, Access-Control-Allow-Origin MUST be omitted entirely (never *, never the first-allowlisted entry as a silent fallback – both create asymmetric browser-vs-curl behavior);
  • Vary: Origin MUST be set on every CORS-affected response;
  • Access-Control-Allow-Private-Network: true MUST 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;
  • attrs is 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 /info within a short window (2s). Three outcomes, three distinct states (see 10): no/failed response → demo (no receiver; synthetic sightings, badged); a response whose version is 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 polling GET /sightings for the snapshot / cold start.
  • Render columns. id (first 9 chars), name, service, duration (ms→ms/s/m), status, and the attrs projections (host as origin, sha).
  • Status colour – load-bearing. ok → green, error → red, unknown → amber/grey. unknown MUST 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":100 does NOT render within the probe window (2s default).
  • The dashboard renders a status of unknown as green (it MUST be amber/grey).
  • The receiver accepts POST /sightings from a non-loopback origin.
  • A compliant client sees its sighting at GET /sightings but 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-deep attrs, non-finite duration) crashes the receiver, drops the connection, or returns 5xx instead of 400 (see 6.7).
  • GET /sightings ever 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

  1. 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 include https://wal.sh so the production dashboard can read your receiver, and MUST send Access-Control-Allow-Private-Network: true.
  2. 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).
  3. 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 – unknown is amber, never green. This is the real acceptance test: the production dashboard reading your from-scratch receiver.
  • Optional: codegen a client from your /openapi.json and 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"
    }
  ]
}