Skip to content

WebSocket Events

Ghost uses WebSocket connections to deliver real-time updates instantly, rather than making the frontend repeatedly ask “has anything changed?” every few seconds. Think of it like a live news ticker — once the connection is established, events flow continuously without any polling delay. Every time a new HTTP request is captured, a device connects, a security finding is detected, or a setting changes, the frontend knows about it within milliseconds.

Ghost maintains two separate WebSocket connections, each serving a different purpose:

ConnectionEndpointPurposeClients
Main HubGET /ws?token=<bearer-token>All real-time UI events — flows, sessions, proxy status, devices, findings, rules, addons, artifactsReact frontend (one or more browser tabs)
Extension HubGET /ws/extension?token=<bearer-token>Browser extension protocol — capture events flowing in, action/inject commands flowing outChrome/Firefox extension (single client at a time)

The main hub is a one-to-many broadcast system (the server sends events to all connected frontends), while the extension hub is a one-to-one bidirectional channel (the extension sends capture data to Ghost, and Ghost sends action commands back to the extension).


GET /ws?token=<bearer-token>

The authentication token is passed as a query parameter (not a header) because the browser’s native WebSocket API doesn’t support custom headers during the initial handshake. The token is the same bearer token used for all REST API calls.

Security details:

  • Token comparison uses crypto/subtle.ConstantTimeCompare — a cryptographic function that takes the same amount of time regardless of how many characters match. This prevents timing attacks where an attacker could guess the token one character at a time by measuring response speed.
  • If the token is missing or wrong, the server returns HTTP 401 and the WebSocket connection is never established.
  • The WebSocket upgrader’s CheckOrigin function always returns true, meaning connections are accepted from any origin. This is safe because the token provides authentication.

Upgrader configuration:

SettingValueWhat it means
Read buffer size1,024 bytesHow much memory is allocated for reading incoming messages from clients
Write buffer size4,096 bytesHow much memory is allocated for writing outgoing messages to clients

Every message sent over the WebSocket follows this exact JSON structure:

{
"type": "flow.created",
"payload": { ... },
"timestamp": 1709913600000
}
FieldTypeDescription
typestringThe event name, using dot notation for most events (e.g., flow.created) or colon notation for attacker events (e.g., attacker:started). This tells the frontend which store should handle the event.
payloadobject or nullThe event data. The structure depends on the event type — it could be a full DTO (like a complete flow summary), a partial update (like just an ID), or null for events that carry no data.
timestampnumberUnix milliseconds — the number of milliseconds since January 1, 1970. For example, 1709913600000 represents a specific moment in March 2024. The frontend uses this for ordering events and detecting stale data.

The hub is the central message dispatcher that sits between the Go backend and all connected frontend clients. When any part of the backend needs to notify the frontend (a new flow was captured, a device connected, a setting changed), it sends a message to the hub, and the hub broadcasts it to every connected client.

Internal channel capacities:

ChannelBuffer SizePurpose
Register16Queue for new client connections waiting to be added to the hub’s client list
Unregister16Queue for disconnecting clients waiting to be removed
Broadcast256Queue for outgoing events waiting to be sent to all clients

These are buffered Go channels, meaning they can hold that many messages before blocking. The broadcast buffer of 256 means the hub can queue up to 256 events before the sender has to wait — this prevents brief traffic spikes from slowing down the proxy.

Non-blocking broadcast with slow client disconnection:

When the hub sends an event to a client, it uses a non-blocking write. If a client’s personal send buffer is full (meaning the client is too slow to keep up — perhaps the user’s browser tab is in the background or their network is congested), the hub doesn’t wait. Instead, it immediately disconnects that slow client. This design choice protects the entire system: one slow client can never block events from reaching other clients or back-pressure the proxy.

Each connected WebSocket client (typically a browser tab running the Ghost frontend) has its own goroutine pair — one for reading, one for writing. Here’s how the connection is maintained:

Timing constants:

