Skip to content

Ports and wire contracts ​

One process, two ports — and neither is 5525.

PortWhat it carries
5526Coordination — the web UI, the plan/agent/memory API, the SignalR hub, the MCP endpoint
5527Stoat's run receiver — ingestion, the run list and detail, exports, the Event Model wire
5525Not 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-setup and its --verify-command
  • stoat gate exec and stoat gate run — the commands they wrap
  • stoat 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:

bash
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/pingThe 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/ingestBatches of monitor events. The only ingestion path.
GET /api/runsThe 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/runsEject 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-modelThe 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/halvesEach 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.

Licensed under the Business Source License 1.1.