@yemi33/minions 0.1.813 → 0.1.815

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/CHANGELOG.md CHANGED
@@ -1,8 +1,9 @@
1
1
  # Changelog
2
2
 
3
- ## 0.1.813 (2026-04-11)
3
+ ## 0.1.815 (2026-04-11)
4
4
 
5
5
  ### Features
6
+ - Bidirectional Teams integration for Command Center (#764)
6
7
  - show branch strategy on PRD view (#762)
7
8
  - Restructure decompose.md sub-task output format (#655)
8
9
  - CC race condition fixes, doc-chat expand, settings save fix, metric rate
@@ -12,6 +13,9 @@
12
13
  - cap review→fix cycles at evalMaxIterations (default 3)
13
14
 
14
15
  ### Fixes
16
+ - reset PRD item status when work item is deleted (closes #779) (#823)
17
+ - add smoke test for checkTimeouts ReferenceError regression (closes #775) (#822)
18
+ - replace safeWrite with mutateDispatch in CLI kill handler (#654)
15
19
  - CC queue drain uses combined flush, not one-at-a-time while loop (#818)
16
20
  - allow agents to explain rationale instead of blindly fixing review feedback
17
21
  - don't URL-encode ADO branchName filter (refs/heads/ format expected raw)
@@ -29,9 +33,6 @@
29
33
  - include PR title in all dispatch labels and escalation alerts
30
34
  - never downgrade reviewStatus from 'approved' unless explicitly rejected
31
35
  - warn on invalid pipeline JSON instead of silently dropping + add test
32
- - repair invalid JSON in daily-arch-improvement pipeline
33
- - flush queued CC messages as single combined request
34
- - human feedback fixes are never capped (only minion review→fix loop is)
35
36
 
36
37
  ## 0.1.782 (2026-04-10)
37
38
 
@@ -378,11 +378,14 @@ async function ccSend() {
378
378
  }
379
379
  var wasAborted = await _ccDoSend(message);
380
380
 
381
- // Flush queued messages combine into single request
382
- if (tab._queue && tab._queue.length > 0) {
383
- var combined = tab._queue.splice(0).join('\n\n');
381
+ // Flush queued messages one at a time, pausing after abort to let server release ccInFlight
382
+ while (tab._queue && tab._queue.length > 0) {
383
+ if (wasAborted) {
384
+ await new Promise(function(r) { setTimeout(r, 1500); });
385
+ }
386
+ var next = tab._queue.shift();
384
387
  _renderQueueIndicator();
385
- await _ccDoSend(combined);
388
+ wasAborted = await _ccDoSend(next);
386
389
  }
387
390
  }
388
391
 
package/dashboard.js CHANGED
@@ -20,6 +20,7 @@ const _dashboardVersion = {
20
20
  };
21
21
  const shared = require('./engine/shared');
22
22
  const queries = require('./engine/queries');
23
+ const teams = require('./engine/teams');
23
24
  const os = require('os');
24
25
 
25
26
  const { safeRead, safeReadDir, safeWrite, safeJson, safeJsonObj, safeJsonArr, safeUnlink, mutateJsonFileLocked, mutateWorkItems, getProjects: _getProjects, DONE_STATUSES, WI_STATUS } = shared;
@@ -38,6 +39,7 @@ function reloadConfig() {
38
39
  }
39
40
 
40
41
  const PLANS_DIR = path.join(MINIONS_DIR, 'plans');
42
+ const TEAMS_INBOX_PATH = path.join(ENGINE_DIR, 'teams-inbox.json');
41
43
 
42
44
  // Resolve a plan/PRD file path: .json files live in prd/, .md files in plans/
43
45
  // Validates that the file stays within the expected directory to prevent path traversal.
@@ -1208,6 +1210,14 @@ const server = http.createServer(async (req, res) => {
1208
1210
  if (cleaned) safeWrite(cooldownPath, cooldowns);
1209
1211
  } catch (e) { console.error('cooldown cleanup:', e.message); }
1210
1212
 
1213
+ // Reset PRD item status so it doesn't stay 'dispatched' with no work item (#779)
1214
+ if (item && item.sourcePlan) {
1215
+ try {
1216
+ const lifecycle = require('./engine/lifecycle');
1217
+ lifecycle.syncPrdItemStatus(id, WI_STATUS.PENDING, item.sourcePlan);
1218
+ } catch (e) { console.error('PRD status reset on delete:', e.message); }
1219
+ }
1220
+
1211
1221
  invalidateStatusCache();
1212
1222
  return jsonReply(res, 200, { ok: true, id, dispatchRemoved });
1213
1223
  } catch (e) { return jsonReply(res, 400, { error: e.message }); }
@@ -2214,6 +2224,9 @@ If nothing to do: { "duplicates": [], "reclassify": [], "remove": [] }`;
2214
2224
  }, { defaultValue: { pending: [], active: [], completed: [] } });
2215
2225
  }
2216
2226
 
2227
+ // Teams notification for plan approval — non-blocking
2228
+ try { teams.teamsNotifyPlanEvent({ name: plan.plan_summary || body.file, file: body.file }, 'plan-approved').catch(() => {}); } catch {}
2229
+
2217
2230
  invalidateStatusCache();
2218
2231
  return jsonReply(res, 200, { ok: true, status: 'approved', resumedWorkItems: resumed });
2219
2232
  } catch (e) { return jsonReply(res, 400, { error: e.message }); }
@@ -2422,6 +2435,10 @@ If nothing to do: { "duplicates": [], "reclassify": [], "remove": [] }`;
2422
2435
  plan.rejectedBy = body.rejectedBy || os.userInfo().username;
2423
2436
  if (body.reason) plan.rejectionReason = body.reason;
2424
2437
  safeWrite(planPath, plan);
2438
+
2439
+ // Teams notification for plan rejection — non-blocking
2440
+ try { teams.teamsNotifyPlanEvent({ name: plan.plan_summary || body.file, file: body.file }, 'plan-rejected').catch(() => {}); } catch {}
2441
+
2425
2442
  return jsonReply(res, 200, { ok: true, status: 'rejected' });
2426
2443
  } catch (e) { return jsonReply(res, 400, { error: e.message }); }
2427
2444
  }
@@ -3442,6 +3459,11 @@ What would you like to discuss or change? When you're happy, say "approve" and I
3442
3459
  });
3443
3460
  }
3444
3461
 
3462
+ // Mirror CC response to Teams (non-blocking, skip Teams-originated)
3463
+ if (!tabId.startsWith('teams-')) {
3464
+ teams.teamsPostCCResponse(body.message, result.text).catch(() => {});
3465
+ }
3466
+
3445
3467
  const reply = { ...parseCCActions(result.text), sessionId: ccSession.sessionId, newSession: !wasResume };
3446
3468
  if (sessionReset) reply.sessionReset = true;
3447
3469
  return jsonReply(res, 200, reply);
@@ -3555,6 +3577,13 @@ What would you like to discuss or change? When you're happy, say "approve" and I
3555
3577
  const donePayload = { type: 'done', text: displayText, actions, sessionId: responseSessionId, newSession: !wasResume };
3556
3578
  if (sessionReset) donePayload.sessionReset = true;
3557
3579
  res.write('data: ' + JSON.stringify(donePayload) + '\n\n');
3580
+
3581
+ // Mirror CC response to Teams (non-blocking, skip Teams-originated)
3582
+ const _streamTabId = body.tabId || 'default';
3583
+ if (!_streamTabId.startsWith('teams-')) {
3584
+ teams.teamsPostCCResponse(body.message, result.text).catch(() => {});
3585
+ }
3586
+
3558
3587
  _ccStreamEnded = true; res.end();
3559
3588
  } finally {
3560
3589
  ccInFlightTabs.delete(tabId);
@@ -3850,6 +3879,93 @@ What would you like to discuss or change? When you're happy, say "approve" and I
3850
3879
  }
3851
3880
  }