ConstantValuePurpose
Write wait10 secondsMaximum time allowed to write a single message to the client. If the write takes longer than this (network congestion, frozen tab), the connection is closed.
Pong wait60 secondsHow long the server waits for a pong response after sending a ping. If no pong arrives within 60 seconds, the client is considered dead and disconnected.
Ping period54 secondsHow often the server sends a ping to the client. This is deliberately shorter than the pong wait (54 < 60) to ensure there’s always time for the pong to arrive before the deadline.
Max message size512 bytesMaximum size of messages the server will accept FROM the client. The main WebSocket is primarily server-to-client, so client messages are small (just pong responses).
Send buffer size256Each client has a personal send buffer that can hold up to 256 pending events. If this buffer fills up, the hub disconnects the client (see non-blocking broadcast above).

Heartbeat mechanism:

The server sends a WebSocket ping frame every 54 seconds. The client’s browser automatically responds with a pong frame (this is handled by the WebSocket protocol itself, not application code). If the server doesn’t receive a pong within 60 seconds, it assumes the connection is dead — the user closed the tab, their network dropped, or their computer went to sleep — and cleans up the connection.

This heartbeat serves two purposes:

  1. Dead connection detection — Without it, a connection that silently dropped (cable unplugged, laptop lid closed) would remain in the hub’s client list forever, wasting memory and broadcast effort.
  2. Keep-alive — Some network infrastructure (proxies, load balancers, firewalls) automatically close idle connections after a timeout. The regular ping/pong traffic prevents the connection from being considered idle.

Frontend reconnection:

When the frontend detects a WebSocket disconnection, it automatically reconnects using exponential backoff: wait 1 second, then 2 seconds, then 4, then 8, doubling each time up to a maximum of 30 seconds. Once reconnection succeeds, the backoff timer resets to 1 second. During disconnection, the frontend falls back to polling the REST API every 3 seconds to avoid missing critical updates.


Ghost defines 34 event type constants in the backend. Each event is broadcast to all connected main WebSocket clients. The events are grouped by the subsystem that produces them.

These events fire every time HTTP traffic is captured, modified, or removed. They are the most frequent events in Ghost — a busy application might generate hundreds of flow.created events per minute.

EventPayloadWhen it fires
flow.createdFlowSummaryDTO — a lightweight summary of the captured request/response, including method, URL, status code, timing, size, tags, and content type. Does NOT include full headers or bodies (those are fetched on demand via REST).A new HTTP request/response pair has been captured by the proxy, imported from a file, or generated by a compose/replay action.
flow.updated{ id, tags, notes } — only the fields that changed, not the entire flow.A user added/changed tags or notes on a flow, or the system auto-tagged it (e.g., a security finding tagged a flow as security:exposure).
flow.deleted{ id } — the ULID of the deleted flow.A flow was deleted by the user, or removed as part of a session deletion or auto-purge.
flow.breakpoint{ flow_id, phase, rule_id } — identifies which flow is paused, at what phase (request or response), and which breakpoint rule caught it.A flow matched an active breakpoint rule and is now paused, waiting for the user to inspect and modify it before allowing it to continue.
flow.resumed{ flow_id, action } — the action is either “continue” (send as-is or with modifications) or “drop” (discard the flow).A previously paused flow was released by the user from the breakpoint editor.

Why flow.created uses a summary DTO: Sending full request/response bodies over the WebSocket for every captured flow would be extremely wasteful. A single API response body could be megabytes, and the user only inspects a tiny fraction of captured flows. The summary contains just enough information to render the flow list row (method, URL, status, size, timing), and the full details are fetched via REST only when the user clicks on a specific flow.

