Events & Webhooks¶
Every notable action inside Soulacy — messages, tool calls, workboard runs, reasoning steps — is published as a versioned JSON envelope. Hook your dashboards, alerting, and log shippers into the stream instead of polling the API.
Three ways to consume events:
- Signed webhooks (
hooks:in config.yaml) — easiest, works with the default in-process queue. - WebSocket
/ws/events— what the GUI uses; great for live tooling. - NATS subjects — set
queue.backend: natsand subscribe from any process or machine.
The envelope (schema v1)¶
{
"schema": 1,
"id": "9b2f6c1e-…",
"type": "run.failed",
"agent_id": "support-bot",
"session_id": "wb-12-1765043210",
"ts": "2026-06-06T21:04:05Z",
"data": { "task_id": 12, "run_id": 3, "attempt": 2, "failure_reason": "…" }
}
data carries the type-specific payload verbatim — an object, a string,
or null. New event types and fields may be added at any time; consumers
must ignore unknown types and fields. Renaming or removing a field
bumps schema, with the previous schema dual-published for at least two
releases. Full contract: docs/EVENTS.md.
Event types¶
| Type | Emitted when | data highlights |
|---|---|---|
message.in |
a run starts (inbound message) | full inbound message |
message.out |
the agent replies | full reply message |
tool.call |
the LLM requests a tool | name, arguments |
tool.result |
a tool returns | name, content, is_error |
llm.result |
one LLM turn completes | model, input_tokens, output_tokens, duration_ms, tool_calls |
error |
a run errors (incl. plugin load failures at boot, stage: plugin-load) |
stage, error |
run.started |
a Workboard run begins | task_id, task_title, run_id, attempt |
run.finished |
a Workboard run succeeds | same as run.started |
run.failed |
a Workboard run fails | + failure_reason |
run.artifact |
a run produced a file | + path, size_bytes, tool |
reasoning.start |
a reasoning strategy begins | strategy details |
reasoning.step |
one reasoning step completes | step details |
reasoning.result |
the strategy concludes | final result |
rulebook.updated |
an agent's procedural rulebook gains a new version | version info |
Queue subjects¶
Envelopes are published on the configured queue backend
(queue: config) under:
Subscribe with NATS wildcards:
soulacy.events.>— everythingsoulacy.events.run.*— workboard run lifecycle onlysoulacy.events.tool.*— tool activity only
With the default memory backend, subjects exist in-process only (used
by the webhook dispatcher). Set queue.backend: nats to consume events
externally.
Signed outbound webhooks (hooks:)¶
Declare endpoints in config.yaml; the dispatcher consumes the queue and
POSTs matching envelopes:
hooks:
- on: [run.failed, "tool.*"] # exact, "x.*" prefix, or "*"
agents: [support-bot] # optional agent filter; empty = all
url: https://ops.example.com/soulacy
secret_env: SOULACY_HOOK_SECRET # env var holding the HMAC secret
| Key | Description |
|---|---|
on |
Event types to deliver: exact (run.failed), prefix (tool.*), or * for all |
agents |
Only deliver events from these agents (empty = all agents) |
url |
Endpoint that receives POST with the envelope as JSON body |
secret_env |
Name of the env var holding the HMAC signing secret |
Each delivery carries these headers:
X-Soulacy-Event: run.failed # envelope type
X-Soulacy-Delivery: <envelope id> # unique per emission
X-Soulacy-Signature: t=1765043210,v1=<hex hmac-sha256>
The signature is HMAC-SHA256 over "<t>.<raw body>" with your secret.
Verify by recomputing and comparing in constant time, and reject
timestamps older than 5 minutes (replay guard). Reference implementation:
hooks.VerifySignature in internal/hooks.
Retries: failed deliveries (non-2xx or network error) retry up to 5
times with exponential backoff + jitter (1s → 10m cap). After exhaustion
the delivery is dropped and a webhook.dead warning is logged.
Best-effort, not at-least-once
Publishing never blocks the engine. If the internal buffer fills (broker down), events are dropped with a warning. Anything that must not be lost should read the durable stores (action log, costs DB) rather than the event stream.
WebSocket stream (/ws/events)¶
The same events the GUI renders, available to your own tools:
# wscat example — browser WebSockets can't set headers, so the
# credential may be passed as a query parameter:
wscat -c "ws://localhost:18789/ws/events?api_key=sy_..."
Authentication uses the same engine as the REST API. Scoped plugin tokens
are accepted only when the plugin's manifest grants the
events.subscribe capability; plugin tokens without that grant get 403.
Broadcasting is non-blocking: a slow client's queue fills and events are dropped for that client rather than stalling the agent engine.
See also¶
docs/EVENTS.md— the full schema contract- Storage & backends — queue backend configuration
- API overview — REST routes including
/ws/events