Package Exports
- @femtomc/mu-control-plane
Readme
@femtomc/mu-control-plane
Control-plane command pipeline for messaging ingress, policy/confirmation safety, idempotency, and outbox delivery. The messaging operator runtime lives in @femtomc/mu-agent.
First-party messaging adapters
- Slack
- Discord
- Telegram
- Neovim
All adapters normalize inbound commands into the same control-plane pipeline and preserve correlation across command journal and outbox delivery.
Runtime setup checklist
Use mu store paths --pretty to resolve <store>, then configure <store>/config.json:
control_plane.adapters.slack.signing_secretcontrol_plane.adapters.slack.bot_tokencontrol_plane.adapters.discord.signing_secretcontrol_plane.adapters.telegram.webhook_secretcontrol_plane.adapters.telegram.bot_tokencontrol_plane.adapters.telegram.bot_usernamecontrol_plane.adapters.neovim.shared_secret
After config changes, run mu control reload (or POST /api/control-plane/reload).
Identity binding examples:
mu control link --channel slack --actor-id U123 --tenant-id T123
mu control link --channel discord --actor-id <user-id> --tenant-id <guild-id>
mu control link --channel telegram --actor-id <chat-id> --tenant-id telegram-botmu control link is currently for Slack/Discord/Telegram. For Neovim, use :Mu link from mu.nvim.
Slack bot setup + onboarding runbook
This runbook is for first-time Slack setup where you want /mu status working end-to-end from Slack.
1) Start mu server and confirm route mount
mu serve
# in another shell
mu control status --pretty
curl -s http://localhost:3000/api/control-plane/channels | jq '.channels[] | select(.channel=="slack")'Expected route from channel capabilities: POST /webhooks/slack.
2) Create Slack app
In Slack API UI:
- Create app (from scratch) for your workspace.
- Open Basic Information and copy:
- Signing Secret (for inbound verification)
- Open OAuth & Permissions and install app to workspace.
- Copy Bot User OAuth Token (
xoxb-...) for outbound Slack delivery.- Without
bot_token, inbound slash/events can still ACK, but deferred outbound replies/media cannot be delivered.
- Without
3) Configure scopes and features in Slack app
Minimum practical setup for command flow:
- Slash Commands
- Command:
/mu - Request URL:
https://<your-host>/webhooks/slack
- Command:
- Event Subscriptions
- Enable events
- Request URL:
https://<your-host>/webhooks/slack - Subscribe to bot event:
app_mention
- Interactivity & Shortcuts
- Enable interactivity
- Request URL:
https://<your-host>/webhooks/slack
Recommended bot token scopes:
app_mentions:read(inboundapp_mentionevents)chat:write(outbound text replies)files:read(inbound Slack file download from events)files:write(outbound Slack media delivery)
4) Wire mu config keys
Resolve <store> first:
mu store paths --prettyEdit <store>/config.json:
{
"version": 1,
"control_plane": {
"adapters": {
"slack": {
"signing_secret": "<SLACK_SIGNING_SECRET>",
"bot_token": "<SLACK_BOT_TOKEN_OR_NULL>"
}
}
}
}Apply config:
mu control reload
mu control status --prettyNotes:
mu control statusmarks Slack as configured whensigning_secretis present.- Deferred outbound delivery (including normal text replies and media) requires
bot_token.
5) Link Slack identity to mu operator authorization
Use Slack actor/workspace IDs:
actor-id: Slack user id (for exampleU123...)tenant-id: Slack workspace/team id (for exampleT123...)
mu control link --channel slack --actor-id U123 --tenant-id T123 --role operator
mu control identities --prettySecure collaboration channel pattern (single driver, shared observers)
For a shared Slack channel, keep exactly one linked actor as the active bot driver. Everyone else can read bot output and participate in discussion, but only the linked actor should be authorized to drive command/conversational ingress.
Practical rotation flow (handoff from U_OLD to U_NEW in workspace T123):
# Inspect current bindings first and identify old binding_id
mu control identities --pretty
# Remove previous driver binding (admin revoke)
mu control unlink <old-binding-id> --revoke --reason "driver handoff"
# Link the new driver
mu control link --channel slack --actor-id U_NEW --tenant-id T123 --role operator
# Re-check effective state
mu control identities --prettyOperational guidance:
- Keep one active linked actor per collaboration surface/thread when you want strict control ownership.
- If multiple actors are linked, each linked actor is authorized; this weakens single-driver control.
- Use unlink during handoffs/on-call rotation so authorization intent stays explicit and auditable.
Thread behavior in this pattern:
- Replies are anchored to the originating Slack thread (
event.thread_tswhen present; otherwise message timestamp fallback). - Linked-driver conversational turns in that thread can route through operator handling.
- Observer freeform messages remain non-executable by default (no operator run) unless that observer is linked.
- Explicit
/mu ...commands from unlinked observers are denied withidentity_not_linked.
6) Smoke tests
- In Slack, run
/mu status. - Expect immediate ephemeral ACK and a deferred result message.
- Validate runtime/audit:
mu control status --pretty
mu store tail cp_commands --limit 20 --pretty
mu store tail cp_outbox --limit 20 --pretty
mu store tail cp_adapter_audit --limit 20 --pretty- Optional event path check (message events): send
/mu statusas text in an event-enabled surface and verify acceptance. Non-command text should no-op by policy.
7) Troubleshooting (reason-code oriented)
missing_slack_signature/invalid_slack_signature- Slack signing secret mismatch or wrong endpoint.
invalid_slack_timestamp/stale_slack_timestamp- malformed or stale signed request timestamp.
slack_command_required- slash payload not using
/mucommand.
- slash payload not using
channel_requires_explicit_command- event message was freeform text; Slack ingress is explicit-command only.
unsupported_slack_action_payload- interactive payload did not match
confirm:<id>orcancel:<id>.
- interactive payload did not match
slack_bot_token_required- Slack file download attempted without
control_plane.adapters.slack.bot_token.
- Slack file download attempted without
Operational checks:
mu control status --pretty
curl -s http://localhost:3000/api/control-plane/channels | jq '.channels[] | select(.channel=="slack")'
mu store tail cp_adapter_audit --limit 50 --pretty
mu store tail cp_outbox --limit 50 --prettyMedia support runbook (Slack + Telegram)
Use this runbook when enabling attachment ingress/egress and debugging media regressions.
Supported media types + limits
Inbound attachment download policy currently allows:
application/pdfimage/svg+xmlimage/pngimage/jpegimage/webptext/plain
Constraints:
- Max size:
10 MiBper attachment - Default ingress enablement: Slack + Telegram enabled; other channels disabled
- Retention:
24hdefault TTL (inboundAttachmentExpiryMs) - Deterministic policy denies include explicit reason codes (for example
inbound_attachment_unsupported_mime,inbound_attachment_oversize)
Required config for media delivery
Both inbound download and outbound media delivery require channel bot credentials in <store>/config.json.
Slack:
control_plane.adapters.slack.signing_secretcontrol_plane.adapters.slack.bot_token
Telegram:
control_plane.adapters.telegram.webhook_secretcontrol_plane.adapters.telegram.bot_tokencontrol_plane.adapters.telegram.bot_username(recommended for command normalization)
Apply config updates with:
mu control reloadThen verify capability flags:
mu control status --pretty
curl -s http://localhost:3000/api/control-plane/channels | jq '.channels[] | {channel, media}'Outbound media routing behavior (Telegram-specific)
Telegram delivery chooses API method by attachment type/mime:
- PNG/JPEG/WEBP image attachments route to
sendPhoto - SVG (
image/svg+xmlor.svg) routes tosendDocument(notsendPhoto) - PDF routes to
sendDocument
If Telegram media upload rejects an attachment, delivery falls back to text-only sendMessage so the command result is still visible.
Slack UX contract: explicit commands + confirmation actions
Slack ingress currently supports two accepted payload families on /webhooks/slack:
- Slash commands (
application/x-www-form-urlencoded), wherecommandmust be/mu - Events API callbacks (
application/json) wheretype=event_callbackandevent.type=app_mention
For both DMs and channels, /mu slash command execution remains explicit:
- Accepted slash command text must normalize to
/mu ... - Slack slash payloads with
command != /muare deterministic no-op with reasonslack_command_required - Slack event callbacks accept
app_mentiononly; other event types are deterministic no-op (unsupported_slack_event)
Linked vs unlinked actor semantics (Slack collaborative contract)
Slack keeps /mu parity while allowing safe mention-triggered conversational ingress for linked event actors:
- Linked actor + app mention containing explicit
/mu ...: command enters normal policy/confirmation pipeline. - Unlinked actor + app mention containing explicit
/mu ...: deterministic deny withidentity_not_linked. - Linked actor + non-
/muapp mention text: adapter setsmetadata.mu_conversational_ingress = "allow", so the turn routes through operator conversational handling. - Unlinked actor + non-
/muapp mention text: deterministic no-op withchannel_requires_explicit_command(no operator execution).
This preserves explicit /mu behavior while enabling linked Slack @mu ... conversational turns without creating an implicit path for unlinked actors.
Slack conversational retries (same event_id) are deduplicated for a short TTL so at-least-once delivery does not fan out duplicate long-running operator turns.
Slack conversational context is scoped per linked actor + channel + slack_thread_ts (top-level mentions use the mention message timestamp), so separate threads do not share operator memory.
When Slack bot token delivery is configured, conversational mention turns post an in-thread progress anchor and outbox delivery updates that anchor in-place with final output (chat.update) to reduce thread noise while preserving liveness.
Long-running turns also emit lightweight in-place progress checkpoints (for example, operator turn started + periodic heartbeat) and deterministic adapter audit rows (slack.progress_checkpoint.*, slack.operator_turn.*).
Conversational operator replies are delivered as plain chat bodies (not wrapped control-plane lifecycle scaffolding). Slack output applies light markdown normalization (for example, ### Heading → *Heading*) for better mrkdwn rendering.
Recommended secure collaboration mode: keep a single linked actor for the shared channel/thread, and rotate ownership via mu control unlink + mu control link when responsibility changes.
Override escape hatch (explicit opt-in, per inbound envelope):
- Command-only channels remain strict by default.
- Adapters may explicitly set
metadata.mu_conversational_ingress = "allow"on a specific inbound envelope to route that one turn through conversational operator handling. - Any other value (including booleans) is ignored.
- Slash command semantics are unchanged.
Slack thread anchoring contract
Slack reply anchoring is deterministic and source-preserving:
- Event callback path: if present,
event.thread_tsis used; elseevent.tsis used. - Interactive payload path: first non-empty value in
container.thread_ts,container.message_ts,message.thread_ts,message.tsis used. - The selected anchor is persisted as
metadata.slack_thread_tsand outbound delivery uses that asthread_ts.
If no usable thread timestamp is present, delivery degrades to non-threaded channel send without changing command lifecycle semantics.
Confirmation action payload contract (for Slack interactive surfaces)
To preserve parity with typed command semantics, interactive confirmation payloads must normalize to the same command pair:
confirm:<command_id>→/mu confirm <command_id>cancel:<command_id>→/mu cancel <command_id>
<command_id> constraints:
- non-empty
- no whitespace
- no additional
:separators
Unsupported/invalid action payloads (including malformed IDs or unknown action verbs) must be treated as deterministic no-op, with explicit audit reason unsupported_slack_action_payload, and must not mutate command lifecycle state.
Slack ACK/error/guidance copy style
Slack responses should stay concise and deterministic:
- ACK path (slash command immediate response): one-line status + short guidance, ephemeral
- Non-command guidance: "Slack ingress is command-only on this route. Use
/mu <command>for actionable requests." - Error surface: include canonical reason code in contract metadata and concise user text in rendered body
Behavioral invariant: interactive confirm/cancel buttons are convenience UI only; /mu confirm <id> and /mu cancel <id> remain the source-of-truth fallback paths.
Telegram callback/gating/chunking contract
Callback payload schema for inline confirmation buttons is intentionally narrow and deterministic:
- Supported callback payloads:
confirm:<command_id>cancel:<command_id>
<command_id>must not include whitespace or additional:separators.- Any other callback payload is rejected with
unsupported_telegram_callbackand an explicit callback ACK.
Behavioral invariants:
- Inline
Confirm/Cancelbuttons are convenience UI over the same command contract;/mu confirm <id>and/mu cancel <id>remain valid fallback parity paths. - Private chats may use conversational freeform turns via the operator runtime.
- Group/supergroup chats require explicit
/mu ...commands; freeform text is deterministic no-op with guidance. - Outbound text keeps deterministic order when chunked; chunks are emitted in-order and preserve full body reconstruction.
- Reply anchoring uses
telegram_reply_to_message_idwhen parseable; invalid anchor metadata gracefully falls back to non-anchored sends. - Attachment-ingest failures preserve deterministic audit metadata while user-visible guidance is mapped to concise recovery copy.
Text-only fallback invariants
- Existing text-only envelopes (no
attachments) continue to use channel text endpoints (chat.postMessagefor Slack,sendMessagefor Telegram). - Optional
attachmentsremain schema-compatible; text-only payloads continue to work without changes. - Telegram callback + group-gating behavior keeps the same command flow semantics because
/mu confirm|cancel <id>and explicit/mu ...command ingress semantics are unchanged.
Adapter contract
Adapter integration points are now explicitly specified in code (adapter_contract.ts):
ControlPlaneAdapterinterface (spec+ingest(req))ControlPlaneAdapterSpecSchema(channel, route, payload format, verification model, ACK format)AdapterIngressResultshape (acceptance, normalized inbound envelope, pipeline result, outbox record)
Built-in specs are exported for each first-platform adapter:
SlackControlPlaneAdapterSpecDiscordControlPlaneAdapterSpecTelegramControlPlaneAdapterSpecNeovimControlPlaneAdapterSpec
Default routes + verification contracts:
- Slack:
POST /webhooks/slackwithx-slack-signature+x-slack-request-timestamp - Discord:
POST /webhooks/discordwithx-discord-signature+x-discord-request-timestamp - Telegram:
POST /webhooks/telegramwithx-telegram-bot-api-secret-token - Neovim:
POST /webhooks/neovimwithx-mu-neovim-secret
This keeps adapter behavior consistent and makes it easier to add new surfaces without changing core pipeline semantics.
Inbound attachment retrieval policy (Option B baseline)
inbound_attachment_policy.ts codifies deterministic security controls for downloaded inbound files:
- Allowlist MIME types:
application/pdf,image/svg+xml,image/png,image/jpeg,image/webp,text/plain - Max size:
10 MiBper attachment - Channel download mode defaults: Slack + Telegram enabled, others disabled
- Malware hook policy: quarantine-on-suspect behavior with deterministic deny reason codes
- Dedupe requirements: channel file id and post-download content hash checks
- Retention defaults:
24hTTL for blobs + metadata (inboundAttachmentExpiryMs) - Redacted audit metadata shape for adapter audit rows (
reason_code, stage, policy marker)
Policy evaluators:
evaluateInboundAttachmentPreDownload(...)evaluateInboundAttachmentPostDownload(...)summarizeInboundAttachmentPolicy(...)
Both evaluators return deterministic allow/deny decisions and reason codes suitable for adapter audit/event logging.
For Telegram inbound media, attachment retrieval/policy failures are also converted into concise conversational guidance (while preserving raw deterministic audit reason codes in metadata) so users can recover by retrying with supported files or plain text.
Inbound attachment store + retention lifecycle
inbound_attachment_store.ts provides shared storage primitives for downloaded inbound files:
- deterministic blob layout under
control-plane/attachments/blobs/sha256/<aa>/<bb>/<hash>.<ext> - JSONL metadata index at
control-plane/attachments/index.jsonlwith append-only upsert/expire events - filename sanitization (
../../etc/passwd-style traversal removed) before persisted metadata - dedupe by
channel+source+source_file_idfirst, then content hash fallback - TTL cleanup (
cleanupExpired) that expires metadata and garbage-collects unreferenced blobs
Helpers:
buildInboundAttachmentStorePaths(controlPlaneDir)toInboundAttachmentReference(record)for adapter metadata references (source=mu-attachment:<channel>,file_id=<attachment_id>)
Interaction contract + visual presentation
Control-plane responses now use a deterministic interaction contract (interaction_contract.ts) and a shared presenter.
Contract fields
speaker:user | operator | mu_system | mu_tool(operatoris presented as Operator)intent:chat | ack | lifecycle | result | errorstatus:info | success | warning | errorstate: normalized lifecycle state (awaiting_confirmation,completed, etc.)summary: concise one-line summarydetails: deterministic key/value details withprimaryvssecondaryimportanceactions: suggested next commands (for example/mu confirm <id>)transition: optionalfrom -> tostate transitionpayload: structured JSON for expandable detail in rich clients
Rendering modes
- Compact: webhook ACK path (summary-first + key details)
- Detailed: deferred outbox delivery (summary + hierarchy + structured payload block)
Outbox metadata stores the structured contract alongside rendered text (interaction_message,
interaction_contract_version, interaction_render_mode) so follow-on channel renderers can build richer,
collapsible UI while preserving deterministic serialization.
Messaging operator + safe CLI triggers
MessagingOperatorRuntime (from @femtomc/mu-agent) is the user-facing operator runtime that sits outside execution dispatch. It translates conversational channel input into approved command proposals and routes them through the same policy/idempotency/confirmation pipeline.
CLI execution is constrained through an explicit allowlist (MuCliCommandSurface) and a non-shell runner (MuCliRunner).
Operator proposals can bridge readonly status/info queries (status, ready, issue list, issue get, forum read, operator config get, operator model list, operator thinking list) and mutating operator configuration actions (operator model set, operator thinking set). Mutations still require confirmation and are correlated end-to-end via:
operator_session_idoperator_turn_idcli_invocation_idcli_command_kind
Unsafe or ambiguous requests are rejected with explicit reasons (context_missing, context_ambiguous, context_unauthorized, cli_validation_failed, etc.).
Frontend client helpers
frontend_client_contract.ts + frontend_client.ts expose typed helpers for first-party editor clients:
- server discovery (
<store>/control-plane/server.json) - channel capability fetch (
/api/control-plane/channels) - identity link bootstrap (
/api/control-plane/identities/link) - frontend ingress submission (
/webhooks/neovim) - session turn injection (
/api/control-plane/turn) for real in-session turns with reply + context cursor
These helpers are intended to keep Neovim integration clients aligned with control-plane channel contracts.
iMessage status
iMessage is not supported by this runtime. Identity rows use first-party channels (slack, discord, telegram, neovim). Unsupported channels are rejected during replay.