pramaan-functions · Story-3038 · EPIC-3034
Channel keywords, with or without a leading slash
/help · /matters
· /current. Until this ships, none of those three worked
— the first message every new user receives advertised three dead
commands. This PR makes them real, plus /status,
/disconnect and its Meta opt-out alias /stop.
The founder ruling: one normalizer, one table, no second path
Every keyword works with or without a leading
/. help and /help hit the same
pre-saved static answer. There is exactly ONE normalizer (strip an optional
leading slash) and ONE keyword table — never two code paths that could
drift apart. Everything is intercepted at the ingress seam, the same place
that already consumes CONFIRM, so no keyword ever starts an
agent run (ADR-166: these are deterministic lookups, and lambdas move).
| Types either | Answer | Built here |
|---|---|---|
help · /help | the capability list, in Mira’s voice | new |
status · /status | this number, firm, connected date, last security check | new |
current · /current | which matter am I on | alias — routes to the existing scope answer |
matters · /matters | the matters this sender can reach | bare form was live; the slash now works too |
disconnect · /disconnect | unlink this number | new |
stop · /stop | alias of disconnect (Meta opt-out expectation) | new |
The single source of truth (owner’s 2026-07-28 addendum)
The command registry is built in pramaan-functions, not
reached across into pramaan-agents (its capabilities_markdown()
serves the LLM mapping route, not counsel-facing copy). The registry lives
in src/shared/whatsapp/commands.py — pure, domain-free,
beside formatter.py. It is the ONE source that will feed three
consumers:
/helprenders its list from the registry (never a hand-maintained duplicate — the one outcome the brief said to avoid);- Meta’s
conversational_automationsync (Chore-3039) will readcommand_catalog(); - a
GET /app/channels/commandssettings endpoint (a separate ticket — not built here, but the registry already serialises for it).
Voice is a requirement, not polish: if the broker answers /help
with a system string it sounds like a system; if it sounds like Mira, it is
Mira. So every reply is authored as product copy in Mira’s
voice and stored in the registry; the broker renders it verbatim
— same voice, zero tokens, byte-identical output.
Hi — I'm Mira. Here's what I can do for you right here:
• *matters* — see the matters you can work on
• *current* — check which matter we're on right now
• *status* — see how this number is connected
• *disconnect* — unlink this number from Mira
• *help* — list everything I can do here
Or just tell me what you need on a matter and I'll get to work. Every
command works with or without a slash, so *help* and */help* both reach me.
The rule that stops this breaking: anchoring
The BARE form must match the WHOLE message (anchored); the SLASH form may
carry arguments. Otherwise “help me summarise this order” would
fire the static help menu instead of reaching Mira, and “current
status of the appeal” would answer the wrong question. A leading slash
is an explicit command sigil, so /current status of the appeal
is deliberately still current.
| Message | Classified as |
|---|---|
help, /help, matters?, /matters | a command |
help me summarise this order | not a command → Mira |
current status of the appeal | not a command → Mira |
/current status of the appeal | current (explicit sigil) |
The trap the brief flagged: _LIST_RE in
disambiguation.py was ^\s*(?:my\s+)?matters\s*\??\s*$,
so /matters failed it. The fix is the normalizer stripping the
slash BEFORE any anchored match. The bare matters keyword now
belongs to the ONE command table; _LIST_RE was narrowed to its
natural-language doors only (“list my matters”, “what
matters can I see”, the “my matters” turn), so the keyword
is never handled in two places.
/current and /matters are one function, two doors
/current routes to answer_current_scope (the old
private _answer_scope, now public) — the same function the
natural-language regex already reached. The NL form
(“which matter are we on?”) keeps working: this is an additional
door, never a replacement, and one function means the two doors can never
answer differently. Likewise /matters and “list my
matters” both render through _list_decision and persist
the same open-matter-menu marker (UAT M-02), so a number typed after
/matters resolves exactly as after the NL form. There is one
spelling of the RLS-bounded matter read (load_channel_contexts,
a thin public wrapper; _load_firm_contexts stays private).
Founder copy change: when no matter is set, the scope answer now ASKS,
mirroring the welcome — We're not on a matter yet. Which matter
do you want to work on? Reply *matters* to see yours.
/disconnect and /stop: the real capability gap
Today disconnecting requires the app — which fails exactly the person
who can only reach WhatsApp. The handler unbinds the sender’s
own binding(s) only: live is the sender’s
own active per-firm bindings (resolved from the platform-verified subject,
never another member’s, never another firm’s), and each is
revoked through the app’s own self-unbind path
service.unbind — which re-checks caller ownership under
the firm-scoped session and kills the binding’s fetch tokens, sticky
context and pending confirmations exactly as the app does (R5, no second
spelling of revocation). The confirmation is sent AFTER the binding died,
via the terminal-notice path (enforce_live_binding=False).
Why an unbound sender learns nothing (EPIC-3034 D6)
Commands are intercepted only for a shield-passed, live-bound sender, at the
same seam as CONFIRM and BEFORE disambiguation. A stranger
typing help is refused by the zero-amp shield first — the
keyword is never an oracle, and the refusal stays byte-identical whether a
command word was aimed at an inaccessible matter or a non-existent one.
Commands are also never cap-limited (they start no run) and never trigger an
interstitial (that timer starts only for turns that reach an agent).
Where it plugs into the seam
record_inbound (dedupe) -> shield -> live bindings -> media
-> confirmation reply (YES/NO)
-> handle_channel_command(message_text, live, to) <-- Story-3038, before disambiguation
classify_command (strip '/', one table)
help -> capabilities_text() (pure)
status -> status_text(number, per-firm connections)
current -> disambiguation.answer_current_scope
matters -> disambiguation.answer_matters_list (+ menu marker)
disconnect/stop -> service.unbind per own binding, confirm after death
True -> mark inbound processed, no enqueue, continue
False -> fall through to resolve_channel_turn
What is proven, and what is not
- Unit (
test_whatsapp_commands.py): every keyword bare AND with a slash (andstop→disconnect); the anchoring collisions reach Mira;/helpis built from the registry; the catalog serialises one row per command with no alias rows; the status and disconnect copy. - Unit (
test_channel_commands.py): the handler routes each keyword to the right collaborator;/disconnectunbinds only the sender’s own live bindings and confirms after the binding died;/currentwith and without a sticky matter. - Regression: the disambiguation decision, zero-amp shield, observability and webhook-handler unit suites stay green with the interception inserted; the welcome footer pin is unchanged (the agreed set did not change).
- Not yet proven — live handset UAT is outstanding.
The final proof is a real phone:
helpand/helpreturning the same list,/disconnectactually unlinking, and “help me summarise this order” reaching Mira rather than the menu. That is a manual post-merge step and is not claimed done here.