EventPayloadWhen it fires
session.createdSessionDTO — includes id, name, description, created_at, and flow_count (initially 0).A new capture session was created via the REST API or automatically when the proxy starts without an active session.
session.updatedSessionDTO — the full updated session object.A session was renamed or its description was changed.
session.deleted{ id } — the ULID of the deleted session.A session was deleted. Note: this triggers cascading deletion of all flows, WebSocket frames, conversations, interactions, journeys, security findings, and other data associated with that session.
EventPayloadWhen it fires
proxy.startedProxyStatusDTO — includes running (true), port, address, session_id, capturing (computed), and network_available.The MITM proxy listener has started and is ready to accept connections.
proxy.stopped{} — empty payload.The proxy has been shut down. System proxy settings have been restored, VPN state has been cleaned up, and the listener is closed.
proxy.statusProxyStatusDTO — same structure as proxy.started, used for status changes that aren’t start/stop (like network availability changes).The proxy’s operational status changed — for example, the network went down and came back up, or the system proxy was re-applied after VPN drift.
proxy.throttleThrottleProfile (when throttling is active) or null (when throttling is cleared). ThrottleProfile includes download_kbps, upload_kbps, and latency_ms.Network throttling was enabled with a preset or custom profile, or throttling was turned off.

Addons are user-written JavaScript scripts that run inside Ghost’s sandboxed JS engine (goja) and can inspect or modify traffic as it flows through the proxy.

EventPayloadWhen it fires
addon.createdAddonDTO — includes id, name, code, enabled status, priority, created_at, and updated_at.A new addon script was saved via the REST API.
addon.updatedAddonDTO — the full updated addon, including new code if it was changed.An addon’s code, name, enabled status, or priority was changed. The addon’s JS VM is hot-reloaded (old VM destroyed, new VM created with fresh code).
addon.deleted{ id } — the ULID of the deleted addon.An addon was removed. Its JS VM is unloaded and its in-memory store is cleared.
addon.log{ addon_id, level, message, timestamp } — level is one of “info”, “warn”, or “error”.An addon called ghost.log(), ghost.warn(), or ghost.error() during request/response processing. These are displayed in the addon’s console panel in the UI.

These events are generated when the browser extension captures user interactions or changes state. They flow from the extension → Extension Hub → Go backend → Main Hub → all frontend clients. This means the React frontend sees extension activity in real-time, even though the extension communicates on a completely separate WebSocket connection.

EventPayloadWhen it fires
extension.connected{ tab_url } — the URL of the currently active browser tab.The Chrome/Firefox extension established its WebSocket connection to Ghost. The frontend uses this to show the extension status indicator.
extension.disconnected{} — empty payload.The extension’s WebSocket connection was closed (browser closed, extension disabled, or network issue).
extension.interactionInteractionDTO — includes type (click, input, submit, etc.), selector, page URL, timestamp, and metadata.The extension captured a user interaction in the browser — a click, form input, navigation, hover, or other DOM event.
extension.tab_switch{ tab_url } — the URL the user switched to.The user switched to a different browser tab. Ghost uses this to correlate traffic with the active page.
extension.console_error{ message, source, line } — the error message, the source file URL, and the line number where the error occurred.A JavaScript error was logged to the browser’s console on the active page.
extension.navigation{ url, type } — the destination URL and the navigation type (e.g., link click, form submission, back/forward).The user navigated to a new page in the browser.

Journey events track the lifecycle of user journey recordings — from start to step-by-step capture to completion. The frontend uses these to update the recording pill in the command bar and the journey panel in real-time.

EventPayloadWhen it fires
journey:started{ journey_id, session_id, name } — the ULID of the new journey, the session it belongs to, and the user-given or auto-generated name.A journey recording was started via the REST API or the AI agent. The frontend adds the journey to the list and shows the recording pill in the command bar.
journey:step_added{ journey_id, step_order, type, flow_id?, interaction_id? } — the step’s position in the sequence, its type (action/flow/interaction), and optional references to the flow or interaction that created it.A new step was added to an active recording. This fires for every HTTP flow captured by the proxy during recording, and for every browser interaction sent by the extension. The frontend increments the step counter on the recording pill and, if the journey detail view is open, appends the step to the timeline.
journey:completed{ journey_id, flow_count, interaction_count, duration_ms } — the final counts and total recording duration in milliseconds.A journey recording was stopped. The frontend transitions the journey status to “completed”, updates the final counts, and hides the recording pill.
journey:deleted{ journey_id } — the ULID of the deleted journey.A journey was deleted via the REST API. The frontend removes it from the list and clears the detail view if it was selected.

