Ports and wire contracts
One process, two ports — and neither is 5525.
| Port | What it carries |
|---|---|
| 5526 | Coordination — the web UI, the plan/agent/memory API, the SignalR hub, the MCP endpoint |
| 5527 | Stoat's run receiver — ingestion, the run list and detail, exports, the Event Model wire |
| 5525 | Not Stoat's. bobcat ui, Bobcat's free run board and Event Model canvas |
Both are defaults. ASPNETCORE_URLS replaces them, so list both addresses in it if you set it; a --urls argument on the command line has no effect. Stoat:RunReceiverUrl moves the receiver alone. Every route is served on both ports. See Configuration.
Why not 5525
5525 is the address every Bobcat publisher sends to by default, and it belongs to bobcat ui: the run board and the Event Model canvas, free, in Bobcat's own tool. Stoat does not bind it, so one machine can run both.
Stoat still needs the runs: a test-run-gate resolves from them, and the run MCP tools (list_runs, failing_tests, await_run_completion) read them. So it has its own receiver on 5527, and a run reaches both because BOBCAT_MONITOR_URL is a list: a Bobcat publisher sends to every URL in it. Because Stoat receives runs itself, gates and the run tools work whether or not anyone started bobcat ui.
Who puts 5527 in the list
Everything Stoat launches that might run a Bobcat suite:
stoat run-plan— each agent, its--agent-setupand its--verify-commandstoat gate execandstoat gate run— the commands they wrapstoat runner— the resident runner, with Stoat first, because a resident runner takes its commands from the first URL and publishes to all of them
Each keeps whatever BOBCAT_MONITOR_URL it inherited and appends the receiver; an unset variable becomes http://localhost:5525,http://localhost:5527, because setting the variable replaces a publisher's 5525 default.
A suite you run yourself, outside those, publishes only to 5525 unless you list Stoat as well:
export BOBCAT_MONITOR_URL="http://localhost:5525,http://localhost:5527"Bobcat 0.60.4 and earlier read one URL
Those versions read the whole variable as one URL, and a list is not one: the publisher fails to start. So a checkout that pins a Bobcat package at 0.60.4 or earlier (in Directory.Packages.props or a project file) is handed Stoat's receiver alone — its gates keep working, and its runs miss bobcat ui until it upgrades. Don't export a list by hand in such a checkout either.
Moving the receiver
Stoat:RunReceiverUrl in configuration (Stoat__RunReceiverUrl in the environment), or STOAT_RUN_RECEIVER_URL. The launchers read STOAT_RUN_RECEIVER_URL too, so setting that one variable moves both ends; stoat runner --url overrides it for the runner.
The receiver's HTTP surface
The run routes are the ones bobcat ui serves on 5525, which is what lets one publisher feed both.
These are public wire contracts: Bobcat's publisher, bobcat ui and other tools rely on their shapes.
| Route | |
|---|---|
GET /api/ping | The probe. A publisher calls it once at startup with a tight timeout and goes no-op for the whole run if nothing answers. |
POST /api/ingest | Batches of monitor events. The only ingestion path. |
GET /api/runs | The run list. Carries the tag, outcome counts and scenario progress, and repositorySlug, the run's owner/name read from its checkout's git remote (null for a path this box can't resolve); takes ?tag=. |
GET /api/runs/{runId} | Per-scenario detail, including lanes, recycles, worker faults, stalls, progress and touched-type evidence. Each scenario carries its declared and planned steps; each step its plan position, input-value spans, cells, columns, logs, diagnostics and — when it threw — the exception with filtered frames. |
GET /api/runs/{runId}/export | ?format=ctrf (default), junit, or ndjson — the raw archive. |
DELETE /api/runs/{runId} | Eject one run. |
DELETE /api/runs | Eject a set, narrowed by ?olderThan= / ?exceptRunId= and by ?repository={owner/name} or ?unattributed=true (the board's repository filter), returning the ids it actually took. |
GET /api/event-model | The merged Event Model descriptor. |
PUT /api/event-model/{source} | A push. Names its source and replaces only that source — see below. PUT /api/event-model is the default source. Optional root, repository and branch query parameters say where the producer pushed from; a half from another repository or checkout than the newest is flagged. A source name is letters, digits, - and _. |
GET /api/event-model/halves | Each producer's half: when it was last pushed, and whether it is flagged stale (more than Stoat:EventModelStaleAfterDays, default 7, older than the newest half of its model). A flag, never a drop. |
DELETE /api/event-model/{source} | A person removes one half. Its producer's next push brings it back. |
PUT /api/event-model-warnings[/{source}] | What wolverine scaffold --json or bobcat import-event-model --json could not decide; stoat warnings push sends it. A push replaces its source's warnings. GET reads them all. |
POST /api/event-model-warnings/dismissals · DELETE …/dismissals/{key} | A person's dismissal of one warning, and taking it back. A push that no longer reports the warning drops its dismissal. |
These are open, like ingestion: Bobcat and Wolverine are MIT and call them.
Plus, on the coordination side: the SignalR hub at /api/messages, MCP at /api/mcp, and two open routes the web UI reads: GET /api/license (whether this caller is on the server's own machine, and what the server's licence is) and GET /api/about (the version and the same licence). See Licence and version.
Ejection is not deletion
An ejected run (by DELETE, or by Monitor:RetentionRuns) still has its NDJSON archive on disk; it moves to ejected/. Only the age policy (Monitor:RetentionDays) ever deletes a file.
A live run is never ejected automatically: its publisher's next event would bring it straight back. An orphaned run, whose publisher has gone, can be.
One model, several producers
A model has producers compiled into different assemblies: the running application knows its Wolverine chains and its store's projections, and a Bobcat spec assembly knows the slices its scenarios declare. Neither can see the other.
So a push names its source and replaces only that source, and GET serves the merge. The merge is keyed on the model name, which is why [assembly: EventModelName("…")] in a spec assembly matters: the sources must agree on the name, or they show as separate models instead of one.
A push naming a different model is accepted and becomes the current model: GET then merges only the halves naming it, and the halves naming the old model stay on disk, unmerged, until a push names that model again. That is why a Bobcat spec run withholds a half whose model name differs from the one the receiver is serving: pushing it would hide the application's half rather than join it.
The publisher's side
Everything a Bobcat run emits (the events, the transport, the environment variables) is documented in Bobcat's own repository, because that is where it ships:
Bobcat references nothing in Stoat, and Stoat references nothing in Bobcat. The wire shape is the contract, so a Bobcat upgrade needs no Stoat upgrade to keep publishing, and the reverse.
Runs received on another server
By default, test-run gates read the runs Stoat's own receiver holds. To have gates read runs from a run console on another server instead, set Stoat:BobcatUrl to its address; Stoat then polls its GET /api/runs every Stoat:BobcatPollSeconds (default 15). The run MCP tools still read Stoat's own receiver.