3852
3881
 
3882
+ // ── Teams Bot Handler ─────────────────────────────────────────────────────
3883
+
3884
+ async function handleTeamsBot(req, res) {
3885
+ if (!teams.isTeamsEnabled()) {
3886
+ return jsonReply(res, 503, { error: 'Teams integration disabled' }, req);
3887
+ }
3888
+ const adapter = teams.createAdapter();
3889
+ if (!adapter) {
3890
+ return jsonReply(res, 503, { error: 'Teams adapter unavailable' }, req);
3891
+ }
3892
+ try {
3893
+ await adapter.process(req, res, async (context) => {
3894
+ const activity = context.activity;
3895
+ const cfg = teams.getTeamsConfig();
3896
+
3897
+ // Save conversation reference on install/member events
3898
+ if (activity.type === 'conversationUpdate' && activity.membersAdded?.length) {
3899
+ const ref = context.activity.conversation?.id;
3900
+ if (ref) {
3901
+ const convRef = {
3902
+ activityId: activity.id,
3903
+ user: activity.from,
3904
+ bot: activity.recipient,
3905
+ conversation: activity.conversation,
3906
+ channelId: activity.channelId,
3907
+ locale: activity.locale,
3908
+ serviceUrl: activity.serviceUrl,
3909
+ };
3910
+ teams.saveConversationRef(activity.conversation.id, convRef);
3911
+ shared.log('info', `Teams conversationUpdate: saved ref for ${activity.conversation.id}`);
3912
+ }
3913
+ }
3914
+
3915
+ if (activity.type === 'installationUpdate') {
3916
+ const convRef = {
3917
+ activityId: activity.id,
3918
+ user: activity.from,
3919
+ bot: activity.recipient,
3920
+ conversation: activity.conversation,
3921
+ channelId: activity.channelId,
3922
+ locale: activity.locale,
3923
+ serviceUrl: activity.serviceUrl,
3924
+ };
3925
+ if (activity.conversation?.id) {
3926
+ teams.saveConversationRef(activity.conversation.id, convRef);
3927
+ shared.log('info', `Teams installationUpdate: saved ref for ${activity.conversation.id}`);
3928
+ }
3929
+ }
3930
+
3931
+ // Handle incoming messages
3932
+ if (activity.type === 'message' && activity.text) {
3933
+ // Filter bot's own echo messages
3934
+ if (activity.from?.id === cfg.appId) return;
3935
+
3936
+ const msgId = `teams-${Date.now()}-${shared.uid()}`;
3937
+ const convRef = {
3938
+ activityId: activity.id,
3939
+ user: activity.from,
3940
+ bot: activity.recipient,
3941
+ conversation: activity.conversation,
3942
+ channelId: activity.channelId,
3943
+ locale: activity.locale,
3944
+ serviceUrl: activity.serviceUrl,
3945
+ };
3946
+ mutateJsonFileLocked(TEAMS_INBOX_PATH, (inbox) => {
3947
+ if (!Array.isArray(inbox)) inbox = [];
3948
+ inbox.push({
3949
+ id: msgId,
3950
+ text: activity.text,
3951
+ from: activity.from?.name || activity.from?.id || 'unknown',
3952
+ conversationRef: convRef,
3953
+ receivedAt: new Date().toISOString(),
3954
+ _processedAt: null,
3955
+ });
3956
+ return inbox;
3957
+ }, { defaultValue: [] });
3958
+ shared.log('info', `Teams message received from ${activity.from?.name || 'unknown'}: ${activity.text.slice(0, 80)}`);
3959
+ }
3960
+ });
3961
+ } catch (err) {
3962
+ shared.log('warn', `Teams bot handler error: ${err.message}`);
3963
+ if (!res.headersSent) {
3964
+ return jsonReply(res, 500, { error: 'Bot processing failed' }, req);
3965
+ }
3966
+ }
3967
+ }
3968
+
3853
3969
  // ── Route Registry ──────────────────────────────────────────────────────────