Map rules let users modify traffic on the fly (redirect URLs, replace headers, swap response bodies). Injection rules let users inject JavaScript into web pages. Both types of rules broadcast events when they’re created, changed, or removed, so the frontend can update its rules panel in real-time without re-fetching.

EventPayloadWhen it fires
rule.createdMapRuleDTO — the full rule definition including match pattern, action type, and replacement values.A new map rule was created.
rule.updatedMapRuleDTO — the full updated rule.A map rule was modified (pattern changed, action changed, enabled/disabled).
rule.deleted{ id } — the ULID of the deleted rule.A map rule was removed.
injection_rule.createdInjectionRuleDTO — includes URL pattern, script content, position, and enabled status.A new JavaScript injection rule was created.
injection_rule.updatedInjectionRuleDTO — the full updated injection rule.An injection rule was modified.
injection_rule.deleted{ id } — the ULID of the deleted rule.An injection rule was removed.

These events track mobile devices (iOS simulators, Android emulators, physical devices) that Ghost can inspect through its mobile inspector feature.

EventPayloadWhen it fires
device.discoveredDeviceDTO — includes device ID, name, platform, OS version, connection status, and screen dimensions.Ghost’s device discovery process found a new device — an iOS simulator was launched, an Android emulator started, or a physical device was connected via USB. Discovery runs on a 5-second interval for Android and 15-second interval for iOS.
device.updatedDeviceDTO — the full updated device information.A device’s properties changed — for example, it finished booting, its screen dimensions were detected, or WDA (WebDriverAgent) connected.
device.connectedDeviceDTO — the device with its connection status now showing as connected.The inspector successfully established a control connection to the device (via atx-agent for Android or WDA for iOS). Screenshots and element hierarchy are now available.
device.disconnected{ id } — the device identifier.The inspector’s control connection to the device was lost — the device was turned off, the emulator/simulator was closed, or the USB cable was disconnected.
device.removed{ id } — the device identifier.A device completely disappeared from the discovery scan. Unlike disconnected (which means the control channel dropped but the device might still be there), removed means the device is no longer visible at all.

Security findings are detected by Ghost’s security interceptor (passive analysis of traffic patterns) and by the AI agent during security scanning.

EventPayloadWhen it fires
finding.createdSecurityFindingDTO — includes 18 fields: id, session_id, flow_id, title, description, severity (critical/high/medium/low/info), type, CWE ID, OWASP category, status, confidence, source, endpoint, dedup_key, evidence, remediation, created_at, updated_at.A new security vulnerability or misconfiguration was detected. The finding is deduplicated by type+endpoint, so the same issue on the same endpoint won’t create duplicate findings.
finding.updatedSecurityFindingDTO — the full updated finding.A finding’s status was changed (e.g., from “open” to “confirmed”, “false_positive”, or “resolved”).
finding.deleted{ id } — the ULID of the deleted finding.A finding was manually deleted by the user.

Critical and high severity findings trigger toast notifications in the frontend UI, ensuring the user notices important security issues even if they’re not looking at the security panel.

Frida is a dynamic instrumentation toolkit that Ghost can control to bypass SSL pinning, detect root/jailbreak checks, and inject custom scripts into running mobile applications.

EventPayloadWhen it fires
frida.session.started{ key, device_id, app, type } — the session key is formatted as deviceID:app:type, identifying the exact device, application, and session type (attach, spawn, or custom).A Frida session was successfully attached to or spawned on a mobile application.
frida.session.stopped{ key } — the session key that was stopped.A Frida session was detached — either the user stopped it, the target app crashed, or the device was disconnected.
frida.output{ key, line, timestamp } — a single line of console output from the Frida script, with the session key identifying which session produced it.A Frida script running inside a mobile app produced console output (via console.log(), send(), or hook logging). These are stored in a 1,000-line ring buffer per session.

