The queryable substrate¶
Sonaloop as infrastructure: everything the interactive runs produce — personas, projects, councils, syntheses — is queryable programmatically over MCP (and the CLI mirrors every call 1:1). This is the contract recurring jobs, multi-user workspaces, the analytics dashboard and the client delivery portal build on.
The contract (pin this)¶
substrate_schema returns the machine-readable version of everything below.
- Versioned — every envelope carries
substrate_version(currently1); any shape change bumps it. Automations should assert on it. - Stable — ids are the durable handles. Ordering is deterministic (newest
first by
updated_at/created_at, id tie-break), so pagination never shuffles between pages. - Paginated — every list returns
{substrate_version, total, limit, offset, next_offset, items}; passnext_offsetback to continue;limitis clamped to 200. - Guarded — every operation passes the access-guard seam (below).
Queries¶
| Tool | Filters | Row |
|---|---|---|
query_personas |
q |
compact persona summary (id, slug, name, age, role, segment) |
query_projects |
status, q, since |
lean project (id, title, goal, status, methodology, counts, timestamps) |
query_councils |
project_id, persona_id, q, since |
lean council (id, project_id, prompt, participants, counts) |
query_syntheses |
status, q, since |
lean synthesis (id, title, status, council_ids, counts) |
q is free text over the row; since is an ISO lower bound on
updated_at/created_at — the natural shape for a recurring job's
"what changed since my last run?".
get_study_result(project_id) is the one-call structured result automations
poll: the lean project row + live run state + council rows + the full
syntheses in the project's graph (the answer nodes) + open questions + counts.
Chat with one persona (durable)¶
Host-authored like everything else — the server never generates text:
chat_with_persona(persona_id, message)→ the persona's loaded agent context + prior turns (history); omitchat_idto open, pass it to continue.- You author the in-character reply, then
record_chat_turn(persona_id, chat_id, user_message, persona_reply)— the exchange persists and emits thechat.recordedlifecycle event. get_chat(chat_id)/list_chats(persona_id?)read the durable artifact back.
CLI: chat-brief, chat-record, chat-get, chat-list, plus query-*,
study-result, substrate-schema.
The auth seam (cloud builds on this)¶
from sonaloop import services
def workspace_guard(operation: str, resource: dict) -> None:
if resource.get("project_id") not in current_workspace_projects():
raise PermissionError("outside workspace scope")
services.register_access_guard(workspace_guard)
Every substrate operation calls the registered guards (operation name +
resource descriptor) before touching data; raising PermissionError denies.
Locally no guards are registered and everything passes — the same surface works
single-user now and multi-tenant in sonaloop-cloud later, which is the point.
How recurring jobs compose (the dependent ticket)¶
A scheduled job is: query_projects(since=last_run) → get_study_result per
hit → compare → act via a lifecycle hook (register_hook on run.finished /
synthesis.recorded / chat.recorded for push instead of poll). No other
surface needed.