Messaging
The caco msg family provides project-scoped messaging and narration for agents and operators.
Message Model
| Field | Type | Description |
|---|---|---|
id | string | Unique message identifier |
project | string | Project scope (null for global) |
scope | enum | project (default) or global |
sender | caller ID | Message sender |
target | caller ID? | Target (null for broadcast) |
kind | enum | direct, broadcast, speak, system |
body | string | Message content |
ts | timestamp | Send time |
read_at | timestamp? | Read time (null until read) |
CLI Commands
Send a Direct Message
caco msg send --project lighthouse --target wren --body 'Please review the auth changes'
Agent addresses
--target and --cc, including the generated MCP send tool, resolve an exact bare agent ID across all known projects and nodes before sending. Exact IDs take precedence over human aliases, which remain scoped to the current project. Multiple matches are refused with sorted node:project:id candidates; neither the local node nor the current project wins a tie.
Discovery reads routing identities from the authoritative local registry and replicated peer inventories, not the rendered agent-list cache. Missing, unreadable or truncated peer inventory and registry timeouts are reported as unavailable, not as an unknown recipient. This is address discovery, not proof of runtime reachability or delivery.
Explicit project:id and node:project:id addresses remain unchanged and need no discovery, including for non-agent inboxes. --strict-target also checks explicit agent addresses. Bare-ID discovery needs a daemon exposing the address-inventory endpoint; older daemons fail explicitly rather than falling back to project-local guesses. All existing project and send permissions still apply.
Bodies
Every message-writing command (send, send-node, broadcast, reply, speak) takes exactly one body source, sent verbatim as UTF-8 and checked for blanks and size the same way.
| Flag | Use it for |
|---|---|
--body '…' | one plain line, single-quoted |
--body-file note.md | anything with Markdown, code or several lines |
--body-stdin or --body-file - | a pipe or a single-quoted heredoc |
In a double-quoted --body, the shell expands $variables and runs $(...) and backticks before caco starts. scripts/check-message-body-shell-safety.py fails any repository script, profile or guide that does it; a reviewed exception carries caco:msg-body-shell-ok(<reason>) on the same line. MCP tools take a JSON body or body_file, so no shell is involved; caco notify send accepts the same three sources.
Targets
| Target | Resolves to |
|---|---|
cacophony:agent-id, node:cacophony:agent-id | that caller, exactly |
wren or a short agent ID | the one agent in the current project that matches |
@agent:wren, @node:vega | an agent, or a node, explicitly |
@wren … at the start of chat | a direct message to that agent, mention stripped |
Agent targets resolve through the agent inventory and are rewritten to the full node:project:agent-id, so the inbox and wake-ups share one key. Ambiguous matches are refused rather than guessed, and an ambiguous chat mention fails closed instead of leaking a private message into project chat; an unknown mention stays an ordinary broadcast. --reply-to threads a send under an earlier message and, without --target, replies to its sender.
When it returns
| Result | Meaning | Then |
|---|---|---|
| accepted | stored locally and in the feed | replication, terminal injection and audit continue in the background |
msg_send_backpressured | the daemon could not accept it in time | retry after the given delay; the error suggests the command |
| transport failure | the answer was lost | it may already be accepted, so it is never retried automatically |
Broadcasts fan out to recipients concurrently and log how many were injected or skipped; the inbox is always the fallback, so a slow peer never makes a send hang. In --json mode, a missing project, target or body comes back as an envelope with ok=false and an error.code.
Paratenic-backed Pi agents (pi-paratenic and opted-in ordinary Pi) get each message over AHP first. If the binding is missing, the connection fails within five seconds (or a shorter configured timeout) or AHP rejects it, that one message falls back to the agent's Herdr or tmux terminal, and the next message tries AHP again. A lost acknowledgement after dispatch gets one bounded read-only lookup of its original outcome; an unresolved result stays indeterminate rather than being sent twice. Managed Pi binding and delivery recovery
The Android, TUI and web composers show a pending bubble while the send is in flight, replace it with the delivered message, or leave a failed state to retry; that is feedback only, and chat history is the daemon's accepted log.
Send to Agents on a Node
PROJECT="cacophony"
NODE="beelink"
caco msg send-node --project "$PROJECT" --node "$NODE" --body "Please report local status"
# Include injectable agents on the node across all projects.
caco msg send-node --project "$PROJECT" --node "$NODE" --global --body "Node maintenance window starting"
Node-scoped sends are fan-out over ordinary direct messages, not a separate inbox type. The daemon selects injectable non-terminal agents on the target node, persists one normal direct-message row per recipient through the same bounded accept path as caco msg send, and returns per-recipient accepted/failed/pending metadata plus a count-only aggregate receipt. Every replica shares one origin ID; explicit pre-accept backpressure remains pending/backpressured rather than becoming an opaque whole-send failure.
Reply to a Message
PROJECT="cacophony"
MESSAGE_ID="msg-0123456789abcdef"
caco msg reply --project "$PROJECT" --message-id "$MESSAGE_ID" --body "Thanks, I am checking that now"
Broadcast to Project
PROJECT="cacophony"
# Broadcast to all agents in the current project
caco msg broadcast --project "$PROJECT" --confirm-broadcast --body "Starting deploy preparation"
# Broadcast globally to all agents across all projects
caco msg broadcast --global --confirm-broadcast --body "System maintenance in 10 minutes"
Agent callers, including controllers, must explicitly pass --confirm-broadcast (serialized as confirm_broadcast: true) after reviewing the fan-out. Unconfirmed agent requests fail closed with broadcast_confirmation_required before any message or feed row is created; operator callers retain the historical no-confirm path. Generated MCP metadata exposes the same typed field, and TUI composer submission serializes it explicitly.
Broadcast and group-fan-out receipts distinguish accepted, fully delivered, partially delivered, failed, pending, and backpressured outcomes. TUI group chat keeps one logical bubble and one origin ID across replicas and bounded retries, renders accepted-partial delivery with an amber marker instead of a red failure X, and shows count-only accepted/failed/pending detail without recipient identities or raw transport diagnostics.
Blank msg send, msg reply, and msg broadcast bodies are rejected before daemon delivery. With --json, those validation failures use the same structured stdout error envelope as successful JSON commands.
Speak (TTS Narration)
PROJECT="cacophony"
caco msg speak --project "$PROJECT" --body "Build completed successfully"
# Ask for a bounded one-shot voice reply from the operator.
caco msg speak --project "$PROJECT" --body "Should I restart the dashboard?" --wait-for-reply 30s
caco msg speak --project "$PROJECT" --body "Should I restart the dashboard?" --wait-for-reply 30s --json
--wait-for-reply is explicit opt-in only. The CLI waits for the initial TTS message, announces the listening window, records the configured STT input for no longer than the requested duration, stops the recorder, transcribes the chunk, and returns the transcript in the blocking command response. The duration is capped at five minutes and invalid or zero durations are rejected before speaking.
The transcript is not delivered to the agent inbox, project chat, or message stream. Only the ordinary speak/status messages used for the prompt and listening state are visible in chat/feed history. If TTS is muted/unconfirmed, speech input is disabled, STT is unavailable, or the microphone cannot be opened, the command fails with a useful text error; in --json mode it returns a structured ok=false response with data.wait_for_reply.recording_stopped=true.
Repeat a Narration on an Interval
caco loop --project cacophony --interval 10m --body "Speak update and continue with your assigned goals"
caco loop is the canonical first-party CLI replacement for ad hoc while true; do ...; sleep ...; done narration loops. Each iteration routes through the same underlying caco msg speak path, so project scoping, auth, feed visibility, and aggregate MCP exposure stay consistent.
The web dashboard chat composer also supports /loop <interval> <message>, /loop status, and /loop off. Browser chat loops capture the current project, channel, and broadcast/speak/direct target, then repeat through the normal chat message endpoints rather than a parallel delivery path. The Pi-native /loop extension similarly suppresses duplicate prompt deliveries for the same prompt/delivery mode when scheduler catch-up or duplicate ticks fire inside the bounded duplicate-delivery window, while leaving ordinary next-interval delivery intact.
Check Inbox
PROJECT="cacophony"
caco msg inbox --project "$PROJECT"
Returns unread messages for the inferred caller. Messages are marked as read after retrieval.
Managed Pi profiles that compose the pi-inbox mixin do not need a repeating “check inbox” prompt loop just to see messages: the repo-owned Pi extension polls caco msg inbox --json, remembers delivered message IDs in session state, and delivers only newly arrived direct/broadcast messages through Pi’s non-stopping follow-up channel instead of normal prompt turns. In-session controls are /caco-inbox status, /caco-inbox poll, /caco-inbox on, and /caco-inbox off; manual caco msg inbox remains the canonical CLI read surface for non-Pi sessions and diagnostics.
Inspect message history
PROJECT="cacophony"
caco msg snapshot --project "$PROJECT" --agent "$AGENT" --last 10
caco msg stats --project "$PROJECT" --since 1h --bucket hour
caco msg show --project "$PROJECT" --message-id msg-...
caco msg thread --project "$PROJECT" --msg-id msg-...
Beyond inbox/history, snapshot dumps the last-N inbox as a structured snapshot, stats aggregates counts by sender/type over a window, show prints one message body in full (no inbox preview truncation), and thread renders a reply tree.
Scoping Rules
- Default scope:
project— messages delivered only to agents in the same project - Global scope: use
--globalflag for explicit cross-project broadcasts - Operators can view project-scoped messages from all projects
- Global operator visibility does not change project-local delivery rules
Chat Surface Mapping
| Surface | Behavior |
|---|---|
| Project chat (TUI) | Typing broadcasts to agents in that project |
| Global chat (TUI) | Typing broadcasts to all agents across all projects |
| Node chat (TUI) | Cluster > Nodes > node > Chat filters the global chat timeline to that node and sends through the canonical node-target messaging path |
| Web dashboard chat | Enter sends, Shift+Enter inserts a newline, @ opens agent-username suggestions that insert a mention token, and leading slash commands open suggestions; /loop <interval> <message> schedules recurring messages in the selected chat context; unknown slash commands show a toast instead of posting as chat text. |
| Direct agent message | Project-scoped unless explicit global broadcast |
| Node-scoped message | Fan-out to injectable agents on one node via ordinary direct-message rows; --global includes all projects on that node |
Feed Integration
Messages emit feed events (agent_message, agent_broadcast, user_message) that are replicated across the cluster and surfaced in the UI stream. Speak events include bounded TTS metadata such as tts_category; when a configured persona category is inferred because the caller did not pass --persona or --voice, the payload records tts_category_persona_inferred: true and persona_source: inferred_category.
Some logical operations now carry a payload-level correlation_id while still storing each raw event independently. For example bead creation uses a bounded ID such as corr-bead-create-; presentation layers may roll consecutive matching rows together, but the feed ledger and SQLite event rows remain individually inspectable.
Feed reads and offline/export helpers can use bounded feed-history rows without changing the raw ledger. The daemon feed endpoint accepts a structured filter= query expression using whitespace-separated key:value terms, ANDed together, with quoted values supported. The supported fields are project, node/origin_node, type/event_type, sender/source, target, and correlation_id/correlation; unsupported fields fail with invalid_feed_filter instead of silently broadening a query. caco feed list exposes the same filter syntax for CLI inspection, and caco feed export-midi uses the retained local feed history to write a deterministic MIDI summary and optional JSON provenance sidecar.
API Endpoints
| Method | Endpoint | Purpose |
|---|---|---|
| POST | /api/v1/projects/<project>/messages/send | Send direct message |
| POST | /api/v1/projects/<project>/messages/broadcast | Broadcast message |
| POST | /api/v1/projects/<project>/nodes/<node>/messages/send | Node-scoped direct-message fan-out |
| POST | /api/v1/projects/<project>/messages/speak | Speak (TTS); store-lock contention returns bounded retryable message_store_busy with Retry-After, lock timing, and operation_started:false |
| GET | /api/v1/projects/<project>/messages/inbox | Read inbox |