The attacker is Ghost’s built-in fuzzing tool that sends many variations of a request to test for vulnerabilities. Attacker events use colon notation (:) instead of dot notation (.) — this is an intentional distinction in the codebase.

EventPayloadWhen it fires
attacker:started{ total_requests } — the total number of requests that will be sent (number of payloads x number of insertion points).An attack run has begun. The frontend uses the total to calculate progress percentage.
attacker:progress{ payload, status, length, time_ms, matched } — the specific payload that was sent, the HTTP status code received, the response body length, how long the request took in milliseconds, and whether any match rules triggered.One request in the attack run completed. These events fire rapidly — one per request — and the frontend adds each result to its results table in real-time.
attacker:completed{ total, completed, matched, duration_ms } — final summary: how many were planned, how many actually completed, how many matched at least one rule, and total wall-clock time.The attack run finished (all requests sent, or the user stopped it early).
attacker:error{ message } — a human-readable error description.The attack run failed to start or encountered a fatal error (network unreachable, invalid configuration, etc.).

Artifacts are files generated by Ghost — exported sessions (HAR, JSON, CSV, Postman, HTML), bug reports, and PoC packages. When a large export exceeds 20MB, it’s automatically saved as an artifact for later download instead of being sent directly in the HTTP response.

EventPayloadWhen it fires
artifact.createdArtifactSummaryDTO — includes id, name, format, size, session_id, and created_at.A new artifact was saved — either automatically (large export) or explicitly (bug report, PoC export).
artifact.deleted{ id } — the ULID of the deleted artifact.An artifact was removed by the user.

Ghost can capture WebSocket traffic between the browser and servers (not to be confused with Ghost’s own WebSocket connections). When the proxy intercepts an HTTP Upgrade handshake, it transparently proxies WebSocket frames and broadcasts each frame as an event.

EventPayloadWhen it fires
ws.frameWSFrameDTO — includes the parent flow ID (which HTTP flow initiated the WebSocket upgrade), direction (client-to-server or server-to-client), opcode (text, binary, close, ping, pong), payload data, payload length, timestamp, and whether the payload was truncated (payloads over 1MB are truncated in storage but forwarded in full).A WebSocket frame was captured flowing through the proxy. Text frames, binary frames, and control frames (close, ping, pong) are all captured.

GET /ws/extension?token=<bearer-token>

The extension WebSocket is fundamentally different from the main WebSocket. While the main hub broadcasts to many clients, the extension hub is designed for exactly one client — the browser extension. If a second extension tries to connect, the first one is disconnected.

AspectMain HubExtension Hub
ClientsMany (multiple browser tabs)One (single extension instance)
DirectionServer → clients (broadcast)Bidirectional (extension sends captures, server sends commands)
Max message size512 bytes (clients only send pongs)1 MB (extension sends rich interaction data, DOM snapshots)
Send buffer256 per client64 (single client)
DeduplicationNone neededRing buffer dedup prevents duplicate events when extension reconnects
On connectNothing specialServer sends ghost.welcome message with current session and configuration

The extension hub uses a structured protocol where both sides send typed messages. This is different from the main hub where only the server sends events.

These are messages the browser extension sends to Ghost:

