Messaging reach — Claude & agents message the user (AU-ECO.messaging.messaging-reach-service-governed–4.54)¶
The reach capability lets Claude (over MCP) and the pydantic-ai graph agents
proactively message the operator on whatever channel they last used — Telegram, Slack,
Discord, and 14 other backends — and route the user's replies back into the graph. It
finishes the wiring of the pre-existing CONCEPT:AU-ECO.messaging.native-backend-abstraction messaging framework
(agent_utilities/messaging/), which shipped 17 backends, a registry, an inbound router,
and KG auto-ingest but had no live caller.
What was added¶
| Concept | What | Where |
|---|---|---|
| AU-ECO.messaging.messaging-reach-service-governed | MessagingService — one core: connected backends, governed sends, routing |
messaging/service.py |
| AU-ECO.messaging.last-active-channel-routing | Last-active channel state (durable UserChannelPreference node) |
messaging/service.py, messaging/router.py |
| AU-ECO.mcp.graph-reach-mcp-tool | graph_reach MCP tool + /graph/reach REST twin |
mcp/tools/reach_tools.py |
| AU-ECO.messaging.sending-reply-failed | Inbound router in the host daemon + real graph-agent reply (replaced the stub) | gateway/daemon.py, messaging/router.py |
| ECO-4.52 | Elicitation bridge — a blocked loop/agent question reaches the user and resumes on reply | observability/approval_manager.py, messaging/service.py |
| AU-ECO.messaging.universal-agent-reach-user | Universal reach_user agent tool |
tools/agent_tools.py, tools/tool_registry.py |
| AU-ECO.messaging.messaging-ontology-shape-so | MessagingChannel ontology interface (owl:Class) |
knowledge_graph/ontology/interfaces.py |
How routing works (OpenClaw-style)¶
reach_user(text) delivers to the user's last-active channel: every inbound message
updates a durable UserChannelPreference node; reach_user reads the most recent one and
falls back to the configured default (MESSAGING_DEFAULT_PLATFORM /
MESSAGING_DEFAULT_CHANNEL) so a fresh system still works. Every send passes the
fail-closed ActionPolicy gate (message.send, default auto_notify) and is mirrored
into KG conversational memory (kg_ingest), so history is recallable cross-platform.
Flow¶
flowchart TD
subgraph Outbound
Claude([Claude / MCP]) -->|go__graph_reach| Reach[graph_reach tool]
Agent([pydantic-ai agent]) -->|reach_user tool| SVC
Loop([goal-loop / elicitation]) -->|reach_user_and_wait| SVC
Reach --> SVC[MessagingService]
SVC -->|ActionPolicy gate| Gate{message.send}
Gate -->|allow| Backend[(Telegram backend)]
SVC -->|mirror| KG[(KG memory)]
Backend --> User((User on Telegram))
end
subgraph Inbound
User -->|reply| Backend
Backend -->|listen| Router[InboundRouter]
Router -->|deliver_reply?| SVC
Router -->|else, per-channel session| Universal[Orchestrator.execute_agent → run_agent]
Universal -->|mementos for session| Mem[(core memory)]
Universal --> Backend
Router -.->|after reply, background| Pref[(UserChannelPreference + episodic + session memento)]
end
The daemon (gateway/daemon.py) auto-starts the InboundRouter whenever a backend token
is configured (opt-out, auto-detected). When the user's reply answers a question a loop
asked, deliver_reply resolves the waiting future and the message is not re-routed;
otherwise the chat turn runs the universal graph agent.
The reply IS the universal graph agent (ECO-4.78)¶
Messaging is thin transport. An inbound chat turn is not handled by a bespoke
messaging-only reply path — it IS a run of the one universal orchestration pipeline
(Orchestrator.execute_agent → run_agent, orchestration/), session-scoped per channel
(session = messaging:{platform}:{channel_id}). That single path natively provides
everything the router used to hand-roll:
- Continuity comes from the core memory:
run_agentprimes each run with the recent compressed mementos for this session source (get_recent_mementos,memento_source), and after the reply the just-finished turn is compressed into a memento under the same source (background). So turn 2 sees turn 1 — without any messaging-specific recall query. - Dynamic capabilities — the graph dynamically resolves specialists / skills / A2A /
swarms and fleet tools; a request that needs e.g. GitHub reaches
graph_orchestrate(execute_agent)for the github specialist, all governed by the fail-closed ActionPolicy gate (OS-5.24). No bespoke delegation code in the messaging layer.
The universal run is wrapped in a hard MESSAGING_REPLY_TIMEOUT (default 45s): a slow or
hung graph run must still answer, so on timeout/error the reply degrades to a plain-chat
completion (_plain_chat_reply). That fallback keeps the local-default / /claude
responder selection (AU-ECO.messaging.model-routed-inbound-responder) — every fallback reply is tagged with who answered
([local] / [claude]) — and carries image attachments to the vision model (ECO-4.67).
MESSAGING_AGENT names which agent the universal path routes a chat turn to (default the
messaging-assistant identity); an unresolved name still flows through the full
orchestration graph, which is exactly the dynamic-delegation behaviour we want.
Instinctive reactions (AU-ECO.messaging.messaging-renderer-core-reaction → core ECO-4.79/4.81)¶
The agent reacts to your messages with an emoji where the platform supports it — 👍 to
acknowledge a request, ❤️ for praise/thanks, etc. As of ECO-4.79/4.81 the reaction logic is
no longer owned by messaging — it is a first-class output of the universal orchestrator
(orchestration/reactions.py), so every entrypoint inherits it. Messaging is now a
renderer: the router's background step calls the core, model-agnostic decision
(decide_reaction → an AgentReaction) and MessagingService.render_reaction() paints it
via the backend's send_reaction (Telegram setMessageReaction is implemented; other
backends expose send_reaction and degrade gracefully where unsupported). The cheap tool-free
decision works even on local models that can't call tools; set REACTIONS=0 (legacy
MESSAGING_REACTIONS=0 still honored) to disable. Full design + the renderer contract for the
other surfaces: reactions.md.
Voice & image input (ECO-4.67/4.68)¶
- Voice (ECO-4.68): a voice note / audio with no text is transcribed via the
audio-transcriber Whisper backend (
transcribe_voice, lazy-loaded, off the event loop) and the transcript flows through the normal path — so you can just talk. Opt-outMESSAGING_VOICE=0; model viaMESSAGING_VOICE_MODEL(defaultbase). - Image (ECO-4.67): image attachments are downloaded and passed as inline
BinaryContentto the vision-capable model (qwen confirmed), so you can upload a picture and ask about it. Images ride the same burst → one multimodal agent turn.
Burst coalescing (ECO-4.63)¶
When you fire several messages in quick succession, the agent collapses them into one
holistic reply with one LLM call instead of answering each separately. A per-conversation
debounce (BurstCoalescer, messaging/coalescer.py) accumulates messages and flushes the
batch when you pause for MESSAGING_BURST_WINDOW_S (default 2.5s) or MESSAGING_BURST_MAX_S
(default 12s) elapses. Per-message side effects that must stay immediate — last-active
channel, KG history ingest, loop-reply delivery, /commands — run per message; only the
agent reply (and its single reaction) coalesce. BurstCoalescer is a shared core primitive
agent-terminal-ui reuses, so burst behavior is identical across surfaces.
Conversation history / continuity (ECO-4.78)¶
Continuity is a property of the core memory, not a messaging-specific query. Because each
chat turn runs the universal path session-scoped per channel (above), run_agent primes the
run with the recent compressed mementos for that session source, and after the reply the
turn (user prompt + assistant reply) is compressed into a memento under the same source
(compress_to_memento(source=session)), off the reply path. The next turn of the channel
then inherits that continuity through the universal path's native memento priming — there is
no bespoke per-channel history query, no channel_key scaffolding, and no recall on the
reply path to stall the answer. The turn is also auto-ingested as episodic memory
(kg_ingest), which the agent's KG tools can pull on demand when a question needs deeper
recall.
Universal commands (AU-ECO.messaging.single-inbound-command-dispatcher)¶
Commands are defined once in agent_utilities/messaging/commands.py (COMMANDS) — the
single source of truth shared by every platform and importable by agent-terminal-ui
(command_specs()). On connect the daemon calls backend.register_commands(...) on every
backend; each registers the menu where its platform supports a runtime command API
(Telegram setMyCommands) and no-ops where commands are set via app-manifest/admin
(Slack/Teams/Mattermost) or a separate interaction model (Discord). Regardless of menu
support, commands also work as typed /cmd text on any backend — the inbound handler
parses a leading /cmd and handle_command answers built-ins (/help, /status,
/tools); /claude and /skill fall through to the model/agent. Add a command once and
it appears everywhere.
Multiple services at once¶
The router runs every configured backend concurrently — set tokens for any of
Telegram, Slack, Teams, Mattermost, Discord, … and start_messaging_router connects and
listens on all of them. Last-active routing stores platform + channel per user, so
reach_user follows the user to whichever service they last used; graph_reach
action=send targets a specific service explicitly.
Configuration¶
| Setting | Purpose |
|---|---|
TELEGRAM_BOT_TOKEN / SLACK_BOT_TOKEN / MATTERMOST_TOKEN / MSTEAMS_APP_ID… |
Enable each backend (auto-detected; multiple may be set together) |
MESSAGING_DEFAULT_PLATFORM |
Default platform when no last-active channel (default telegram) |
MESSAGING_DEFAULT_CHANNEL |
Default channel id for reach_user fallback |
MESSAGING_AGENT |
Named agent the universal path routes a chat turn to (default the messaging-assistant identity; unresolved names still flow through the full orchestration graph) |
MESSAGING_CLAUDE_TRIGGER |
Prefix that routes the plain-chat fallback to Claude (default /claude) |
MESSAGING_CLAUDE_MODEL |
Anthropic model for the Claude route (default claude-sonnet-4-6) |
MESSAGING_LOCAL_MODEL |
Override the local responder model id |
MESSAGING_REPLY_TIMEOUT |
Seconds to wait for the universal graph run before degrading to the plain-chat fallback (default 45) |
ANTHROPIC_API_KEY |
Required for the Claude route |
MATTERMOST_URL / MATTERMOST_TOKEN / MATTERMOST_BOT_USER |
Mattermost (ECO-4.90): server base URL, a Bot Account token, and the bot's username/id (optional — auto-resolved from the token). Inbound runs over the bot WebSocket (posted events); outbound posts via the bot REST API |
MCP_CLIENT_AUTH / OIDC_CLIENT_ID / OIDC_CLIENT_SECRET / OIDC_AUDIENCE / OIDC_TOKEN_URL |
Fleet OIDC client-credentials — loaded into the daemon env so spawned agents authenticate to the jwt-protected fleet. Source from OpenBao, never a plaintext file (AU-ECO.messaging.make-fleet-credentials-present) |
Mattermost as a first-class platform (ECO-4.90)¶
Mattermost is a thin, bidirectional adapter exactly like Telegram — the universal
orchestrator is still the ONE agent. Inbound: the bot's WebSocket event stream is
consumed (posted events), each post normalized into the shared InboundEvent the
InboundRouter routes; the bot's own posts are dropped (no echo loop). Outbound:
reach_user/MessagingService posts a Markdown reply via the bot REST API (a threaded
reply roots under the originating post id). Like Telegram, the WebSocket is started lazily
by listen() (not connect()), so a send-only client never opens a duplicate stream.
Operator provisioning (the bot account is created out-of-band): in the Mattermost
System Console → Integrations → Bot Accounts, enable bot accounts and Add Bot
Account; copy its token into MATTERMOST_TOKEN (store in OpenBao apps/<service>),
set MATTERMOST_URL to the server URL, then add the bot to the teams/channels it should
listen in and post to (or DM it). Install the extra with
pip install agent-utilities[messaging-mattermost].
Fleet delegation is native to the universal path (ECO-4.78)¶
Delegation needs no messaging-specific wiring. Because a chat turn runs the universal path
(Orchestrator.execute_agent → run_agent), the orchestration graph resolves and binds the
right specialists / skills / MCP fleet tools dynamically and offloads through
graph_orchestrate(execute_agent) for a spawned specialist — the same single delegation
core (and one governance/identity path) the rest of agent-utilities uses. A spawned
specialist's own fleet actions remain governed by the fail-closed ActionPolicy gate
(OS-5.24), and a nested spawn authenticates to the jwt-protected fleet via the daemon's
OIDC client-credentials (loaded into its env at startup, _spawn_auth_headers). OpenBao
is the source of truth for those creds — never a plaintext config/env file.