Messaging

The caco msg family provides project-scoped messaging and narration for agents and operators.

Message Model

FieldTypeDescription
idstringUnique message identifier
projectstringProject scope (null for global)
scopeenumproject (default) or global
sendercaller IDMessage sender
targetcaller ID?Target (null for broadcast)
kindenumdirect, broadcast, speak, system
bodystringMessage content
tstimestampSend time
read_attimestamp?Read time (null until read)

CLI Commands

Send a Direct Message

caco msg send --project lighthouse --target wren --body 'Please review the auth changes'
caco msg send resolves its target (a canonical caller ID, an unambiguous short ID or an explicit @agent or @node) and asks the local daemon to accept it. The daemon stores the message and its feed event locally and only then returns, or fails fast with a retryable msg_send_backpressured error. Everything else happens in the background: the feed event replicates to peers, and the recipient's owning daemon either injects the message into the agent's terminal now or, if that scope batches messages, holds it for the agent's next heartbeat. Opted-in Pi agents are tried over Paratenic AHP first and fall back to their Herdr or tmux terminal; if live delivery fails, the inbox row is still there.
Sending a messageA send returns once it is stored locally; replication, wake-or-batch and terminal injection follow, and the inbox is always the fallback.

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.

FlagUse it for
--body '…'one plain line, single-quoted
--body-file note.mdanything with Markdown, code or several lines
--body-stdin or --body-file -a pipe or a single-quoted heredoc
Never double-quote Markdown

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

TargetResolves to
cacophony:agent-id, node:cacophony:agent-idthat caller, exactly
wren or a short agent IDthe one agent in the current project that matches
@agent:wren, @node:vegaan agent, or a node, explicitly
@wren … at the start of chata 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

ResultMeaningThen
acceptedstored locally and in the feedreplication, terminal injection and audit continue in the background
msg_send_backpressuredthe daemon could not accept it in timeretry after the given delay; the error suggests the command
transport failurethe answer was lostit 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

Chat Surface Mapping

SurfaceBehavior
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 chatEnter 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 messageProject-scoped unless explicit global broadcast
Node-scoped messageFan-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

MethodEndpointPurpose
POST/api/v1/projects/<project>/messages/sendSend direct message
POST/api/v1/projects/<project>/messages/broadcastBroadcast message
POST/api/v1/projects/<project>/nodes/<node>/messages/sendNode-scoped direct-message fan-out
POST/api/v1/projects/<project>/messages/speakSpeak (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/inboxRead inbox