Lifecycle hooks¶
The core emits a documented event at every durable lifecycle moment — persona created, council recorded ("session finished"), synthesis recorded, run finished — and exposes three subscription seams. Hooks are the connective tissue for automations: a Slack/Notion/CRM push, a cloud alerting connector, a recurring-jobs trigger. Local hooks work fully standalone; sonaloop-cloud builds delivery and alerting on top of the same envelope.
Emission is best-effort by contract: events fire after the state is persisted, a failing subscriber is logged and never breaks the operation that emitted the event, and payloads carry ids + lean summaries — never authored text.
The envelope¶
Every subscriber — in-process handler, command, or webhook — receives the same version-stamped envelope. This shape is the stable surface connectors depend on:
{
"event": "council.recorded",
"schema": 1,
"emitted_at": "2026-06-10T12:00:00.000000+00:00",
"data": { "council_id": "council_…", "project_id": "research_…", "…": "…" }
}
The events¶
| Event | Fires when | Payload |
|---|---|---|
persona.created |
a host-authored profile is validated + persisted | persona_id, slug, display_name |
persona.updated |
a profile patch lands (audit reason included) | persona_id, reason |
evidence.attached |
a real-world source is attached to a persona | persona_id, evidence_id, source_type |
chat.recorded |
a persona chat exchange is persisted (docs/substrate.md) | chat_id, persona_id, turns |
persona.grounded |
a persona was grounded in real source material (docs/grounding.md) | persona_id, corpus_ids, claims |
prediction.scored |
a real outcome was matched to a predicted behavior (docs/calibration.md) | project_id, outcome_id, brier, hit |
calibration.round_recorded |
a correction round was stamped after calibration misses | scope, corrections, mean_brier |
asset.attached |
a file/image/screenshot is attached to a project as evidence (docs/project-assets.md) | project_id, asset_id, kind, filename |
day.recorded |
one simulated day is persisted | persona_id, date, events |
council.recorded |
a council session finishes inside its project — the "session finished" moment | council_id, project_id, prompt, persona_ids, statements, votes |
synthesis.recorded |
a synthesis (answer/report node) is created or updated | synthesis_id, title, status, council_ids |
project.created |
a research project is created + its plan seeded | project_id, title, goal, methodology |
run.finished |
a governed run ends — the study completed | run_id, project_id, status, steps |
The live catalogue (always current, including payload contracts) is
list_lifecycle_events over MCP or sonaloop hooks-events on the CLI.
Subscriptions accept an exact event name, a domain wildcard (persona.*), or *.
Subscribing¶
1. Durable hooks (user-facing; persisted in the DB)¶
# A command hook: the envelope arrives as JSON on stdin (+ SONALOOP_EVENT in env)
sonaloop hook-register council.recorded command \
'jq -r .data.prompt >> ~/council-log.txt' --label "council log"
# A webhook: the envelope is POSTed as JSON (X-Sonaloop-Event header set)
sonaloop hook-register run.finished webhook https://hooks.slack.com/services/…
sonaloop hooks-list # all registrations
sonaloop hook-test <id> # fire a sample envelope through one hook
sonaloop hook-remove <id>
The same four operations exist as MCP tools: register_hook, list_hooks,
test_hook, unregister_hook (+ list_lifecycle_events for the catalogue).
Registration is idempotent on (event, kind, target).
2. Entry-point extensions (sonaloop.hooks)¶
The hooks counterpart of the sonaloop.web.extensions seam — how
sonaloop-cloud / sonaloop-research plug connectors in without touching core:
# the extension package's pyproject.toml
[project.entry-points."sonaloop.hooks"]
alerting = "sonaloop_cloud.alerting:setup"
def setup() -> None:
from sonaloop import services
services.add_hook_handler("run.finished", push_to_slack)
Extensions load lazily on first emit; a broken extension is logged and skipped.
3. In-process handlers¶
from sonaloop import services
services.add_hook_handler("council.recorded", lambda env: ...)
The event bus + live inspector (SSE)¶
The MCP server, the CLI and the web inspector are separate processes sharing one
SQLite DB, so the inspector can't see in-process emissions directly. A built-in
'*' subscriber (sonaloop/services/_events.py, registered whenever the services
layer loads — i.e. in whichever process records data) appends every emitted event
to a durable events table: monotonic id, timestamp, event name, the primary
entity (+ owning project), and a short label + inspector URL. The table is capped
to the newest ~1000 rows on append; like every subscriber, the append is
best-effort and never breaks the recording operation.
The web app tails that table:
GET /api/events— a plain SSE stream (heartbeat comment every ~15s; the table is polled about once a second). Each frame'sid:is the bus row id, so anEventSourcereconnect replays missed rows viaLast-Event-IDautomatically.- Every inspector page connects on load: new events show a small activity toast linking to the recorded entity, and the page reloads itself when an event concerns the entity/project currently on screen — Claude records a council over MCP in one window, the open project page updates in the other.
/activity— the Activity feed page listing the recent bus rows.
Knobs: SONALOOP_EVENTS_POLL (table-poll seconds, default 1) and
SONALOOP_EVENTS_HEARTBEAT (heartbeat seconds, default 15).
Operational knobs¶
SONALOOP_DISABLE_HOOKS=1— skip command/webhook delivery entirely (in-process handlers still run).SONALOOP_HOOK_TIMEOUT— per-delivery timeout in seconds (default 10, clamped 1–120).