@sjawhar/opencode-legion-envoy 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sjawhar/opencode-legion-envoy",
3
- "version": "0.7.0",
3
+ "version": "0.9.0",
4
4
  "type": "module",
5
5
  "main": "dist/src/server.js",
6
6
  "exports": {
@@ -21,14 +21,16 @@
21
21
  "files": [
22
22
  "dist",
23
23
  "src",
24
- "!src/**/__tests__"
24
+ "!src/**/__tests__",
25
+ "skills"
25
26
  ],
26
27
  "scripts": {
27
28
  "build": "bun build src/server.ts bin/dispatch-mcp-shim.ts --outdir dist --target bun --format esm --external '@opencode-ai/*'",
28
- "prepack": "bun run build",
29
+ "prepack": "bun run build && rm -rf skills && cp -r ../../skills skills",
29
30
  "typecheck": "bunx tsc --noEmit",
30
31
  "test": "bun test",
31
- "lint": "bunx biome check src/"
32
+ "lint": "bunx biome check src/",
33
+ "postpack": "rm -rf skills"
32
34
  },
33
35
  "dependencies": {
34
36
  "@opencode-ai/plugin": "~1.14.46"
@@ -0,0 +1,39 @@
1
+ # Skills Layer
2
+
3
+ Legion skills guide the architect and its sequential phase workers in a shared issue workspace.
4
+ They are Markdown instructions loaded by Oh My Pi sessions; the daemon and OMP extension own
5
+ event intake, process lifecycle, credentials, and role delivery.
6
+
7
+ ## Structure
8
+
9
+ ```
10
+ skills/
11
+ ├── dispatch/ # Raising a durable human question as a GitHub-issue thread
12
+ ├── github/ # GitHub issue and pull-request operations
13
+ ├── linear/ # Linear adapter instructions
14
+ ├── legion-architect/ # Tree ownership, decomposition, gates, and scheduling
15
+ ├── legion-controller/ # Derived-verdict control-plane operation
16
+ ├── legion-oracle/ # Repository-grounded research
17
+ ├── legion-retro/ # Post-review retrospective
18
+ └── legion-worker/ # Sequential architect, plan, implement, test, and review phases
19
+ ```
20
+
21
+ ## Phase workers
22
+
23
+ The extension supplies a phase worker with its issue, workspace, role token, and structured
24
+ output schema. The worker claims its supplied role, works only on its phase artifact, and
25
+ returns that schema to the architect. It writes the same phase-specific payload to
26
+ `.legion/<phase>.json`, verifies it exists, and commits the handoff before reporting completion.
27
+ The committed predecessor handoff wins after revival or re-creation.
28
+
29
+ Workers do not run a controller loop or mutate lifecycle labels. Workers coordinate
30
+ lifecycle, scope, and cross-phase decisions with the owning architect through hub, sending
31
+ the verified observation and decision needed. A worker may use the `dispatch` MCP tool
32
+ directly for a durable human question; replies come back to the worker's own session.
33
+
34
+ ## Durable artifacts
35
+
36
+ Phase handoffs are committed in lifecycle order: architect, plan, implement, test, and review.
37
+ The reviewer removes `.legion/` as its final commit; retro records its learning in
38
+ `docs/solutions/` and writes no handoff. GitHub comments and reviews carry the required Legion
39
+ footer so the daemon can attribute artifacts to their worker session.
@@ -0,0 +1,94 @@
1
+ ---
2
+ name: dispatch
3
+ description: Use when you need a human decision from Sami or the user — asking a question, waiting on approval, saying "let me know", writing an end-of-message question block, picking between options with tradeoffs, or starting unattended/long-running work that will need input later. Raises the question as a durable GitHub-issue thread instead of a transcript question nobody will see.
4
+ ---
5
+
6
+ # Dispatch
7
+
8
+ `dispatch` turns a question into a durable, dashboard-visible GitHub-issue thread instead
9
+ of a line buried in a transcript. Use it any time you would otherwise leave a question for
10
+ a human to notice on their own.
11
+
12
+ ## When to dispatch
13
+
14
+ - Any question not answerable at the keyboard in seconds.
15
+ - Anything decision-shaped: a fork in approach, a tradeoff only the human can weigh, an
16
+ approval gate.
17
+ - Anything you would otherwise write as an end-of-message question block and hope someone
18
+ reads.
19
+ - Before you park on a blocker. Never leave a session idle on an unasked question.
20
+ - Unattended or long-running work (Legion roles, background agents) that will need input
21
+ later — dispatch the question as soon as you know you'll need it, don't wait to be asked.
22
+
23
+ ## When NOT to dispatch
24
+
25
+ - A one-word clarification the human is actively typing with you right now, in the same
26
+ turn — use the built-in `ask` tool instead. `dispatch` is for questions the reader has
27
+ not seen your transcript for; `ask` is for questions inside a live conversation.
28
+ - Anything you can resolve yourself from tools or repo context. Dispatch is for genuine
29
+ human decisions, not a substitute for research.
30
+
31
+ ## How
32
+
33
+ ```
34
+ dispatch({
35
+ subject: "Postgres migration: run online or take a maintenance window?",
36
+ context: "Implementing the users-table index migration for issue #482. The table has
37
+ 40M rows; a plain CREATE INDEX locks writes for ~6 minutes in the staging
38
+ timing test I just ran.",
39
+ question: "Current: no online-migration tooling wired into this repo's deploy pipeline.
40
+ Desired: index added without a customer-visible write outage.
41
+ Option A — CREATE INDEX CONCURRENTLY, no lock, ~25 min, can't run inside a
42
+ transaction (rules out the existing migration runner without a change).
43
+ Option B — take a 6-minute maintenance window during the 03:00 UTC low-traffic
44
+ slot, keep the existing runner unchanged.
45
+ Recommendation: B — smaller blast radius, no migration-runner change, and the
46
+ window is well inside the existing maintenance SLA.",
47
+ urgency: "med"
48
+ })
49
+ ```
50
+
51
+ - `subject`: one line, the decision — this is the issue title and the dashboard row.
52
+ - `context` (required): what you are doing, what you found, why you are stuck. **The
53
+ reader has not seen your transcript** — never write "the list above," "those items," or
54
+ anything that assumes shared context. State it fresh.
55
+ - `question` (required): current state → desired state → proposed change, then options
56
+ with tradeoffs, then your own recommendation. Don't just describe the fork — say which
57
+ branch you'd take and why.
58
+ - `ask` (optional): a list of `{ question, header?, options: [{ label, description? }] }`
59
+ when the decision is a discrete set of choices. These render as buttons on the dashboard
60
+ — use this instead of asking the human to type a free-text answer when the answer really
61
+ is "pick one of N."
62
+ - `urgency` (optional, default `med`):
63
+ - `low` — whenever it's convenient, no deadline pressure.
64
+ - `med` — needed today.
65
+ - `high` — blocking something that will stall soon if unanswered.
66
+ - `blocking` — you cannot proceed at all right now.
67
+ - `repo` (optional): only pass this to target a **different** repo than your current
68
+ working directory's GitHub remote. The shim fills `repo` from cwd automatically —
69
+ omit it in the common case.
70
+ - `parent` (optional): only set this when a real issue already exists to attach to —
71
+ `<n>` for an issue in the resolved repo, or `owner/name#<n>` to point at a specific
72
+ issue in another repo. For Legion roles, `parent` is the root issue you're working
73
+ under. Omitting it creates a standalone top-level thread; don't invent a parent that
74
+ doesn't exist.
75
+
76
+ ## After dispatching
77
+
78
+ The reply arrives back in your own session as a steer — you do not fetch it. Keep every
79
+ other non-blocked lane of work moving while you wait; a dispatch is not a reason to go
80
+ idle. Never poll GitHub for the reply and never set up a watcher, cron, or retry loop to
81
+ check on it — the delivery mechanism is already push-based. The issue URL returned by the
82
+ tool (`{"thread": N, "url": "..."}`) is the durable record; you don't need to keep it
83
+ anywhere else.
84
+
85
+ If the tool errors because it can't resolve a repo (cwd has no GitHub remote and no
86
+ qualified `parent`), pass `repo=owner/name` explicitly and retry.
87
+
88
+ **Named risk:** a thread created in a repo where the Envoy GitHub App is not installed
89
+ gets created successfully, but replies to it never route back to your session — you'll
90
+ wait forever for a steer that can't arrive. Coverage is every repo under `sjawhar`, plus
91
+ `acme-org/example-repo` and `acme-org/example-e2e` — the App is
92
+ deliberately not installed org-wide. That list can change; to check a repo before relying
93
+ on a reply, `gh api /repos/<owner>/<name>/installation` returns 200 when the App is
94
+ installed there and 404 when it is not.
@@ -0,0 +1,404 @@
1
+ ---
2
+ name: envoy
3
+ description: Use when subscribing sessions to Envoy topics, sending agent-to-agent messages, or reasoning about topic formats for Slack/GitHub/agent routing.
4
+ ---
5
+
6
+ # Envoy
7
+
8
+ Envoy is Legion's event-routing subsystem. It delivers Slack, GitHub, and agent-to-agent events to OpenCode sessions.
9
+
10
+ ## What the tools do
11
+
12
+ - `envoy_subscribe(topics)` — make the current session RECEIVE future events on those topics
13
+ - `envoy_unsubscribe(topics?)` — stop receiving some or all topics
14
+ - `envoy_list()` — show the union of live local and persisted registry subscriptions, with each topic marked `live`, `registry`, or `both`
15
+ - `envoy_send(session_id, message)` — SEND a message directly to another session
16
+
17
+ ## Topic formats
18
+
19
+ ### Agent-to-agent
20
+
21
+ - Direct session route:
22
+ - `notifications.agent.<session_id>`
23
+
24
+ Example:
25
+
26
+ - `notifications.agent.ses_2e6ca3034ffejVikSZ8mDwk0mR`
27
+
28
+ ### Legion role claims
29
+
30
+ - Role topic:
31
+ - `notifications.role.<role>`
32
+
33
+ Legion agents receive through a daemon-minted role token. Claim the role for the current
34
+ session with `envoy_role_set(role="<role>")`; claiming transfers its holder with
35
+ last-claim-wins semantics. A claimant does not manually subscribe to its role topic.
36
+
37
+ Legion role tokens must satisfy `^[a-z0-9][a-z0-9_-]*$` and are unique across repositories:
38
+
39
+ ```text
40
+ legion-<project>-controller
41
+ legion-<project>-<enc(owner)>__<enc(repo)>-<number>-<role>
42
+ ```
43
+
44
+ `<project>` matches `[a-z0-9]+`. In owner and repository components, the injective escape
45
+ encoding is `_` → `_u`, `.` → `_d`, and `-` → `_h`; `__` separates owner from repository.
46
+ The daemon owns the authoritative token-to-issue map and hands the token to each process.
47
+ Do not construct a token from a partial issue reference.
48
+
49
+ Claims last through the issue's post-close linger. They survive parking and worker
50
+ re-creation; a re-claim re-points the role to the backing session. The daemon publishes to an
51
+ issue role only while the issue is active. Inactive issue events become daemon state and later
52
+ surface as derived catch-up, never as raw event replay.
53
+
54
+ ### Legion exception lane
55
+
56
+ `no_holder` and `delivery_failed` on a Legion role are daemon liveness signals, not a prompt
57
+ for a second subscriber or a manual retry. The daemon probes the process that owns the tree:
58
+
59
+ 1. If it is alive, the daemon sends a control-topic directive. The extension revives or
60
+ re-creates the backing worker in code, then the daemon re-delivers the message.
61
+ 2. If it is dead, the daemon resurrects the root process behind a generation lock and supplies
62
+ derived catch-up plus the shared workspace handoffs.
63
+
64
+ This keeps raw delivery failures out of architect context. Controller exception wakes are
65
+ handled by the controller's wake routing table; every other role follows the liveness path.
66
+
67
+ ### GitHub
68
+
69
+ GitHub topics are **resource-scoped** — every event includes the resource type and number (or, for push/workflow events, the ref or workflow filename).
70
+
71
+ **Topic structure:** `notifications.github.<owner>.<repo>.<resource_type>.<number>.<event_kind>`
72
+
73
+ - PR opened/closed/merged/ready:
74
+ - `notifications.github.<owner>.<repo>.pr.<number>`
75
+ - Issue opened/closed/labeled:
76
+ - `notifications.github.<owner>.<repo>.issue.<number>`
77
+ - Comment on a PR:
78
+ - `notifications.github.<owner>.<repo>.pr.<number>.comment`
79
+ - Comment on an issue:
80
+ - `notifications.github.<owner>.<repo>.issue.<number>.comment`
81
+ - PR review submitted:
82
+ - `notifications.github.<owner>.<repo>.pr.<number>.review`
83
+ - Raw CI check observation (per-PR, immediate):
84
+ - `notifications.github.<owner>.<repo>.pr.<number>.check`
85
+ - Every PR-associated `check_run` publishes one raw observation per associated PR. `Payload` is JSON with `sha`, `name`, `status`, and `conclusion`; `PayloadSummary` names the check and its state.
86
+ - CI/check summary (per-PR, per-commit, debounced):
87
+ - `notifications.github.<owner>.<repo>.pr.<number>.ci`
88
+ - The same `check_run` updates ingest-side per-commit state in JetStream KV bucket `envoy_ci_state`. Once the check set has been quiet for `ENVOY_CI_DEBOUNCE` (default `5s`), Envoy publishes one JSON summary. A new summary is emitted only when the check set changes; a new push starts a fresh tally, and a re-run that returns to running changes the tally.
89
+ - `PayloadSummary` is a compact JSON object (`Payload` unused): `{"kind":"ci_summary","repo":"<o>/<r>","number":"<n>","sha":"<sha>","failed":{"count":N,"checks":[...]},"running":{...},"passed":{...},"queued":{...},"skipped":{...}}`. Each status is `{count, checks}` with the full sorted name list; every status is present (`{"count":0,"checks":[]}` when empty).
90
+ - `check_suite` is ignored (it is a per-app rollup without a per-check name). Checks not tied to a PR are dropped, so there is no repo-wide CI topic. For non-PR visibility, use `workflow.<filename>.<action>`; individual GitHub Actions `workflow_job` events are not routed.
91
+ - Mention events (per-resource):
92
+ - `notifications.github.<owner>.<repo>.pr.<number>.mention`
93
+ - `notifications.github.<owner>.<repo>.issue.<number>.mention`
94
+ - Mention events (repo-wide — catches all mentions):
95
+ - `notifications.github.<owner>.<repo>.mention`
96
+ - Push events (per branch/tag):
97
+ - `notifications.github.<owner>.<repo>.push.branch.<branch>`
98
+ - `notifications.github.<owner>.<repo>.push.tag.<tag>`
99
+ Branch and tag names with dots are sanitized to underscores (`v1.0.0` → `v1_0_0`).
100
+ Push events for refs other than `refs/heads/...` and `refs/tags/...` are not routed.
101
+ - Workflow run events (per workflow file):
102
+ - `notifications.github.<owner>.<repo>.workflow.<filename>.<action>`
103
+ Filename is the basename of `workflow_run.path` with dots sanitized (`ci.yml` → `ci_yml`).
104
+ `action` is one of `requested`, `in_progress`, `completed`.
105
+
106
+ **Using wildcards to subscribe broadly:**
107
+ - All events in a repo: `notifications.github.<owner>.<repo>.>`
108
+ - All PR events in a repo: `notifications.github.<owner>.<repo>.pr.>`
109
+ - All events for a specific PR: `notifications.github.<owner>.<repo>.pr.<number>.>`
110
+ - All issue events in a repo: `notifications.github.<owner>.<repo>.issue.>`
111
+ - All events for a specific issue: `notifications.github.<owner>.<repo>.issue.<number>.>`
112
+ - All push events in a repo: `notifications.github.<owner>.<repo>.push.>`
113
+ - Pushes to main only: `notifications.github.<owner>.<repo>.push.branch.main`
114
+ - All branch pushes: `notifications.github.<owner>.<repo>.push.branch.>`
115
+ - All tag pushes: `notifications.github.<owner>.<repo>.push.tag.>`
116
+ - All workflow events: `notifications.github.<owner>.<repo>.workflow.>`
117
+ - All events for a specific workflow: `notifications.github.<owner>.<repo>.workflow.ci_yml.>`
118
+ - Every workflow completion: `notifications.github.<owner>.<repo>.workflow.*.completed`
119
+
120
+ Examples:
121
+
122
+ - `notifications.github.example-org.example-repo.pr.9880` (PR #9880 state changes)
123
+ - `notifications.github.example-org.example-repo.pr.9880.comment` (comments on PR #9880)
124
+ - `notifications.github.example-org.example-repo.issue.9909.>` (all events on issue #9909)
125
+ - `notifications.github.sjawhar.legion.pr.>` (all PR events across all PRs)
126
+ - `notifications.github.sjawhar.legion.mention` (all @mentions repo-wide)
127
+ - `notifications.github.sjawhar.legion.push.branch.main` (pushes to main)
128
+ - `notifications.github.sjawhar.legion.workflow.ci_yml.in_progress` (CI workflow starts)
129
+
130
+ ### Slack
131
+
132
+ - Channel message events:
133
+ - `notifications.slack.<team_id>.<channel_id>.message`
134
+ - App mention events:
135
+ - `notifications.slack.<team_id>.<channel_id>.mention`
136
+ - Thread message events:
137
+ - `notifications.slack.<team_id>.<channel_id>.thread.<normalized_ts>.message`
138
+ - Thread mention events:
139
+ - `notifications.slack.<team_id>.<channel_id>.thread.<normalized_ts>.mention`
140
+
141
+ Thread timestamps are normalized: `1234567890.123456` → `1234567890_123456`
142
+ (dots replaced with underscores to make the thread ID a single NATS segment).
143
+
144
+ Examples:
145
+
146
+ - `notifications.slack.T01234567.C0A0DHVU8HE.message`
147
+ - `notifications.slack.T01234567.C0A0DHVU8HE.mention`
148
+ - `notifications.slack.T01234567.C0A0DHVU8HE.thread.1234567890_123456.message`
149
+ - `notifications.slack.T01234567.C0A0DHVU8HE.thread.1234567890_123456.mention`
150
+
151
+ ### Ghost Wispr
152
+
153
+ - Session started events:
154
+ - `notifications.ghostwispr.<session_id>.session.started`
155
+ - Session ended events:
156
+ - `notifications.ghostwispr.<session_id>.session.ended`
157
+ - Summary ready events:
158
+ - `notifications.ghostwispr.<session_id>.summary.ready`
159
+
160
+ **Parameters:**
161
+ - `<session_id>`: Ghost Wispr session timestamp string (e.g., `20260326041405`). Alphanumeric only, safe for NATS topic segments.
162
+ - Supported kinds: `session.started`, `session.ended`, `summary.ready`
163
+
164
+ Examples:
165
+
166
+ - `notifications.ghostwispr.20260326041405.session.ended` (session ended)
167
+ - `notifications.ghostwispr.20260326041629.summary.ready` (summary ready)
168
+
169
+ ### WhatsApp
170
+
171
+ - Chat message events:
172
+ - `notifications.whatsapp.<phone>.<jid>.message`
173
+ - Status/receipt events:
174
+ - `notifications.whatsapp.<phone>.<jid>.status`
175
+
176
+ **Parameters:**
177
+ - `<phone>`: Connected WhatsApp account phone number in E.164 digits-only format (no `+` prefix). Example: `15551234567`. This identifies **which WhatsApp account** the events belong to — not the remote contact.
178
+ - `<jid>`: Remote chat's WhatsApp JID. Individual: `PHONE@s.whatsapp.net`. Group: `ID@g.us`.
179
+ - Supported kinds: `message`, `status`
180
+
181
+ > **⚠️ JID dot expansion:** JID dots (`.`) become additional NATS subject tokens. For example, `5551234567@s.whatsapp.net` splits into tokens `5551234567@s`, `whatsapp`, `net`. This means individual chat topics produce 7 tokens and group chat topics produce 6 tokens. **Always use `>` (multi-level wildcard), never `*` (single-token wildcard)**, when subscribing to a chat or phone number.
182
+
183
+ Examples:
184
+
185
+ - `notifications.whatsapp.15551234567.5551234567@s.whatsapp.net.message` (individual chat messages — note this expands to 7 NATS tokens)
186
+ - `notifications.whatsapp.15551234567.120363XXX@g.us.message` (group chat messages — 6 NATS tokens)
187
+
188
+ ## When to use what
189
+
190
+ ### To receive future Slack/GitHub/WhatsApp events
191
+
192
+ 1. Decide the exact topic(s)
193
+ 2. Call `envoy_subscribe([...])`
194
+ 3. Optionally call `envoy_list()` to confirm
195
+
196
+ ### To talk directly to another agent/session
197
+
198
+ 1. Get the target session ID
199
+ 2. Call `envoy_send(session_id, message)`
200
+
201
+ You do NOT need to subscribe in order to send or publish.
202
+
203
+ **Session ids are not stable for the life of a conversation.** On OMP, `/fork` and `/handoff`
204
+ re-mint the session id while the conversation continues (esc-esc rewinds also did, on omp
205
+ 18.1.0–18.1.2 only); the extension rebinds automatically and injects an `envoy` notice naming
206
+ the previous and new ids. When that notice arrives, any id you shared earlier (an
207
+ `envoy_whoami` result quoted in a message, an id a peer saved) is stale — re-run
208
+ `envoy_whoami` and re-announce yourself. Never treat a whoami result from earlier in the
209
+ transcript as current when identifying yourself to peers.
210
+
211
+ ### To wait for CI, PR checks, or other async work
212
+
213
+ **Don't `sleep`-poll. Don't "check back in N minutes."** Subscribe to the event and continue with productive work — the system will wake the session when the event arrives.
214
+
215
+ 1. Identify the relevant topic (e.g., `notifications.github.<owner>.<repo>.pr.<num>.>` for all PR events; `.pr.<num>.check` for immediate per-check state; `.pr.<num>.ci` for the debounced PR summary; `.workflow.<filename>.completed` for a workflow run finishing)
216
+ 2. Call `envoy_subscribe([...])`
217
+ 3. Move on to other work, or end the response and let the watcher wake you
218
+ 4. The next response is triggered by the event, with the payload available in your context
219
+
220
+ If you have nothing else to do, end the response. The user is not your alarm clock; do not loop with `sleep`.
221
+
222
+ ### Tools
223
+
224
+ - `envoy_subscribe(topics)` — receive future events on those topics
225
+ - `envoy_unsubscribe(topics?)` — stop receiving some or all topics
226
+ - `envoy_list()` — show live and persisted subscriptions, marked by source
227
+ - `envoy_send(session_id, message)` — send directly to a specific session (point-to-point)
228
+ - `envoy_publish(topic, message)` — publish a normal topic to matching subscribers or route a role topic to its current holder
229
+ - `envoy_role_set(role)` — claim a named role for the current session (exactly-one-holder)
230
+
231
+ ## Patterns
232
+
233
+ ### Subscribe controller to a specific Slack channel mentions
234
+
235
+ ```text
236
+ envoy_subscribe([
237
+ "notifications.slack.T01234567.C0A0DHVU8HE.mention"
238
+ ])
239
+ ```
240
+
241
+ ### Subscribe to all events in a specific Slack thread
242
+
243
+ ```text
244
+ envoy_subscribe([
245
+ "notifications.slack.T01234567.C0A0DHVU8HE.thread.1234567890_123456.>"
246
+ ])
247
+ ```
248
+
249
+ ### Subscribe to only messages in a Slack thread (not mentions)
250
+
251
+ ```text
252
+ envoy_subscribe([
253
+ "notifications.slack.T01234567.C0A0DHVU8HE.thread.1234567890_123456.message"
254
+ ])
255
+ ```
256
+
257
+ ### Subscribe to all threads in a Slack channel
258
+
259
+ ```text
260
+ envoy_subscribe([
261
+ "notifications.slack.T01234567.C0A0DHVU8HE.thread.>"
262
+ ])
263
+ ```
264
+
265
+ ### Subscribe to all PR events for example-repo
266
+
267
+ ```text
268
+ envoy_subscribe([
269
+ "notifications.github.example-org.example-repo.pr.>"
270
+ ])
271
+ ```
272
+
273
+ ### Subscribe controller to GitHub @mentions for example-repo
274
+
275
+ ```text
276
+ envoy_subscribe([
277
+ "notifications.github.example-org.example-repo.mention"
278
+ ])
279
+ ```
280
+
281
+ ### Message another session directly
282
+
283
+ ```text
284
+ envoy_send(
285
+ session_id="ses_2e6ca3034ffejVikSZ8mDwk0mR",
286
+ message="Please continue the smoke test"
287
+ )
288
+ ```
289
+
290
+ ### Subscribe to a specific WhatsApp contact (1:1 chat)
291
+
292
+ ```text
293
+ envoy_subscribe([
294
+ "notifications.whatsapp.15551234567.5551234567@s.whatsapp.net.>"
295
+ ])
296
+ ```
297
+
298
+ Use `>` (not `*`) to catch all event kinds despite JID dot expansion into multiple NATS tokens.
299
+
300
+ ### Subscribe to a WhatsApp group
301
+
302
+ ```text
303
+ envoy_subscribe([
304
+ "notifications.whatsapp.15551234567.120363XXX@g.us.>"
305
+ ])
306
+ ```
307
+
308
+ ### Subscribe to all WhatsApp events for an account
309
+
310
+ ```text
311
+ envoy_subscribe([
312
+ "notifications.whatsapp.15551234567.>"
313
+ ])
314
+ ```
315
+
316
+ Catches all conversations and event kinds for the specified phone number.
317
+
318
+ **When to use which:**
319
+ - **1:1 chat** — when monitoring a specific contact conversation (e.g., a bot handling customer queries)
320
+ - **Group chat** — when monitoring a specific group for commands or events
321
+ - **All chats for a phone** — when building a general WhatsApp event handler or dashboard for an account
322
+
323
+ ## Important notes
324
+
325
+ - Sessions choose their own Slack/GitHub subscriptions
326
+ - Different sessions can subscribe to different channels/repos
327
+ - Agent-to-agent delivery uses exact session IDs
328
+ - `envoy_list()` distinguishes `live`, `registry`, and `both`; a `live` topic is receiving now even when the listener registry has not caught up.
329
+ - For Slack, use the real `team_id` in topics (for example `T01234567`), not a workspace slug like `acme`
330
+ - GitHub mention routing is body-based because GitHub has no dedicated app mention webhook event
331
+
332
+ ## Synthetic Smoke Test (WhatsApp — NATS Routing Only)
333
+
334
+ > **Important:** This procedure validates Envoy's NATS → listener → session delivery path using `envoy_publish`. It does **not** test real WhatsApp message ingestion. The generic MCP bridge (`packages/envoy/cmd/mcp/`) can bridge real WhatsApp events, but requires production configuration. See "Current Limitations" below.
335
+ >
336
+ > **Two sessions required:** `envoy_publish` sets `source_session` to the publishing session's ID. The listener skips delivering broadcasts back to the sender (`packages/envoy/cmd/listener/main.go`). You must subscribe in one session and publish from a different session.
337
+
338
+ ### Step 1: Subscribe to a WhatsApp topic (Session A)
339
+
340
+ ```text
341
+ envoy_subscribe([
342
+ "notifications.whatsapp.15551234567.5551234567@s.whatsapp.net.>"
343
+ ])
344
+ ```
345
+
346
+ ### Step 2: Verify subscription is active (Session A)
347
+
348
+ ```text
349
+ envoy_list()
350
+ ```
351
+
352
+ Confirm `notifications.whatsapp.15551234567.5551234567@s.whatsapp.net.>` appears as `live` or `both`.
353
+
354
+ ### Step 3: Publish a synthetic test envelope (Session B — a different session)
355
+
356
+ ```text
357
+ envoy_publish(
358
+ topic="notifications.whatsapp.15551234567.5551234567@s.whatsapp.net.message",
359
+ message="Synthetic WhatsApp smoke test: hello from envoy_publish"
360
+ )
361
+ ```
362
+
363
+ ### Step 4: Verify delivery (Session A)
364
+
365
+ Session A should receive a notification containing the text "Synthetic WhatsApp smoke test: hello from envoy_publish". This confirms:
366
+ - The topic pattern matches the subscription
367
+ - NATS routes the message to the listener
368
+ - The listener delivers to the subscribed session (Session A ≠ the publishing session)
369
+
370
+ **If the notification does not arrive:** Check `envoy_list()` in Session A. The topic should be `live` or `both`; `registry` alone does not confirm the local subscription is receiving. Verify the topic in `envoy_publish` matches the subscription pattern. Ensure you are publishing from a **different** session than the one subscribed.
371
+
372
+ ### Reference: Real WhatsApp Envelope Shape
373
+
374
+ When the MCP bridge (`packages/envoy/internal/mcpbridge/envelope.go`) publishes a real WhatsApp event, the Envoy envelope has this structure:
375
+
376
+ ```json
377
+ {
378
+ "event_id": "<generated unique ID>",
379
+ "source": "whatsapp",
380
+ "source_event_id": "whatsapp://messages/15551234567/5551234567@s.whatsapp.net",
381
+ "topic": "notifications.whatsapp.15551234567.5551234567@s.whatsapp.net.message",
382
+ "dedupe_key": "whatsapp.<event_id value>",
383
+ "issued_at": 1712345678000,
384
+ "payload_summary": "Hello from WhatsApp",
385
+ "payload_ref": "whatsapp://messages/15551234567/5551234567@s.whatsapp.net",
386
+ "trace_id": "<generated unique ID>"
387
+ }
388
+ ```
389
+
390
+ **Field notes:**
391
+ - `source` is `"whatsapp"` — in contrast, `envoy_publish` sets `source: "agent"` for synthetic messages
392
+ - `issued_at` is in **milliseconds** (Unix epoch ms), not seconds
393
+ - `dedupe_key` is `source + "." + event_id` (e.g., `"whatsapp.cuid_abc123"`)
394
+ - `payload_summary` is the actual message text from the MCP resource read (truncated to 200 chars), or fallback `"whatsapp event from <uri>"` if no text content
395
+ - `payload_ref` and `source_event_id` are both the MCP resource notification URI
396
+ - `source_session` is **omitted** (empty) — the MCP bridge is not an OpenCode session, so no echo-skip occurs
397
+ - `expires_at` is **omitted** — the bridge does not set message expiry
398
+
399
+ ## Current Limitations (WhatsApp)
400
+
401
+ - **No production WhatsApp event ingestion configured.** The repo contains a generic MCP→NATS bridge (`packages/envoy/cmd/mcp/` + `packages/envoy/internal/mcpbridge/`) that already supports WhatsApp topic patterns (tested in `packages/envoy/internal/integration/delivery_test.go`). However, it is not yet configured/deployed to connect to the `@sjawhar/whatsapp-mcp` server in production.
402
+ - **Synthetic testing only.** The smoke test above uses `envoy_publish` to inject test messages into NATS. It validates Envoy delivery mechanics (NATS → listener → session), not true WhatsApp end-to-end delivery.
403
+ - **Production wiring needed.** To receive real WhatsApp events, the MCP bridge needs to be configured with the `@sjawhar/whatsapp-mcp` server connection details (similar to how `packages/envoy/cmd/github/` and `packages/envoy/cmd/slack/` are configured for their respective platforms). The bridge would then subscribe to WhatsApp MCP resource notifications and publish Envoy envelopes to NATS automatically.
404
+ - **Subscription + routing + delivery path is ready.** The contracts layer (`whatsappSubject` helper), NATS topic format, generic listener routing, and MCP bridge infrastructure all work. Only the production configuration connecting the bridge to the WhatsApp MCP server is missing.