Web API

April 20, 2026 · View on GitHub

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.

Connection

URLws://<host>:<port>/ws/agent (default host/port: whatever clearwing serve binds to)
Subprotocolnone — plain JSON-text frames
Authnone at the transport layer; access control is the deployer's responsibility
CORSallow_origins=["*"] — the frontend is served from the same FastAPI app

Lifecycle

  1. Accept. Client opens the WebSocket. The server calls websocket.accept() and subscribes handlers to the 14 EventBus event types listed in Server → client events.
  2. Start. Client sends a start frame to create an agent and a session id. Server replies with a started frame.
  3. Stream. Client sends message and approve frames; server streams agent_message frames from the LangGraph astream loop plus any bus-emitted events produced by the agent's tools, the sourcehunt pipeline, the validator, etc. Events are drained from an internal asyncio.Queue every ~100 ms between client receives.
  4. Disconnect. Either side closes. On disconnect the server unsubscribes every bus handler it registered for this connection.

Client → server messages

Every client frame is a JSON object with a type discriminator.

start

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".

message

Sends a user turn into the ReAct graph.

{"type": "message", "content": "scan ports on the target"}

approve

Resumes a graph that paused at an approval interrupt.

{"type": "approve", "approved": true}

Server → client envelope

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).

Server → client events

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).

agent_message

Fires whenever the agent (or any subsystem) calls EventBus().emit_message(...).

Payload:

FieldTypeNotes
contentstringHuman-readable message text.
typestringMessage 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.

tool_start

Fires when a tool invocation begins (emit_tool(name, "start", data)).

FieldTypeNotes
toolstringTool name (e.g. "scan_ports").
phasestringAlways "start" for this event.
dataanyCaller-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"}
  }
}

tool_result

Fires when a tool invocation finishes (emit_tool(name, "end", data)).

FieldTypeNotes
toolstringTool name.
phasestringTypically "end" / "result".
dataanyTool output, truncated for transport where applicable.
{
  "type": "tool_result",
  "data": {
    "tool": "scan_ports",
    "phase": "end",
    "data": {"open": [22, 80, 443], "duration_s": 4.2}
  }
}

flag_found

Fires whenever the agent detects a flag-like token (emit_flag(flag, context)).

FieldTypeNotes
flagstringThe captured flag literal.
contextanySurrounding 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'"
  }
}

cost_update

Periodic running-cost tick (emit_cost(tokens, cost_usd)).

FieldTypeNotes
tokensintegerCumulative tokens for the session.
cost_usdnumberCumulative USD cost for the session.
{"type": "cost_update", "data": {"tokens": 18432, "cost_usd": 0.27}}

approval_needed

Fires when the guardrail / approval layer pauses on a destructive operation and waits for a human approve frame.

FieldTypeNotes
promptstringHuman-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`?"}
}

error

Fires on EventType.ERROR. Unlike the inline error frame, bus-routed errors go through the standard {type, data} envelope.

FieldTypeNotes
dataanyWhatever the emitter passed — typically a string message or a {"message": ..., "where": ...} dict.
{"type": "error", "data": "provider timeout after 30s"}

campaign_progress

Fires from clearwing/sourcehunt/campaign.py whenever a project in a multi-project campaign transitions (start, finish, error). Payload is CampaignProgressPayload.

FieldTypeNotes
campaign_namestring
projects_completedintegerCount of targets whose state is "completed".
projects_totalintegerTotal targets declared in the campaign config.
current_projectstringRepo currently being processed — "" between projects.
statusstringOne of "running", "completed", "error".
cost_usdnumberBudget spent so far across the campaign.
findings_totalintegerSum of findings across all projects.
verified_totalintegerSubset 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
  }
}

sourcehunt_stage

Fires at every sourcehunt pipeline transition in clearwing/sourcehunt/runner.py: preprocess, rank, hunt, exploit, report. Payload is SourcehuntStagePayload.

FieldTypeNotes
session_idstringPer-run id (hex).
repostringTarget repo URL.
stagestring"preprocess" | "rank" | "hunt" | "exploit" | "report".
statusstring"started" | "completed" | "degraded" | "error".
findings_so_farintegerRunning count at the moment the stage emitted.
cost_usdnumberRunning per-session LLM cost.
detailstringFree-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"
  }
}

hunt_progress

Fires from the hunter pool (clearwing/sourcehunt/pool.py) after every per-file worker returns. Payload is HuntProgressPayload.

FieldTypeNotes
session_idstringMatches the parent sourcehunt_stage session.
tierstringPool tier name (e.g. "high", "medium", "deep").
bandstringFile priority band within the tier.
files_completedintegerWorkers in completed/error/timeout state.
files_totalintegerTotal files in the hunt config.
findings_this_tierintegerFindings from completed workers in this pool.
cost_usdnumberCumulative pool spend.
budget_remainingnumberRemaining 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
  }
}

validation_result

Fires from clearwing/sourcehunt/validator.py after the adversarial second-pass validator issues a verdict. Payload is ValidationResultPayload.

FieldTypeNotes
finding_idstringStable id of the finding being validated.
axesobject<string, boolean>Axis-name → pass/fail map (reachability, exploitability, impact, etc.).
advancebooleanWhether the verdict promotes the finding past the validator gate.
severitystring | nullValidator-assigned severity; null if the verdict refused to rate.
evidence_levelstringOne 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"
  }
}

disclosure_update

Fires from clearwing/ui/commands/disclose.py on every disclosure workflow transition. Payload is DisclosureUpdatePayload.

FieldTypeNotes
finding_idstring
actionstring"validated" | "rejected" | "sent" (extensible — other workflow transitions may be added).
reviewerstring | nullOperator handle that triggered the action; null for automated transitions.
days_remaininginteger | nullCVD-clock days left; 90 on sent, null until the clock starts.
detailstringFree-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"
  }
}

benchmark_progress

Fires from clearwing/bench/ossfuzz.py after every benchmark target finishes. Payload is BenchmarkProgressPayload.

FieldTypeNotes
modestringBenchmark mode name (e.g. "ossfuzz").
targets_completedintegerTargets processed so far.
targets_totalintegerTotal targets in this benchmark run.
current_projectstringProject of the target that just finished.
tier_distributionobject<string, integer>Tier-name → count of findings landing in that tier.
cost_usdnumberCumulative 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
  }
}

eval_progress

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.

FieldTypeNotes
projectstringProject being evaluated.
config_namestringNamed config under test.
run_indexinteger0-based run ordinal within this config.
runs_totalintegerConfigured runs-per-config.
configs_completedintegerConfigs finished before the current one.
configs_totalintegerTotal configs in the eval.
statusstring"running" | "cached" | "completed" | "error".
cost_usdnumberUSD 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
  }
}

Inline server frames

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).

started

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"
}

agent_message (inline)

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."}
}

error (inline)

Produced when the message or approve handler catches an exception while driving the graph.

{
  "type": "error",
  "data": {"message": "ProviderTimeout: no response in 30s"}
}

Events that are emitted but not forwarded

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_changed
  • user_input
  • user_command

If you need them, subscribe directly in-process via EventBus().subscribe(...).