The Clearwing web UI backend (clearwing.ui.web.app, started by
clearwing serve) exposes a FastAPI server with a single real-time
WebSocket endpoint. Commit dd5f093 wired the in-process
EventBus into that WebSocket so external
consumers (dashboards, CI tailers, custom TUIs) can follow campaign /
sourcehunt / validator / disclosure / benchmark / eval progress
without polling the REST API.
This page documents every message that crosses the wire. Field types
are drawn from the dataclasses in clearwing/core/event_payloads.py
and the emit_* call sites — no "optional" marker appears here
unless the underlying payload type is genuinely T | None.
| URL | ws://<host>:<port>/ws/agent (default host/port: whatever clearwing serve binds to) |
| Subprotocol | none — plain JSON-text frames |
| Auth | none at the transport layer; access control is the deployer's responsibility |
| CORS | allow_origins=["*"] — the frontend is served from the same FastAPI app |
- Accept. Client opens the WebSocket. The server calls
websocket.accept()and subscribes handlers to the 14EventBusevent types listed in Server → client events. - Start. Client sends a
startframe to create an agent and a session id. Server replies with astartedframe. - Stream. Client sends
messageandapproveframes; server streamsagent_messageframes from the LangGraphastreamloop plus any bus-emitted events produced by the agent's tools, the sourcehunt pipeline, the validator, etc. Events are drained from an internalasyncio.Queueevery ~100 ms between client receives. - Disconnect. Either side closes. On disconnect the server unsubscribes every bus handler it registered for this connection.
Every client frame is a JSON object with a type discriminator.
Initializes an agent session. Must be sent before message or
approve (both require a live graph/config).
{
"type": "start",
"target": "10.0.0.1",
"model": "claude-sonnet-4-6",
"base_url": "https://api.anthropic.com",
"api_key": "sk-ant-..."
}target, base_url, and api_key are forwarded to
create_agent(...); they default to empty / None if omitted.
model defaults to "claude-sonnet-4-6".
Sends a user turn into the ReAct graph.
{"type": "message", "content": "scan ports on the target"}Resumes a graph that paused at an approval interrupt.
{"type": "approve", "approved": true}All bus-forwarded events share one envelope:
interface BusEnvelope<T> {
type: string; // one of the event names listed below
data: T; // payload — a plain JSON object (dataclasses are `asdict`-ed)
}Three server frames do not use this envelope because they are
emitted inline by the WebSocket handler itself rather than forwarded
from the bus — started, the streaming agent_message reply, and
the inline error frame. Their shapes are documented under
Inline server frames.
Payload serialization rules (clearwing/ui/web/app.py lines
303–317):
dict,list,str,int,float,bool,None→ passed through unchanged.- dataclass (
__dataclass_fields__) →dataclasses.asdict(...). - Anything else →
str(data).
The bus supports 17 EventType values; the WebSocket handler
forwards 14 of them. Three are deliberately not forwarded:
STATE_CHANGED, USER_INPUT, USER_COMMAND (they describe
client-driven transitions the client already knows about).
Fires whenever the agent (or any subsystem) calls
EventBus().emit_message(...).
Payload:
| Field | Type | Notes |
|---|---|---|
content |
string |
Human-readable message text. |
type |
string |
Message category — "info", "warn", "error", or other caller-defined tag. Defaults to "info". |
{
"type": "agent_message",
"data": {"content": "Enumerating services on 10.0.0.1", "type": "info"}
}Note: the inline non-bus agent_message frame produced by the
LangGraph streaming loop has a different payload — see
Inline server frames.
Fires when a tool invocation begins
(emit_tool(name, "start", data)).
| Field | Type | Notes |
|---|---|---|
tool |
string |
Tool name (e.g. "scan_ports"). |
phase |
string |
Always "start" for this event. |
data |
any |
Caller-supplied context — usually the tool's input args. |
{
"type": "tool_start",
"data": {
"tool": "scan_ports",
"phase": "start",
"data": {"target": "10.0.0.1", "ports": "1-1024"}
}
}Fires when a tool invocation finishes
(emit_tool(name, "end", data)).
| Field | Type | Notes |
|---|---|---|
tool |
string |
Tool name. |
phase |
string |
Typically "end" / "result". |
data |
any |
Tool output, truncated for transport where applicable. |
{
"type": "tool_result",
"data": {
"tool": "scan_ports",
"phase": "end",
"data": {"open": [22, 80, 443], "duration_s": 4.2}
}
}Fires whenever the agent detects a flag-like token
(emit_flag(flag, context)).
| Field | Type | Notes |
|---|---|---|
flag |
string |
The captured flag literal. |
context |
any |
Surrounding evidence — usually the file path or tool output line. |
{
"type": "flag_found",
"data": {
"flag": "flag{sql_injection_on_login}",
"context": "HTTP response body at /login?id=1'"
}
}Periodic running-cost tick (emit_cost(tokens, cost_usd)).
| Field | Type | Notes |
|---|---|---|
tokens |
integer |
Cumulative tokens for the session. |
cost_usd |
number |
Cumulative USD cost for the session. |
{"type": "cost_update", "data": {"tokens": 18432, "cost_usd": 0.27}}Fires when the guardrail / approval layer pauses on a destructive
operation and waits for a human approve frame.
| Field | Type | Notes |
|---|---|---|
prompt |
string |
Human-readable description of the pending action. |
Other implementation-specific fields (tool name, args) may accompany
prompt; consumers should preserve unknown fields.
{
"type": "approval_needed",
"data": {"prompt": "Run `msfconsole exploit/multi/http/struts2_namespace_ognl`?"}
}Fires on EventType.ERROR. Unlike the inline error frame, bus-routed
errors go through the standard {type, data} envelope.
| Field | Type | Notes |
|---|---|---|
data |
any |
Whatever the emitter passed — typically a string message or a {"message": ..., "where": ...} dict. |
{"type": "error", "data": "provider timeout after 30s"}Fires from clearwing/sourcehunt/campaign.py whenever a project in a
multi-project campaign transitions (start, finish, error). Payload
is CampaignProgressPayload.
| Field | Type | Notes |
|---|---|---|
campaign_name |
string |
|
projects_completed |
integer |
Count of targets whose state is "completed". |
projects_total |
integer |
Total targets declared in the campaign config. |
current_project |
string |
Repo currently being processed — "" between projects. |
status |
string |
One of "running", "completed", "error". |
cost_usd |
number |
Budget spent so far across the campaign. |
findings_total |
integer |
Sum of findings across all projects. |
verified_total |
integer |
Subset of findings_total that passed validator gating. |
{
"type": "campaign_progress",
"data": {
"campaign_name": "q2-oss-audit",
"projects_completed": 3,
"projects_total": 12,
"current_project": "https://github.com/FFmpeg/FFmpeg",
"status": "running",
"cost_usd": 14.82,
"findings_total": 27,
"verified_total": 9
}
}Fires at every sourcehunt pipeline transition in
clearwing/sourcehunt/runner.py: preprocess, rank, hunt,
exploit, report. Payload is SourcehuntStagePayload.
| Field | Type | Notes |
|---|---|---|
session_id |
string |
Per-run id (hex). |
repo |
string |
Target repo URL. |
stage |
string |
"preprocess" | "rank" | "hunt" | "exploit" | "report". |
status |
string |
"started" | "completed" | "degraded" | "error". |
findings_so_far |
integer |
Running count at the moment the stage emitted. |
cost_usd |
number |
Running per-session LLM cost. |
detail |
string |
Free-form stage context, e.g. "Enumerated 1842 files". |
{
"type": "sourcehunt_stage",
"data": {
"session_id": "7f3a2c91",
"repo": "https://github.com/FFmpeg/FFmpeg",
"stage": "hunt",
"status": "started",
"findings_so_far": 0,
"cost_usd": 0.0,
"detail": "1842 files"
}
}Fires from the hunter pool (clearwing/sourcehunt/pool.py) after
every per-file worker returns. Payload is HuntProgressPayload.
| Field | Type | Notes |
|---|---|---|
session_id |
string |
Matches the parent sourcehunt_stage session. |
tier |
string |
Pool tier name (e.g. "high", "medium", "deep"). |
band |
string |
File priority band within the tier. |
files_completed |
integer |
Workers in completed/error/timeout state. |
files_total |
integer |
Total files in the hunt config. |
findings_this_tier |
integer |
Findings from completed workers in this pool. |
cost_usd |
number |
Cumulative pool spend. |
budget_remaining |
number |
Remaining budget — never negative (max(0, budget − spent)). |
{
"type": "hunt_progress",
"data": {
"session_id": "7f3a2c91",
"tier": "high",
"band": "A",
"files_completed": 42,
"files_total": 180,
"findings_this_tier": 3,
"cost_usd": 1.87,
"budget_remaining": 8.13
}
}Fires from clearwing/sourcehunt/validator.py after the adversarial
second-pass validator issues a verdict. Payload is
ValidationResultPayload.
| Field | Type | Notes |
|---|---|---|
finding_id |
string |
Stable id of the finding being validated. |
axes |
object<string, boolean> |
Axis-name → pass/fail map (reachability, exploitability, impact, etc.). |
advance |
boolean |
Whether the verdict promotes the finding past the validator gate. |
severity |
string | null |
Validator-assigned severity; null if the verdict refused to rate. |
evidence_level |
string |
One of the evidence-ladder rungs — "suspicion", "static_corroboration", "crash_reproduced", "root_cause_explained", "exploit_demonstrated", "patch_validated". |
{
"type": "validation_result",
"data": {
"finding_id": "sh-7f3a2c91-0004",
"axes": {"reachability": true, "exploitability": true, "impact": false},
"advance": false,
"severity": "medium",
"evidence_level": "static_corroboration"
}
}Fires from clearwing/ui/commands/disclose.py on every disclosure
workflow transition. Payload is DisclosureUpdatePayload.
| Field | Type | Notes |
|---|---|---|
finding_id |
string |
|
action |
string |
"validated" | "rejected" | "sent" (extensible — other workflow transitions may be added). |
reviewer |
string | null |
Operator handle that triggered the action; null for automated transitions. |
days_remaining |
integer | null |
CVD-clock days left; 90 on sent, null until the clock starts. |
detail |
string |
Free-form — notes, rejection reason, or status text such as "CVD 90-day timeline started". |
{
"type": "disclosure_update",
"data": {
"finding_id": "sh-7f3a2c91-0004",
"action": "sent",
"reviewer": "rob",
"days_remaining": 90,
"detail": "CVD 90-day timeline started"
}
}Fires from clearwing/bench/ossfuzz.py after every benchmark target
finishes. Payload is BenchmarkProgressPayload.
| Field | Type | Notes |
|---|---|---|
mode |
string |
Benchmark mode name (e.g. "ossfuzz"). |
targets_completed |
integer |
Targets processed so far. |
targets_total |
integer |
Total targets in this benchmark run. |
current_project |
string |
Project of the target that just finished. |
tier_distribution |
object<string, integer> |
Tier-name → count of findings landing in that tier. |
cost_usd |
number |
Cumulative benchmark cost. |
{
"type": "benchmark_progress",
"data": {
"mode": "ossfuzz",
"targets_completed": 5,
"targets_total": 20,
"current_project": "libxml2",
"tier_distribution": {"high": 1, "medium": 2, "low": 3},
"cost_usd": 2.14
}
}Fires from clearwing/eval/preprocessing.py on every eval run —
once per run with status="running" or "cached", and once again
with "completed" or "error" when the run settles. Payload is
EvalProgressPayload.
| Field | Type | Notes |
|---|---|---|
project |
string |
Project being evaluated. |
config_name |
string |
Named config under test. |
run_index |
integer |
0-based run ordinal within this config. |
runs_total |
integer |
Configured runs-per-config. |
configs_completed |
integer |
Configs finished before the current one. |
configs_total |
integer |
Total configs in the eval. |
status |
string |
"running" | "cached" | "completed" | "error". |
cost_usd |
number |
USD cost for this run — 0.0 for "running"/"cached"/"error". |
{
"type": "eval_progress",
"data": {
"project": "FFmpeg",
"config_name": "deep-reasoning-v2",
"run_index": 2,
"runs_total": 5,
"configs_completed": 1,
"configs_total": 4,
"status": "completed",
"cost_usd": 0.84
}
}These frames originate in the WebSocket handler itself rather than
the EventBus. They share the top-level type discriminator but
their fields sit alongside type, not nested under data (with one
exception — the inline streaming agent_message and inline error
frames do nest under data).
Sent exactly once in response to a client start frame.
{
"type": "started",
"session_id": "a1b2c3d4",
"target": "10.0.0.1",
"model": "claude-sonnet-4-6"
}Produced by the LangGraph streaming loop after each message turn.
Carries only the final assistant text; intermediate tool turns reach
the client as tool_start / tool_result bus frames.
{
"type": "agent_message",
"data": {"content": "I scanned ports 1-1024; 22, 80, and 443 are open."}
}Produced when the message or approve handler catches an
exception while driving the graph.
{
"type": "error",
"data": {"message": "ProviderTimeout: no response in 30s"}
}For completeness — these EventType values exist on the bus but the
/ws/agent handler does not subscribe to them, so they never
reach WebSocket clients:
state_changeduser_inputuser_command
If you need them, subscribe directly in-process via
EventBus().subscribe(...).