3854
3970
  // Order matters: specific routes before general ones (e.g., /api/plans/approve before /api/plans/:file)
3855
3971
 
@@ -4453,6 +4569,9 @@ What would you like to discuss or change? When you're happy, say "approve" and I
4453
4569
  { method: 'POST', path: '/api/settings', desc: 'Update engine + claude + agent config', params: 'engine?, claude?, agents?', handler: handleSettingsUpdate },
4454
4570
  { method: 'POST', path: '/api/settings/routing', desc: 'Update routing.md', params: 'content', handler: handleSettingsRouting },
4455
4571
  { method: 'POST', path: '/api/settings/reset', desc: 'Reset engine + claude + agent settings to defaults', handler: handleSettingsReset },
4572
+
4573
+ // Teams Bot Framework webhook
4574
+ { method: 'POST', path: '/api/bot', desc: 'Bot Framework webhook for Teams integration', handler: handleTeamsBot },
4456
4575
  ];
4457
4576
 
4458
4577
  // ── Route Dispatcher ────────────────────────────────────────────────────────
@@ -0,0 +1,368 @@
1
+ # Teams Production Endpoint Migration
2
+
3
+ Guide for migrating the Minions Teams bot from a Dev Tunnel to a stable public HTTPS endpoint for production use. Choose one of the three deployment options below based on your infrastructure.
4
+
5
+ **Key fact:** The Azure Bot messaging endpoint URL can be changed at any time in the Azure Portal — it takes effect immediately. No bot reinstallation is needed in Teams. This means you can switch between Dev Tunnel and production endpoints freely.
6
+
7
+ **Prerequisites:**
8
+
9
+ - A working Teams integration via Dev Tunnel (see [docs/teams-setup.md](teams-setup.md))
10
+ - Azure CLI installed (`az --version`) for Options 1 and 2
11
+ - A public-facing server or VM for Option 3
12
+
13
+ ---
14
+
15
+ ## Option 1: Azure App Service
16
+
17
+ Deploy the Minions dashboard as an Azure App Service with a stable FQDN.
18
+
19
+ ### Steps
20
+
21
+ 1. **Create an App Service Plan** (skip if you have one):
22
+
23
+ ```bash
24
+ az appservice plan create \
25
+ --name minions-plan \
26
+ --resource-group rg-minions \
27
+ --sku B1 \
28
+ --is-linux
29
+ ```
30
+
31
+ 2. **Create the Web App:**
32
+
33
+ ```bash
34
+ az webapp create \
35
+ --name minions-dashboard \
36
+ --resource-group rg-minions \
37
+ --plan minions-plan \
38
+ --runtime "NODE:20-lts"
39
+ ```
40
+
41
+ This creates a publicly accessible URL: `https://minions-dashboard.azurewebsites.net`
42
+
43
+ 3. **Configure environment variables:**
44
+
45
+ ```bash
46
+ az webapp config appsettings set \
47
+ --name minions-dashboard \
48
+ --resource-group rg-minions \
49
+ --settings \
50
+ PORT=8080 \
51
+ NODE_ENV=production
52
+ ```
53
+
54
+ > Azure App Service routes external port 443 (HTTPS) to your app's internal port (default 8080). Set `PORT=8080` so the dashboard listens on the expected port.
55
+
56
+ 4. **Deploy the code:**
57
+
58
+ ```bash
59
+ # From the minions repository root
60
+ az webapp deploy \
61
+ --name minions-dashboard \
62
+ --resource-group rg-minions \
63
+ --src-path . \
64
+ --type zip
65
+ ```
66
+
67
+ Alternatively, configure continuous deployment from your Git repository:
68
+
69
+ ```bash
70
+ az webapp deployment source config \
71
+ --name minions-dashboard \
72
+ --resource-group rg-minions \
73
+ --repo-url https://github.com/your-org/minions \
74
+ --branch master \
75
+ --manual-integration
76
+ ```
77
+
78
+ 5. **Copy `config.json` to the App Service.** The simplest approach is to use the Kudu console or App Service Editor to upload your `config.json` to the application root. Alternatively, mount an Azure File Share containing your config.
79
+
80
+ 6. **Update the Azure Bot messaging endpoint:**
81
+
82
+ - Open the [Azure Portal](https://portal.azure.com) > your Azure Bot resource > **Configuration**.
83
+ - Change the **Messaging endpoint** to:
84
+ ```
85
+ https://minions-dashboard.azurewebsites.net/api/bot
86
+ ```
87
+ - Click **Apply**. The change takes effect immediately.
88
+
89
+ ### Verify
90
+
91
+ 1. Open `https://minions-dashboard.azurewebsites.net/api/routes` in a browser — you should see the API route list.
92
+ 2. In the Azure Bot resource, click **Test in Web Chat** and send a message.
93
+ 3. Send a message in Teams to the bot — confirm it receives and responds.
94
+
95
+ ### Rollback
96
+
97
+ To revert to Dev Tunnel:
98
+
99
+ 1. Start your local Dev Tunnel: `devtunnel host -p 7331 --allow-anonymous`
100
+ 2. Update the Azure Bot messaging endpoint back to your tunnel URL: `https://<tunnel>.devtunnels.ms/api/bot`
101
+ 3. Click **Apply**. Traffic returns to your local machine immediately.
102
+
103
+ ---
104
+
105
+ ## Option 2: Azure Container App
106
+
107
+ Containerize the dashboard and deploy to Azure Container Apps with a stable FQDN.
108
+
109
+ ### Steps
110
+
111
+ 1. **Create a Dockerfile** in the repository root:
112
+
113
+ ```dockerfile
114
+ FROM node:20-slim
115
+ WORKDIR /app
116
+ COPY package*.json ./
117
+ RUN npm ci --omit=dev 2>/dev/null || true
118
+ COPY . .
119
+ EXPOSE 7331
120
+ CMD ["node", "dashboard.js"]
121
+ ```
122
+
123
+ > Minions has zero npm dependencies beyond Node.js built-ins, so `npm ci` may be a no-op. The botbuilder package (added by this feature branch) is the exception.
124
+
125
+ 2. **Build and push to Azure Container Registry:**
126
+
127
+ ```bash
128
+ # Create a container registry (skip if you have one)
129
+ az acr create \
130
+ --name minionsacr \
131
+ --resource-group rg-minions \
132
+ --sku Basic
133
+
134
+ # Build and push
135
+ az acr build \
136
+ --registry minionsacr \
137
+ --image minions-dashboard:latest .
138
+ ```
139
+
140
+ 3. **Create a Container Apps environment** (skip if you have one):
141
+
142
+ ```bash
143
+ az containerapp env create \
144
+ --name minions-env \
145
+ --resource-group rg-minions \
146
+ --location eastus
147
+ ```
148
+
149
+ 4. **Deploy the container:**
150
+
151
+ ```bash
152
+ az containerapp create \
153
+ --name minions-dashboard \
154
+ --resource-group rg-minions \
155
+ --environment minions-env \
156
+ --image minionsacr.azurecr.io/minions-dashboard:latest \
157
+ --registry-server minionsacr.azurecr.io \
158
+ --target-port 7331 \
159
+ --ingress external \
160
+ --min-replicas 1 \
161
+ --max-replicas 1 \
162
+ --env-vars NODE_ENV=production
163
+ ```
164
+
165
+ > Use `--min-replicas 1 --max-replicas 1` because the Minions engine uses file-based state that doesn't support multiple replicas.
166
+
167
+ 5. **Get the FQDN:**
168
+
169
+ ```bash
170
+ az containerapp show \
171
+ --name minions-dashboard \
172
+ --resource-group rg-minions \
173
+ --query "properties.configuration.ingress.fqdn" \
174
+ --output tsv
175
+ ```
176
+
177
+ This returns something like: `minions-dashboard.happyfield-abc123.eastus.azurecontainerapps.io`
178
+
179
+ 6. **Update the Azure Bot messaging endpoint:**
180
+
181
+ - Open the [Azure Portal](https://portal.azure.com) > your Azure Bot resource > **Configuration**.
182
+ - Change the **Messaging endpoint** to:
183
+ ```
184
+ https://minions-dashboard.happyfield-abc123.eastus.azurecontainerapps.io/api/bot
185
+ ```
186
+ - Click **Apply**. The change takes effect immediately.
187
+
188
+ ### Verify
189
+
190
+ 1. Open `https://<your-fqdn>/api/routes` in a browser.
191
+ 2. Test via Azure Bot **Test in Web Chat**.
192
+ 3. Send a message in Teams — confirm end-to-end flow works.
193
+
194
+ ### Rollback
195
+
196
+ To revert to Dev Tunnel:
197
+
198
+ 1. Start your local Dev Tunnel: `devtunnel host -p 7331 --allow-anonymous`
199
+ 2. Update the Azure Bot messaging endpoint back to your tunnel URL.
200
+ 3. Click **Apply**. Immediate switchover.
201
+
202
+ Optionally stop the container to save costs:
203
+
204
+ ```bash
205
+ az containerapp update \
206
+ --name minions-dashboard \
207
+ --resource-group rg-minions \
208
+ --min-replicas 0 --max-replicas 0
209
+ ```
210
+
211
+ ---
212
+
213
+ ## Option 3: Reverse Proxy (nginx / Caddy)
214
+
215
+ For servers with a public IP address or an existing reverse proxy setup.
216
+
217
+ ### Steps (Caddy — recommended for simplicity)
218
+
219
+ Caddy automatically provisions and renews TLS certificates via Let's Encrypt.
220
+
221
+ 1. **Install Caddy:**
222
+
223
+ ```bash
224
+ # Debian/Ubuntu
225
+ sudo apt install -y caddy
226
+
227
+ # macOS
228
+ brew install caddy
229
+ ```
230
+
231
+ 2. **Configure Caddy.** Create or edit `/etc/caddy/Caddyfile`:
232
+
233
+ ```
234
+ minions.yourdomain.com {
235
+ reverse_proxy localhost:7331
236
+ }
237
+ ```
238
+
239
+ > Replace `minions.yourdomain.com` with your actual domain. Ensure a DNS A record points this domain to your server's public IP.
240
+
241
+ 3. **Start Caddy:**
242
+
243
+ ```bash
244
+ sudo systemctl enable --now caddy
245
+ ```
246
+
247
+ Caddy automatically obtains a Let's Encrypt TLS certificate for your domain.
248
+
249
+ 4. **Start the Minions dashboard:**
250
+
251
+ ```bash
252
+ minions dash
253
+ ```
254
+
255
+ Or run it as a systemd service for persistence:
256
+
257
+ ```bash
258
+ # /etc/systemd/system/minions-dashboard.service
259
+ [Unit]
260
+ Description=Minions Dashboard
261
+ After=network.target
262
+
263
+ [Service]
264
+ Type=simple
265
+ User=your-user
266
+ WorkingDirectory=/path/to/minions
267
+ ExecStart=/usr/bin/node dashboard.js
268
+ Restart=on-failure
269
+
270
+ [Install]
271
+ WantedBy=multi-user.target
272
+ ```
273
+
274
+ ```bash
275
+ sudo systemctl enable --now minions-dashboard
276
+ ```
277
+
278
+ 5. **Update the Azure Bot messaging endpoint:**
279
+
280
+ - Open the Azure Portal > your Azure Bot resource > **Configuration**.
281
+ - Change the **Messaging endpoint** to:
282
+ ```
283
+ https://minions.yourdomain.com/api/bot
284
+ ```
285
+ - Click **Apply**. The change takes effect immediately.
286
+
287
+ ### Steps (nginx)
288
+
289
+ 1. **Install nginx and certbot:**
290
+
291
+ ```bash
292
+ sudo apt install -y nginx certbot python3-certbot-nginx
293
+ ```
294
+
295
+ 2. **Configure nginx.** Create `/etc/nginx/sites-available/minions`:
296
+
297
+ ```nginx
298
+ server {
299
+ listen 80;
300
+ server_name minions.yourdomain.com;
301
+
302
+ location / {
303
+ proxy_pass http://localhost:7331;
304
+ proxy_http_version 1.1;
305
+ proxy_set_header Upgrade $http_upgrade;
306
+ proxy_set_header Connection 'upgrade';
307
+ proxy_set_header Host $host;
308
+ proxy_set_header X-Real-IP $remote_addr;
309
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
310
+ proxy_set_header X-Forwarded-Proto $scheme;
311
+ }
312
+ }
313
+ ```
314
+
315
+ ```bash
316
+ sudo ln -s /etc/nginx/sites-available/minions /etc/nginx/sites-enabled/
317
+ sudo nginx -t && sudo systemctl reload nginx
318
+ ```
319
+
320
+ 3. **Obtain a TLS certificate:**
321
+
322
+ ```bash
323
+ sudo certbot --nginx -d minions.yourdomain.com
324
+ ```
325
+
326
+ Certbot modifies the nginx config to add TLS and sets up auto-renewal.
327
+
328
+ 4. **Start the Minions dashboard** (same as Caddy option above).
329
+
330
+ 5. **Update the Azure Bot messaging endpoint** (same as Caddy option above).
331
+
332
+ ### Verify
333
+
334
+ 1. Open `https://minions.yourdomain.com/api/routes` in a browser — confirm the route list loads over HTTPS.
335
+ 2. Check the TLS certificate: `curl -vI https://minions.yourdomain.com 2>&1 | grep "SSL certificate"`.
336
+ 3. Test via Azure Bot **Test in Web Chat**.
337
+ 4. Send a message in Teams — confirm the bot responds.
338
+
339
+ ### Rollback
340
+
341
+ To revert to Dev Tunnel:
342
+
343
+ 1. Start your local Dev Tunnel: `devtunnel host -p 7331 --allow-anonymous`
344
+ 2. Update the Azure Bot messaging endpoint back to your tunnel URL.
345
+ 3. Click **Apply**. Immediate switchover.
346
+
347
+ The reverse proxy can remain running — it just won't receive Bot Framework traffic until the endpoint is pointed back.
348
+
349
+ ---
350
+
351
+ ## Choosing an Option
352
+
353
+ | Criteria | App Service | Container App | Reverse Proxy |
354
+ |----------|-------------|---------------|---------------|
355
+ | Setup complexity | Medium | Medium | Low (Caddy) / Medium (nginx) |
356
+ | TLS management | Automatic | Automatic | Automatic (Caddy/certbot) |
357
+ | Cost | ~$13/mo (B1) | Pay-per-use | Free (your server + Let's Encrypt) |
358
+ | Custom domain | Supported | Supported | Required |
359
+ | Scaling | Supported but not needed | Supported but not needed | Manual |
360
+ | Best for | Azure-native teams | Container workflows | Existing servers |
361
+
362
+ > **Note on replicas:** Minions uses file-based state (`engine/*.json`). Do not run multiple replicas — use exactly 1 instance. All three options above default to single-instance deployment.
363
+
364
+ ## Common Notes
365
+
366
+ - **Endpoint changes are immediate.** When you update the messaging endpoint in the Azure Bot Configuration, it takes effect right away. No bot reinstallation, no downtime, no user-visible change in Teams.
367
+ - **No deprecated webhooks.** This guide uses Azure Bot Framework exclusively. Do not use deprecated O365 Connector webhooks or Power Automate flows — they are being removed by Microsoft.
368
+ - **Config portability.** The same `config.json` works across all environments. Just ensure the `teams.appId` and `teams.appPassword` are correct for the bot registration that points to your production URL.