@patimweb/pi-teams 1.1.0 → 1.2.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/README.md CHANGED
@@ -19,7 +19,9 @@ pi install /path/to/pi-teams
19
19
 
20
20
  ## Quick start
21
21
 
22
- 1. **Register an app in Entra ID** (once per organization — see below).
22
+ 1. **Register an app in Entra ID** (once per organization) — the step-by-step
23
+ walkthrough, including the redirect URI and public-client settings that sign-in
24
+ needs, is in [docs/entra-app-registration.md](docs/entra-app-registration.md).
23
25
  2. **Configure the account:**
24
26
  ```
25
27
  teams_setup:
@@ -32,12 +34,80 @@ pi install /path/to/pi-teams
32
34
  sign-in page and closes itself when you are done — nothing to type.
33
35
  4. **Check it works:** `/teams-status`, then `/teams-inbox`.
34
36
 
37
+ Full walkthrough with the exact portal paths: **[docs/entra-app-registration.md](docs/entra-app-registration.md)**.
38
+
39
+ ---
40
+
41
+ ## Listen mode
42
+
43
+ Off by default. When it is on, pi polls your chats and turns an incoming message
44
+ into a prompt it answers — as you, in the chat it arrived in.
45
+
46
+ ```
47
+ /teams-listen on # switch it on for the session's account
48
+ /teams-listen status # what is configured, and what is running right now
49
+ /teams-listen off
50
+ ```
51
+
52
+ ```json
53
+ "watch": {
54
+ "enabled": true,
55
+ "intervalSeconds": 60,
56
+ "chats": ["Anna*", "Vertrieb*"],
57
+ "from": ["anna.schmidt@contoso.com"],
58
+ "mentionOnly": false,
59
+ "cooldownSeconds": 300,
60
+ "maxTriggersPerHour": 10
61
+ }
62
+ ```
63
+
64
+ | Key | Default | Meaning |
65
+ |-----|---------|---------|
66
+ | `enabled` | `false` | whether the watcher runs |
67
+ | `intervalSeconds` | `60` | seconds between polls (minimum 15) |
68
+ | `chats` | `[]` | **where** pi listens: glob patterns matched against topic, label, chat ID and participant names. Empty means every recent chat. |
69
+ | `from` | `[]` | **who** pi listens to: glob patterns matched against display name, UPN and e-mail. Empty means any sender. |
70
+ | `mentionOnly` | `false` | only wake where you are mentioned |
71
+ | `cooldownSeconds` | `300` | stay quiet in a chat after waking pi for it |
72
+ | `maxTriggersPerHour` | `10` | hard cap on wakes per hour |
73
+
74
+ Set it globally, or per account (the account level wins field by field).
75
+ `teams_watch` writes the same keys; `/teams-listen` writes to the session's
76
+ account.
77
+
78
+ What it does, and what it deliberately does not:
79
+
80
+ - **Costs a model turn per wake.** That is what the filters and the hourly cap
81
+ are for — narrow `chats` and `from`, and enable it for a handful of
82
+ conversations rather than the whole company.
83
+ - **Never answers the backlog.** The first poll records what is there; only what
84
+ arrives afterwards wakes pi.
85
+ - **Never wakes for its own messages.** pi posts as you, so its own reply comes
86
+ back as "my own message" and stops the loop.
87
+ - **Respects the read rules.** A chat excluded by `permissions.read.chats` is
88
+ not even polled.
89
+ - **Does not bypass the safety model.** A reply it decides to send goes through
90
+ the same safety level and scope rules as one you asked for. At `confirm` you
91
+ approve each one; for hands-free answers, narrow `permissions.write.chats` and
92
+ set `safetyLevel: open` — deliberately, and on an account where you mean it.
93
+ - **Chats only, not channels.** Channel polling needs `ChannelMessage.Read.All`
94
+ (admin consent) and multiplies the calls; answering in channels stays a
95
+ requested action.
96
+
97
+ Why polling and not a webhook: Graph change notifications for chats need a
98
+ publicly reachable HTTPS endpoint plus subscription renewal, which a laptop on a
99
+ home network cannot offer. Polling costs one `/me/chats` call per interval and
100
+ works anywhere.
101
+
35
102
  ---
36
103
 
37
104
  ## Entra ID app registration
38
105
 
39
- pi signs in with the OAuth 2.0 **device authorization grant**, so it never sees
40
- your password and your MFA works normally.
106
+ Full walkthrough: **[docs/entra-app-registration.md](docs/entra-app-registration.md)**.
107
+ The short version, with the two settings that sign-in actually depends on:
108
+
109
+ pi signs in as you with a delegated OAuth 2.0 flow — the browser opens, you sign
110
+ in with your own credentials and MFA, and pi never sees your password.
41
111
 
42
112
  ### 1. Create the registration
43
113
 
@@ -207,8 +277,8 @@ prefer.
207
277
  "accounts": [
208
278
  {
209
279
  "name": "work",
210
- "displayName": "NeoImpulse",
211
- "tenantId": "neoimpulse.onmicrosoft.com",
280
+ "displayName": "Contoso",
281
+ "tenantId": "contoso.onmicrosoft.com",
212
282
  "clientId": "00000000-0000-0000-0000-000000000000",
213
283
  "authMode": "interactive",
214
284
  "safetyLevel": "confirm",
@@ -223,7 +293,7 @@ prefer.
223
293
  "deny": ["*/Announcements"]
224
294
  },
225
295
  "chats": { "allow": ["*"] },
226
- "people": { "deny": ["ceo@neoimpulse.de"] }
296
+ "people": { "deny": ["ceo@contoso.com"] }
227
297
  }
228
298
  },
229
299
  "tenants": [
@@ -337,14 +407,14 @@ teams_permissions:
337
407
  | `teams_status` | Which account is active, who pi acts as, what it may do |
338
408
  | `teams_permissions` | Show, test or change the allow/deny rules |
339
409
  | `teams_doctor` | Diagnose config, sign-in, consent and connectivity |
410
+ | `teams_watch` | Listen mode: status, enable, disable, and the filters for who and where |
340
411
 
341
412
  ### Reading
342
413
 
343
414
  | Tool | Description |
344
415
  |------|-------------|
345
416
  | `teams_inbox` | Chats with new messages plus recent mentions of you |
346
- | `teams_list_chats` | Recent chats with a preview of the last message |
347
- | `teams_read_chat` | Messages of one chat |
417
+ | `teams_list_chats` | Recent chats with a preview of the last message || `teams_read_chat` | Messages of one chat |
348
418
  | `teams_list_teams` | Teams you belong to |
349
419
  | `teams_list_channels` | Channels of a team |
350
420
  | `teams_read_channel` | Posts in a channel |
@@ -381,6 +451,7 @@ teams_permissions:
381
451
  | `/teams-status` | Show the connection and permissions |
382
452
  | `/teams-login` | Sign in to Teams as yourself |
383
453
  | `/teams-inbox` | What needs your attention right now |
454
+ | `/teams-listen` | Listen mode: `on`, `off`, or `status` |
384
455
  | `/teams-permissions` | What pi may and may not do |
385
456
  | `/teams-doctor` | Diagnose the setup |
386
457
 
@@ -397,6 +468,32 @@ teams_permissions:
397
468
 
398
469
  ---
399
470
 
471
+ ## How messages are formatted
472
+
473
+ A message body is treated as **lightweight markdown** and converted to the HTML
474
+ subset Teams renders in a chat bubble:
475
+
476
+ | You write | Teams shows |
477
+ |-----------|-------------|
478
+ | `**bold**`, `*italic*`, `~~struck~~` | bold, italic, struck through |
479
+ | `` `code` ``, fenced blocks | inline code, code block |
480
+ | `- item`, `1. item` | bullet list, numbered list |
481
+ | `[label](https://…)` | link |
482
+ | `# Heading` | a bold line — Teams has no headings in a chat |
483
+
484
+ Tables and images are deliberately **not** translated: Teams renders neither in
485
+ a chat, so passing them through would look worse than plain text. Put a table in
486
+ a file and link it instead.
487
+
488
+ Pass `html: true` on a send tool to bypass the conversion and supply raw HTML
489
+ instead. Everything else is escaped, so a message body cannot inject markup.
490
+
491
+ This is also why pi writes the way it does: see *How to format a Teams message*
492
+ in the bundled skill. Short, answer-first, bullets for anything enumerable — the
493
+ medium is a chat bubble, not a document.
494
+
495
+ ---
496
+
400
497
  ## Addressing things
401
498
 
402
499
  **Channels** — a `Team/Channel` path, or `team` and `channel` separately.
@@ -449,17 +546,22 @@ Data-oriented: plain immutable data, I/O at the edges, pure functions in the
449
546
  middle.
450
547
 
451
548
  - **`src/types.ts`** — domain data as plain interfaces. No behavior.
452
- - **`src/config/index.ts`** — accounts, tenants, the safety cascade, persistence.
549
+ - **`src/config/index.ts`** — accounts, tenants, the safety cascade, listen-mode settings, persistence.
453
550
  - **`src/config/scope.ts`** — allow/deny matching. Pure, and the most heavily
454
551
  tested module in the package.
455
552
  - **`src/auth/`** — MSAL applications, the file-backed token cache, JWT claims.
456
553
  - **`src/graph/client.ts`** — fetch wrapper: bearer token, paging, throttling.
457
554
  - **`src/graph/*.ts`** — one module per resource area, returning domain types.
458
555
  - **`src/graph/mappers.ts`** — Graph JSON → domain types. Pure.
556
+ - **`src/watch/`** — listen mode: the decision rules (`index.ts`, pure), the
557
+ polling loop (`loop.ts`), and the prompt an incoming message turns into
558
+ (`prompt.ts`).
459
559
  - **`src/utils/formatting.ts`** — domain types → display strings. Pure.
560
+ - **`src/utils/richtext.ts`** — markdown → the HTML subset Teams renders. Pure.
460
561
  - **`src/safety/`** — the three gates and the audit log.
461
562
  - **`src/tools/`** — one module per tool.
462
- - **`src/extension/index.ts`** — registration, commands, the `tool_call` interceptor.
563
+ - **`src/extension/index.ts`** — registration, commands, the `tool_call`
564
+ interceptor, and the listen-mode lifecycle.
463
565
 
464
566
  ## Development
465
567
 
@@ -470,23 +572,29 @@ npm test
470
572
  ```
471
573
 
472
574
  Tests cover the pure logic — scope matching, the config cascade, message body
473
- construction, HTML flattening, the safety gates — and run without a tenant or
474
- a network connection.
575
+ construction, markdown rendering, HTML flattening, the listen-mode decision
576
+ rules, the safety gates — and run without a tenant or a network connection.
475
577
 
476
578
  ## Troubleshooting
477
579
 
478
580
  | Symptom | Cause and fix |
479
581
  |---------|---------------|
582
+ | `AADSTS500113` (no reply address) | The registration has no redirect URI. Add `http://localhost` under *Mobile and desktop applications* — see [docs/entra-app-registration.md](docs/entra-app-registration.md) |
480
583
  | `AADSTS7000218` | "Allow public client flows" is off in the app registration |
481
584
  | `AADSTS65001` | Consent missing — sign in again, or have an admin consent |
482
585
  | `AADSTS50011` (redirect URI mismatch) | Add `http://localhost` under *Mobile and desktop applications*, or pin `loopbackPort` |
483
586
  | `AADSTS50020` | The account is not a member/guest of that tenant, or the app is single-tenant |
587
+ | Signed out after about an hour | The tenant grants no `offline_access`, so there is no refresh token. `teams_doctor` names the missing scope; otherwise sign in again when it expires |
484
588
  | Browser never opens | No browser is reachable here; `teams_doctor` says why. `teams_login mode: device-code` always works |
485
589
  | Browser opens, page never returns | A firewall is blocking the loopback port. Pin `loopbackPort` and allow it, or use the device code flow |
486
590
  | "Not signed in" | Run `teams_login`; the saved session may have been revoked |
591
+ | Status line says "not signed in" although sign-in worked | The line is written at session start and after every auth tool; if it lags, `/teams-status` repaints it |
487
592
  | "Blocked by configuration" | Your own scope rules. `teams_permissions action: test` shows which rule |
488
593
  | "cannot run on an app-only token" | Set `authMode: "interactive"` and run `teams_login` |
489
594
  | 403 reading channel messages | `ChannelMessage.Read.All` needs **admin** consent — see "Who has to approve what" |
595
+ | Listen mode does nothing | `teams_watch action: status` says whether it is enabled and running. Only messages arriving *after* it was switched on wake pi |
596
+ | Listen mode answers too much | Narrow `watch.chats` and `watch.from`, raise `cooldownSeconds`, lower `maxTriggersPerHour` |
597
+ | Listen mode stopped by itself | `teams_watch action: status` shows the last polling error; a revoked session or a sleeping laptop is the usual cause, and it resumes on its own |
490
598
  | Throttled | Graph rate limit; the client retries with back-off, then reports it |
491
599
 
492
600
  Run `teams_doctor` first — it checks all of the above at once.
@@ -0,0 +1,218 @@
1
+ # Entra ID app registration for pi-teams
2
+
3
+ The one-time setup that lets pi sign in as you. Fifteen minutes, once per
4
+ organization, and nothing in it needs a build or a server.
5
+
6
+ Everything below uses placeholders. Wherever you see
7
+
8
+ | Placeholder | Where it comes from |
9
+ |---|---|
10
+ | `<APPLICATION_CLIENT_ID>` | *Overview* → **Application (client) ID** |
11
+ | `<DIRECTORY_TENANT_ID>` | *Overview* → **Directory (tenant) ID** |
12
+ | `<TENANT_DOMAIN>` | e.g. `contoso.onmicrosoft.com`, or your verified domain |
13
+
14
+ …substitute your own values locally. Do not paste real tenant or client IDs into
15
+ a README, a ticket, a screenshot or a chat — they identify your organization,
16
+ and the client ID is half of what an attacker needs to start a sign-in flow
17
+ against your tenant.
18
+
19
+ ---
20
+
21
+ ## 1. Create the registration
22
+
23
+ **Entra admin center** → [entra.microsoft.com](https://entra.microsoft.com) →
24
+ **App registrations** → **New registration**
25
+
26
+ | Field | Value |
27
+ |---|---|
28
+ | Name | `pi-teams` (anything recognizable) |
29
+ | Supported account types | **Accounts in any organizational directory (multitenant)** if pi should also reach guest or customer tenants; **single tenant** is fine for one company |
30
+ | Redirect URI | leave empty here — it goes in step 2, on the right platform |
31
+
32
+ Two things to copy out of **Overview** before moving on:
33
+
34
+ - **Application (client) ID** → `<APPLICATION_CLIENT_ID>`
35
+ - **Directory (tenant) ID** → `<DIRECTORY_TENANT_ID>`
36
+
37
+ ## 2. Authentication — the part that actually decides whether sign-in works
38
+
39
+ **App registrations** → your app → **Authentication**. Two settings matter, and
40
+ they are needed for two different reasons. Both are in this one blade.
41
+
42
+ Direct link, with your own application ID substituted:
43
+
44
+ ```
45
+ https://entra.microsoft.com/#view/Microsoft_AAD_RegisteredApps/ApplicationMenuBlade/~/Authentication/appId/<APPLICATION_CLIENT_ID>
46
+ ```
47
+
48
+ ### 2a. A reply address, for the browser sign-in
49
+
50
+ **Authentication** → **Add a platform** → **Mobile and desktop applications** →
51
+ tick **`http://localhost`** → **Configure**.
52
+
53
+ MSAL picks a free loopback port when the browser sign-in starts, and Entra
54
+ treats the port-less `http://localhost` entry as matching *any* port for a
55
+ public client. One entry therefore covers every run — no per-port registration
56
+ and no fixed port needed.
57
+
58
+ Skipping this is the single most common setup mistake, and it fails *after* the
59
+ user has typed their password, with:
60
+
61
+ ```
62
+ AADSTS500113: No reply address is registered for the application.
63
+ ```
64
+
65
+ If your tenant insists on an exact URI, register e.g. `http://localhost:3000`
66
+ instead and pin the same number in pi (`loopbackPort` under the account, or
67
+ `teams_login port: 3000`).
68
+
69
+ ### 2b. Public client flows, for the device code sign-in
70
+
71
+ Same blade → **Advanced settings** → **Allow public client flows** → **Yes** →
72
+ **Save**.
73
+
74
+ pi uses delegated OAuth flows and never handles a client secret for sign-in, so
75
+ Entra must treat the app as a public client. Without this, sign-in fails with:
76
+
77
+ ```
78
+ AADSTS7000218: The request body must contain the following parameter:
79
+ 'client_assertion' or 'client_secret'.
80
+ ```
81
+
82
+ ## 3. API permissions
83
+
84
+ **App registrations** → your app → **API permissions** → **Add a permission** →
85
+ **Microsoft Graph** → **Delegated permissions**:
86
+
87
+ | Permission | Needed for |
88
+ |---|---|
89
+ | `User.Read`, `User.ReadBasic.All` | identity, looking colleagues up |
90
+ | `Team.ReadBasic.All`, `Channel.ReadBasic.All` | listing teams and channels |
91
+ | `ChannelMessage.Read.All` | reading channel messages and threads |
92
+ | `ChannelMessage.Send` | posting and replying in channels |
93
+ | `ChannelMember.Read.All` | channel member lists |
94
+ | `Chat.ReadWrite` | reading chats, creating chats |
95
+ | `ChatMessage.Send` | sending chat messages |
96
+ | `Presence.ReadWrite`, `Presence.Read.All` | your status, colleagues' availability |
97
+ | `Calendars.ReadWrite` | reading and creating calendar events |
98
+ | `OnlineMeetings.ReadWrite` | Teams meeting links |
99
+ | `Files.Read.All`, `Sites.Read.All` | channel files |
100
+
101
+ `openid`, `profile` and `offline_access` are deliberately absent: MSAL always
102
+ requests them and rejects them in an explicit scope list.
103
+
104
+ **What is not needed:** no client secret, no certificate, no redirect URI beyond
105
+ step 2, no application (as opposed to delegated) permissions.
106
+
107
+ ## 4. Consent
108
+
109
+ **API permissions** → **Grant admin consent** — only for the two scopes that
110
+ require it:
111
+
112
+ | Scope | Consent | Without it |
113
+ |---|---|---|
114
+ | `ChannelMessage.Read.All` | **admin** | cannot read channel messages or threads |
115
+ | `ChannelMember.Read.All` | **admin** | cannot list channel members |
116
+ | everything else above | user | a normal sign-in consent prompt covers it |
117
+
118
+ Without an administrator, drop those two scopes from the configuration and
119
+ everything else still works: chats in full, posting and replying in channels,
120
+ presence, calendar, meetings, directory lookup. See the README, *Who has to
121
+ approve what*, for the reduced scope list.
122
+
123
+ If the tenant has *Users can consent to applications* switched off, even the
124
+ user-consentable scopes need an administrator — usually through the admin
125
+ consent request workflow.
126
+
127
+ ## 5. Give it to pi
128
+
129
+ ```yaml
130
+ teams_setup:
131
+ name: work
132
+ displayName: Contoso
133
+ tenantId: "<DIRECTORY_TENANT_ID or <TENANT_DOMAIN>>"
134
+ clientId: "<APPLICATION_CLIENT_ID>"
135
+ setDefault: true
136
+ ```
137
+
138
+ Or edit `~/.pi/agent/pi-teams.json` directly — the same file, mode `0600`:
139
+
140
+ ```json
141
+ {
142
+ "accounts": [
143
+ {
144
+ "name": "work",
145
+ "tenantId": "<TENANT_DOMAIN>",
146
+ "clientId": "<APPLICATION_CLIENT_ID>",
147
+ "authMode": "interactive",
148
+ "safetyLevel": "confirm"
149
+ }
150
+ ],
151
+ "defaultAccount": "work"
152
+ }
153
+ ```
154
+
155
+ ## 6. Sign in and verify
156
+
157
+ 1. `teams_login` — the browser opens at the Microsoft sign-in page, you sign in
158
+ with your own credentials and MFA, and the page closes itself. Nothing to
159
+ copy or type. Over SSH or in a container, pi falls back to the device code
160
+ flow and shows a short code to enter on any device.
161
+ 2. `teams_status` — who pi acts as, which token is cached, what it may do.
162
+ 3. `teams_doctor` — checks configuration, sign-in, granted scopes and
163
+ connectivity in one go. It reports a missing consent as a **named
164
+ permission**, which is the quickest way to see whether the admin consent of
165
+ step 4 actually landed.
166
+
167
+ A successful sign-in is cached on disk and renewed silently, so this is a
168
+ once-per-account affair — as long as the tenant grants the session a refresh
169
+ token (see below).
170
+
171
+ ## 7. Guest and customer tenants
172
+
173
+ For a tenant that is not your own:
174
+
175
+ 1. the registration must be **multitenant** (step 1),
176
+ 2. your account must be a **guest in that tenant**,
177
+ 3. an administrator **of that tenant** consents to the app once.
178
+
179
+ In practice the third step is the hurdle, not your own tenant. Once it is done:
180
+
181
+ ```yaml
182
+ teams_setup:
183
+ parentAccount: work
184
+ name: customer-alpha
185
+ tenantId: "customeralpha.onmicrosoft.com"
186
+ ```
187
+
188
+ then `teams_login` with `tenant: customer-alpha`, and `teams_doctor` to confirm
189
+ that tenant on its own.
190
+
191
+ ---
192
+
193
+ ## Troubleshooting the registration
194
+
195
+ | Error | Cause | Fix |
196
+ |---|---|---|
197
+ | `AADSTS500113` — *No reply address is registered* | no redirect URI on the registration | step 2a: add `http://localhost` under **Mobile and desktop applications** |
198
+ | `AADSTS50011` — *redirect URI mismatch* | an exact URI is registered that does not match the chosen port | register the port-less `http://localhost`, or pin the port in the same file |
199
+ | `AADSTS7000218` — *client_assertion or client_secret* | **Allow public client flows** is off | step 2b |
200
+ | `AADSTS65001` — *consent required* | permissions added but never consented | sign in again; for the two admin scopes, step 4 |
201
+ | `AADSTS50020` — *not a member of the tenant* | wrong tenant, no guest account, or the app is single-tenant | steps 1 and 7 |
202
+ | `AADSTS700016` — *application not found in directory* | wrong tenant ID or client ID | recheck `<DIRECTORY_TENANT_ID>` and `<APPLICATION_CLIENT_ID>` |
203
+ | signed out after about an hour | the tenant grants no `offline_access` | the app needs a refresh token to renew silently; without it, run `teams_login` when the session expires. `teams_doctor` names the missing scope |
204
+
205
+ Run `teams_doctor` first for anything in this table: it validates the
206
+ configuration, the cached session, the granted scopes and the connection to
207
+ Graph, and it says which of them failed rather than only that something did.
208
+
209
+ ## Rotating or revoking access
210
+
211
+ - **To sign out of pi:** `teams_logout` (per account), or `all: true` for every
212
+ account. This deletes the cached session, not the app.
213
+ - **To revoke pi's access for everyone:** Entra admin center → **Enterprise
214
+ applications** → your app → **Properties** → *User assignment required*, or
215
+ delete the registration. Deleting it invalidates every cached session at once.
216
+ - **To change the registration:** editing scopes or redirect URIs does not touch
217
+ the local configuration — but it does invalidate a *new* consent, so sign in
218
+ again afterwards.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@patimweb/pi-teams",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "Microsoft Teams integration for pi coding agent — acts fully as the signed-in user across multiple accounts and tenants: chats, channels, meetings, presence, with configurable guardrails",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: teams-collaboration
3
- description: Microsoft Teams chats, channels, meetings and presence, acting as the signed-in user. Use when the user asks to read, search, summarize, send or reply to Teams messages; to catch up on what they missed; to post in a channel or a thread; to start a chat; to react to a message; to check or set their Teams status; to see who is available; or to schedule, change or cancel a Teams meeting. Also covers multiple Teams accounts and multiple company tenants, and the allow/deny rules that decide what pi may touch.
3
+ description: Microsoft Teams chats, channels, meetings and presence, acting as the signed-in user. Use when the user asks to read, search, summarize, send or reply to Teams messages; to catch up on what they missed; to post in a channel or a thread; to start a chat; to react to a message; to check or set their Teams status; to see who is available; to schedule, change or cancel a Teams meeting; or to make pi notice incoming Teams messages on its own (listen mode). Also covers how to format a message for a chat rather than a document, multiple Teams accounts and multiple company tenants, and the allow/deny rules that decide what pi may touch.
4
4
  ---
5
5
 
6
6
  # Microsoft Teams
@@ -91,6 +91,7 @@ checks a target without touching it.
91
91
  | `teams_create_channel` | Adding a channel to a team |
92
92
  | `teams_set_presence` / `teams_set_status_message` | Changing the user's status |
93
93
  | `teams_create_meeting` / `teams_update_meeting` / `teams_cancel_meeting` | Scheduling work |
94
+ | `teams_watch` | Letting pi notice incoming messages on its own |
94
95
 
95
96
  ## How to write as the user
96
97
 
@@ -105,6 +106,48 @@ checks a target without touching it.
105
106
  - Prefer `teams_reply_channel_message` over a new post when answering something
106
107
  — starting a second thread fragments the conversation.
107
108
 
109
+ ## How to format a Teams message
110
+
111
+ A Teams chat is a chat bubble, not a document. What arrives badly is long
112
+ prose: the reader has to hunt for the point, and it reads as if a machine wrote
113
+ it. The body is rendered as HTML — markdown is converted for you, so write
114
+ markdown and let the tool do the rest.
115
+
116
+ **Reach for this shape, not for paragraphs:**
117
+
118
+ ```
119
+ Kurz: die QA-Umgebung läuft wieder.
120
+
121
+ - Ursache: abgelaufenes Zertifikat
122
+ - Fix: erneuert und neu deployt
123
+ - Offen: Monitoring für 90 Tage
124
+ ```
125
+
126
+ - **Lead with the answer.** The first line should work as the whole message for
127
+ someone who reads nothing else.
128
+ - **Three short paragraphs at most.** If it needs more, it is a document — put
129
+ it in a file or a wiki page and link it.
130
+ - **Bullets for anything enumerable** — causes, options, next steps. Three items
131
+ and up belong in a list, not in a sentence with commas.
132
+ - **Bold the one thing that must not be missed**, and only that. Bold used
133
+ everywhere is bold nowhere.
134
+ - **Numbers, names and dates exactly as they are.** Do not round a figure or
135
+ paraphrase a deadline into something friendlier.
136
+ - **One question per message.** Two questions get one answer.
137
+
138
+ **What Teams renders:** `**bold**`, `*italic*`, `` `code` ``, `~~struck~~`,
139
+ `[label](url)`, `- bullets`, `1. numbered`, ``` fences. A `# heading` arrives as
140
+ a bold line — fine for structure, not for hierarchy. **Tables are not supported
141
+ in a chat**: convert them to bullets, or put the table in a file and link it.
142
+
143
+ **Language and tone:** answer in the language of the conversation, in the
144
+ register of the people in it. Short forms are normal in a chat ("passt", "ok,
145
+ 5 min") — but never shorten a fact into ambiguity.
146
+
147
+ When pi listens on its own (see *Listen mode*), this matters most: an answer
148
+ that arrives unrequested must be readable at a glance, because the recipient is
149
+ not expecting it.
150
+
108
151
  ## Channel addressing
109
152
 
110
153
  Channels are addressed as `Team/Channel` or via separate `team` and `channel`
@@ -143,3 +186,46 @@ summarize from openers alone.
143
186
  Before replying anywhere, read the recent messages first. It prevents
144
187
  answering a question that was already answered, and it is what tells you the
145
188
  tone to match.
189
+
190
+ ## Listen mode
191
+
192
+ Listen mode is the only case where pi acts without being asked: it polls the
193
+ user's chats, and an incoming message is turned into a prompt. Off by default,
194
+ and it costs one model turn per message that wakes it.
195
+
196
+ ```yaml
197
+ teams_watch:
198
+ action: enable
199
+ chats: ["Anna*", "Vertrieb*"] # where to listen
200
+ from: ["anna.schmidt@contoso.com"] # who to listen to
201
+ mentionOnly: false
202
+ intervalSeconds: 60
203
+ ```
204
+
205
+ - `chats` decides **where** pi listens (topic, label, chat ID, participants),
206
+ `from` decides **to whom** (name, UPN, e-mail). Narrow both: a watcher that
207
+ watches everything answers everything.
208
+ - `/teams-listen on|off|status` switches it in the session; `teams_watch
209
+ action: status` reports what is configured and what is actually running.
210
+ - The first poll only records what is there — switching it on never answers the
211
+ backlog, only what arrives afterwards.
212
+ - pi never wakes for its own messages, so it cannot answer itself.
213
+ - Listen mode does **not** bypass anything: a reply it decides to send is
214
+ subject to the same safety level and scope rules as a reply you asked for. At
215
+ `safetyLevel: confirm` every one of them is confirmed with the user first.
216
+
217
+ **When a listen prompt arrives**, do this:
218
+
219
+ 1. Judge the message before answering. "Danke, passt" needs no reply; a
220
+ question, a blocker or a decision does.
221
+ 2. If an answer is warranted, send it with `teams_send_chat_message` to the
222
+ **chat ID from the prompt** — never re-resolve the chat by name. pi posts as
223
+ the user, so the wrong chat is a wrong statement in the user's name.
224
+ 3. Keep it to the shape in *How to format a Teams message*: answer first, three
225
+ short paragraphs at most, bullets for lists. An unrequested message has to be
226
+ readable at a glance.
227
+ 4. If no answer is warranted, say so in one line and stop. Do not react, do not
228
+ touch other chats, do not post to a channel.
229
+
230
+ Never let listen mode turn into a conversation with itself: if the incoming
231
+ message is already an answer to something pi sent, the loop ends there.