Message TypePayloadPurpose
ext.hello{ version, tab_url } — the extension’s version number and the URL of the currently active tab.Initial handshake after connection. Ghost uses this to verify compatibility and know which page the user is on. Triggers extension.connected on the main hub.
ext.tab_switch{ tab_url } — the URL of the tab the user switched to.The user switched browser tabs. Ghost needs to know the active page to correlate traffic correctly. Triggers extension.tab_switch on the main hub.
ext.ping{} — empty payload.Keep-alive from the extension side. The extension sends these periodically to prevent the connection from being closed by browser idle timeouts.
capture.interactionInteractionDTO — type, selector, page URL, timestamp, and metadata about the user action.The extension captured a user interaction (click, form input, submission, hover, focus). Triggers extension.interaction on the main hub.
capture.console_error{ message, source, line } — error details from the browser console.A JavaScript error occurred on the active page. Triggers extension.console_error on the main hub.
capture.navigation{ url, type } — destination URL and navigation type.The user navigated to a new page. Triggers extension.navigation on the main hub.
capture.storage_change{ key, old_value, new_value, storage_type } — what changed in localStorage or sessionStorage.A storage value was modified on the active page. Used for tracking state changes during testing.
action.result{ id, success, data, error } — the result of a previously requested action, including the action ID for correlation.The extension completed an action that Ghost requested (page read, element click, form fill, screenshot, etc.). Ghost matches the result to the original request using the action ID.

These are messages Ghost sends to the browser extension:

Message TypePayloadPurpose
ghost.welcome{ session_id, config } — current active session and extension configuration.Sent immediately after the extension connects. Tells the extension which session to associate captures with and what features are enabled.
action.request{ id, action, params } — a unique action ID, the action type (e.g., “read_page”, “click”, “fill”, “screenshot”), and action-specific parameters.Ghost is requesting the extension to perform an action in the browser — triggered by the AI agent using browser tools or by the user through the UI. The extension executes the action and responds with action.result.
inject.command{ script, target } — JavaScript code to inject and the target URL pattern.Ghost is requesting the extension to inject JavaScript into a matching page. Used for injection rules and the proxy_inject_script agent tool.

When the extension captures something (like a user clicking a button), the event flows through multiple systems:

  1. Extension content script detects the DOM event in the browser page
  2. Extension service worker receives it and sends it over the Extension WebSocket as capture.interaction
  3. Extension Hub (Go backend) receives the message, deduplicates it using the ring buffer, and stores the interaction in the database
  4. Extension Hub then broadcasts to the Main Hub as extension.interaction
  5. Main Hub broadcasts to all connected frontend clients
  6. React frontend receives the event and updates the interactions store, which updates the UI

This multi-hop design keeps the extension protocol isolated from the main event system while still ensuring the frontend sees all extension activity in real-time.


All WebSocket events from the main hub are received in App.tsx and routed to the appropriate Zustand store based on the event type. The routing table ensures each event type updates exactly the right piece of state, with no unnecessary re-renders across unrelated components.

Routing overview (38 event types routed to 12 stores):

StoreEvents handled
Flow storeflow.created, flow.updated, flow.deleted, flow.breakpoint, flow.resumed, ws.frame
Session storesession.created, session.updated, session.deleted
Proxy storeproxy.started, proxy.stopped, proxy.status, proxy.throttle
Addon storeaddon.created, addon.updated, addon.deleted, addon.log
Extension storeextension.connected, extension.disconnected, extension.interaction, extension.tab_switch, extension.console_error, extension.navigation
Rule storerule.created, rule.updated, rule.deleted
Injection rule storeinjection_rule.created, injection_rule.updated, injection_rule.deleted
Device storedevice.discovered, device.updated, device.connected, device.disconnected, device.removed
Security storefinding.created, finding.updated, finding.deleted
Frida storefrida.session.started, frida.session.stopped, frida.output
Attacker storeattacker:started, attacker:progress, attacker:completed, attacker:error
Artifact storeartifact.created, artifact.deleted

Client-side filtering for flow.created: When a new flow event arrives, the frontend doesn’t blindly add it to the visible flow list. It first checks whether the flow matches the currently active filters (host scope, content type, status code range, search query, and other filter strip settings). Only flows that pass all active filters appear in the UI. This means the flow list accurately reflects what the user wants to see, even as hundreds of flows arrive per minute.

Two events that are received but intentionally ignored (no-ops): The frontend WebSocket handler recognizes all event types, but some event types from the backend exist only for future extensibility or are handled by a different mechanism. These are silently dropped without any state update.