copilot-tap-extension 2.0.7 → 2.0.9

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.
Files changed (58) hide show
  1. package/README.md +4 -1
  2. package/SOUL.md +51 -0
  3. package/bin/install.mjs +7 -1
  4. package/dist/copilot-instructions.md +15 -0
  5. package/dist/extension.mjs +823 -29
  6. package/dist/skills/tap-goal/SKILL.md +13 -2
  7. package/dist/skills/tap-loop/SKILL.md +6 -0
  8. package/dist/skills/tap-monitor/SKILL.md +19 -3
  9. package/dist/skills/tap-orchestrate/SKILL.md +81 -0
  10. package/dist/version.json +1 -1
  11. package/docs/adr/0001-persistent-config-default-ownership.md +33 -0
  12. package/docs/adr/0002-local-provider-gateway-runtime-security.md +36 -0
  13. package/docs/adr/0003-emitter-delivery-lifecycle.md +68 -0
  14. package/docs/adr/0004-persistent-config-canonical-streams.md +86 -0
  15. package/docs/adr/0005-provider-sdk-push-and-dynamic-tools.md +48 -0
  16. package/docs/adr/0006-command-emitter-cwd-workspace-boundary.md +46 -0
  17. package/docs/adr/0007-runtime-session-workspace-context.md +62 -0
  18. package/docs/evals.md +41 -0
  19. package/docs/evolution-of-tap-icon.html +989 -0
  20. package/docs/providers.md +242 -0
  21. package/docs/recipes/adaptive-agent.md +303 -0
  22. package/docs/recipes/agent-brainstorm/100-extension-ideas.md +288 -0
  23. package/docs/recipes/agent-brainstorm/deep-ideas.md +216 -0
  24. package/docs/recipes/ambient-guardian.md +314 -0
  25. package/docs/recipes/browser-bridge.md +162 -0
  26. package/docs/recipes/codex-goals-for-tap-goal.md +136 -0
  27. package/docs/recipes/copilot-sdk-canvas.md +147 -0
  28. package/docs/recipes/deferred-cognition.md +310 -0
  29. package/docs/recipes/provider-integration-patterns.md +93 -0
  30. package/docs/recipes/provider-interface-advanced.md +1364 -0
  31. package/docs/recipes/provider-interface-core-profile.md +568 -0
  32. package/docs/recipes/tap-control-plane-roadmap.md +60 -0
  33. package/docs/recipes/universal-tool-gateway.md +202 -0
  34. package/docs/reference.md +229 -0
  35. package/docs/use-cases.md +348 -0
  36. package/package.json +4 -1
  37. package/providers/detour/README.md +84 -0
  38. package/providers/detour/bridge.js +219 -0
  39. package/providers/detour/index.mjs +322 -0
  40. package/providers/detour/package-lock.json +577 -0
  41. package/providers/detour/package.json +19 -0
  42. package/providers/detour/scripts/build.mjs +31 -0
  43. package/providers/detour/src/bridge.js +256 -0
  44. package/providers/detour/src/contracts.js +40 -0
  45. package/providers/detour/src/inspector.js +260 -0
  46. package/providers/detour/src/inspector.test.mjs +53 -0
  47. package/providers/detour/src/panel.js +465 -0
  48. package/providers/detour/src/provider-core.js +233 -0
  49. package/providers/detour/src/provider-core.test.mjs +185 -0
  50. package/providers/detour/src/react-context-core.js +143 -0
  51. package/providers/detour/src/react-context.js +44 -0
  52. package/providers/detour/src/react-context.test.mjs +41 -0
  53. package/providers/templates/README.md +23 -0
  54. package/providers/templates/ci-review-provider.mjs +46 -0
  55. package/providers/templates/detour-workflow-provider.mjs +41 -0
  56. package/providers/templates/jira-github-provider.mjs +42 -0
  57. package/providers/templates/provider-utils.mjs +45 -0
  58. package/providers/templates/sast-triage-provider.mjs +51 -0
@@ -0,0 +1,1364 @@
1
+ # Provider Interface v2 — The Contract Between Gateway and Providers
2
+
3
+ ## Quickstart: build a provider in 5 minutes
4
+
5
+ A provider is any process that connects to the gateway and exposes tools. Here's the complete happy path:
6
+
7
+ ```
8
+ 1. Connect: ws://localhost:9400
9
+ 2. Auth: { "type": "auth", "token": "<your TAP_PROVIDER_TOKEN>" }
10
+ 3. Receive: { "type": "sessions", "active": [...] }
11
+ 4. Register: { "type": "hello", "name": "my-provider", "protocolVersion": 2,
12
+ "session": "<pick one from sessions>", "tools": [...] }
13
+ 5. Receive: { "type": "hello.ack", "providerId": "p-xxx", ... }
14
+ 6. Handle: { "type": "tool.call", "id": "c-1", "tool": "my_tool", "args": {...} }
15
+ 7. Respond: { "type": "tool.result", "id": "c-1", "data": "result" }
16
+ 8. If cancel: { "type": "tool.cancel", "id": "c-1" }
17
+ Respond: { "type": "tool.result", "id": "c-1", "errorCode": "CANCELLED" }
18
+ ```
19
+
20
+ That's it for a simple tool provider. **If you only want to expose tools, you can ignore**: hooks, transforms, push events, streams, filters, multi-instance, `"all"` binding, and reconnect tokens.
21
+
22
+ ### Minimal Node.js provider (complete, correct)
23
+
24
+ ```js
25
+ import WebSocket from "ws";
26
+
27
+ const TOKEN = process.env.TAP_PROVIDER_TOKEN;
28
+ const ws = new WebSocket("ws://localhost:9400");
29
+
30
+ ws.on("open", () => {
31
+ ws.send(JSON.stringify({ type: "auth", token: TOKEN }));
32
+ });
33
+
34
+ ws.on("message", (raw) => {
35
+ const msg = JSON.parse(raw);
36
+
37
+ if (msg.type === "sessions") {
38
+ ws.send(JSON.stringify({
39
+ type: "hello",
40
+ name: "my-provider",
41
+ protocolVersion: 2,
42
+ session: msg.active[0]?.id ?? "all",
43
+ tools: [{
44
+ name: "greet",
45
+ description: "Say hello",
46
+ parameters: { type: "object", properties: { name: { type: "string" } }, required: ["name"] }
47
+ }]
48
+ }));
49
+ }
50
+
51
+ if (msg.type === "tool.call" && msg.tool === "greet") {
52
+ ws.send(JSON.stringify({ type: "tool.result", id: msg.id, data: `Hello, ${msg.args.name}!` }));
53
+ }
54
+
55
+ if (msg.type === "tool.cancel") {
56
+ ws.send(JSON.stringify({ type: "tool.result", id: msg.id, error: "Cancelled", errorCode: "CANCELLED" }));
57
+ }
58
+
59
+ if (msg.type === "error") {
60
+ console.error(`Gateway error: ${msg.code} — ${msg.message}`);
61
+ }
62
+ });
63
+ ```
64
+
65
+ ### What's optional (ignore until you need it)
66
+
67
+ | Feature | When you need it |
68
+ |---|---|
69
+ | Push events | You want to proactively send data into the Copilot session |
70
+ | Hook rules / transforms | You want to gate or modify tool calls, or inject into the system prompt |
71
+ | Streams / filters | You produce continuous output that needs noise filtering |
72
+ | Multi-instance | Multiple instances of your provider run simultaneously (e.g., browser tabs) |
73
+ | `"all"` binding | Your provider serves multiple Copilot sessions at once |
74
+ | Reconnect tokens | Your provider may disconnect and reconnect mid-session |
75
+
76
+ ---
77
+
78
+ ## The split
79
+
80
+ ```
81
+ Extension (Gateway) Provider (tap, browser, anything)
82
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
83
+ Owns the Copilot SDK sessions Knows nothing about Copilot SDK
84
+ Runs the WS server on :9400 Connects as WS client
85
+ Calls registerTools() per session Announces tool definitions
86
+ Executes hooks in-process Sends hook rules (declarative)
87
+ Holds EventStreams Pushes events, queries streams
88
+ Manages session lifecycle Stateless (can reconnect anytime)
89
+ Tracks multiple sessions Optionally picks a session
90
+ ```
91
+
92
+ One extension, installed once. Unlimited providers, no install needed.
93
+
94
+ ## Transport abstraction
95
+
96
+ The provider interface is a message contract, not a wire format. Two transports implement it:
97
+
98
+ ```
99
+ Gateway process
100
+ ├── WsProviderTransport → JSON over WebSocket (external providers)
101
+ ├── LocalProviderTransport → direct function calls (in-process providers)
102
+ └── tap runtime (uses LocalProviderTransport — same contract, no serialization)
103
+ ```
104
+
105
+ External providers connect via `ws://localhost:9400`. In-process providers (like tap) use direct method calls with the same message shapes. The gateway treats both identically for registration, tool dispatch, and hook evaluation.
106
+
107
+ ---
108
+
109
+ ## Multi-session model
110
+
111
+ ### The problem
112
+
113
+ Multiple Copilot CLI sessions can run simultaneously on the same machine (different terminals, different projects). Each session loads the gateway extension. Only one can bind the WS port.
114
+
115
+ ### The solution: shared gateway with session registry
116
+
117
+ ```
118
+ Terminal 1: copilot (project-foo)
119
+ └─ Gateway extension starts
120
+ └─ Tries to bind :9400 → success → becomes the gateway owner
121
+ └─ Registers session "abc" (cwd: /code/foo)
122
+
123
+ Terminal 2: copilot (project-bar)
124
+ └─ Gateway extension starts
125
+ └─ Tries to bind :9400 → EADDRINUSE
126
+ └─ Connects to existing gateway as an internal client
127
+ └─ Registers session "def" (cwd: /code/bar)
128
+
129
+ Both sessions are now managed by the single gateway on :9400.
130
+ ```
131
+
132
+ ### Session registry
133
+
134
+ The gateway maintains a registry of active sessions:
135
+
136
+ ```json
137
+ [
138
+ { "id": "abc", "label": "PR #42 review", "cwd": "/code/foo", "foreground": true },
139
+ { "id": "def", "label": "feature/auth", "cwd": "/code/bar", "foreground": false }
140
+ ]
141
+ ```
142
+
143
+ ### What happens when sessions come and go
144
+
145
+ | Event | Gateway behavior |
146
+ |---|---|
147
+ | **New session registers** | Added to registry. Gateway sends `sessions.updated` to all connected providers. Internal providers spawned from that session's config are bound to it. |
148
+ | **Session ends** | Removed from registry. Internal providers bound to it are stopped. External providers bound to it receive `session.lifecycle: shutdown.pending` with a deadline. Providers bound to `"all"` remain connected for other sessions but still receive `shutdown.pending` for the ended session and must send `shutdown.ready`. Gateway sends `sessions.updated` to all providers. |
149
+ | **Gateway-owning session ends** | If other sessions remain, gateway ownership transfers — the WS server keeps running. If no sessions remain, the gateway shuts down. |
150
+ | **All sessions end** | Gateway shuts down. WS server closes. External providers disconnect. |
151
+ | **External provider is bound to a session that ends** | Provider receives `session.lifecycle: shutdown.pending`. Its tools are deregistered from that session. The provider stays connected and can re-bind to another session via a new `hello`. |
152
+
153
+ ### Provider perspective
154
+
155
+ Providers never manage sessions. They see:
156
+
157
+ 1. `sessions` message on connect — list of active sessions
158
+ 2. `sessions.updated` when sessions come or go — updated list
159
+ 3. They pick a session in `hello` (or `"all"`)
160
+ 4. If their session ends, they get `session.lifecycle: shutdown.pending` and can re-bind
161
+
162
+ ---
163
+
164
+ ## Session binding
165
+
166
+ | Provider type | Session binding | Who decides |
167
+ |---|---|---|
168
+ | **Internal** (spawned by gateway from project config) | Bound to the session that started it | Automatic — gateway stamps it |
169
+ | **External** (self-connects via WS) | Picks a session, or `"all"` | Provider decides, using session list from gateway |
170
+ | **In-process** (tap, via LocalProviderTransport) | Bound to its session | Automatic — same process |
171
+
172
+ ### What "bound to a session" means
173
+
174
+ - Provider's tools are registered only in that session's `registerTools()` call
175
+ - Provider's `push` events are injected into that session only
176
+ - Provider's hook rules apply to that session only
177
+ - Provider's transforms apply to that session only
178
+ - When `session: "all"`, everything is registered in every active session
179
+
180
+ ---
181
+
182
+ ## Provider lifecycle
183
+
184
+ ```
185
+ Provider connects via WS
186
+
187
+
188
+ Provider sends: auth (gateway secret)
189
+
190
+
191
+ Gateway sends: sessions (list of active sessions)
192
+
193
+
194
+ Provider sends: hello (name, session, instance, tools, hooks, context)
195
+
196
+
197
+ Gateway sends: hello.ack (reconnectToken, persistDir)
198
+
199
+
200
+ Gateway registers tools + hook rules in the bound session(s)
201
+
202
+ ├─── Copilot calls a tool ──► Gateway sends: tool.call
203
+ │ Provider sends: tool.result
204
+
205
+ ├─── Transform needed ──► Gateway sends: transform.request
206
+ │ Provider sends: transform.result
207
+
208
+ ├─── Gate check needed ──► Gateway sends: gate.check
209
+ │ Provider sends: gate.result
210
+
211
+ ├─── Provider pushes event ──► Gateway routes to session
212
+
213
+ ├─── Provider updates tools ──► Gateway re-registers
214
+
215
+ ├─── Sessions change ──► Gateway sends: sessions.updated
216
+
217
+
218
+ Session ending:
219
+ Gateway sends: session.lifecycle (shutdown.pending, deadline)
220
+ Provider does async cleanup
221
+ Provider sends: shutdown.ready
222
+ Gateway proceeds with teardown
223
+
224
+ Disconnect (or crash):
225
+ Gateway removes provider's tools + hook rules from bound session(s)
226
+ ```
227
+
228
+ ---
229
+
230
+ ## Protocol rules
231
+
232
+ ### Version negotiation
233
+
234
+ Provider includes `protocolVersion` in `hello`. Gateway includes `protocolVersion` in `hello.ack`.
235
+
236
+ ```json
237
+ // hello
238
+ { "type": "hello", "name": "...", "protocolVersion": 2, ... }
239
+
240
+ // hello.ack
241
+ { "type": "hello.ack", "protocolVersion": 2, "providerId": "p-8f3a", ... }
242
+ ```
243
+
244
+ - If the gateway does not support the requested version, it sends `error` with code `UNSUPPORTED_VERSION` and closes the connection.
245
+ - Receivers MUST ignore unknown fields in any message (forward-compatible).
246
+ - Receivers MUST ignore unknown message types (log and discard, do not disconnect).
247
+ - New optional message types or fields can be added in minor versions without negotiation. New required behavior requires a major version bump.
248
+
249
+ ### Provider identity
250
+
251
+ Gateway assigns a stable `providerId` in `hello.ack`. This ID is included in `error` messages and can be used for debugging. It persists across reconnects (same `reconnectToken` → same `providerId`).
252
+
253
+ ### Authentication and trust levels
254
+
255
+ The gateway uses a **tiered trust model**, not a single shared secret.
256
+
257
+ #### Trust levels
258
+
259
+ | Level | Who | Capabilities | How authenticated |
260
+ |---|---|---|---|
261
+ | **internal** | In-process providers (tap) | Full: all sessions, all hooks, all transforms | LocalProviderTransport — no auth needed |
262
+ | **project** | Spawned from `tap.config.json` | Bound to spawning session, tools + push + gates + context | Per-provider token issued at spawn time via env `TAP_PROVIDER_TOKEN` |
263
+ | **external** | Self-connecting processes | Tools + push only. No transforms, no hook callbacks, no cross-provider streams | User-approved pairing flow |
264
+
265
+ #### Internal providers
266
+
267
+ LocalProviderTransport — in-process, fully trusted, no auth message needed.
268
+
269
+ #### Project providers
270
+
271
+ Gateway generates a unique, short-lived token per provider at spawn time, passed as `TAP_PROVIDER_TOKEN` env var. Provider sends it in `auth`:
272
+
273
+ ```json
274
+ { "type": "auth", "token": "ptk-a8f3..." }
275
+ ```
276
+
277
+ Token is scoped to the spawning session. Provider cannot access other sessions.
278
+
279
+ #### External providers (browser, standalone scripts)
280
+
281
+ External providers use a **user-approved pairing flow** with out-of-band verification:
282
+
283
+ 1. Provider connects via WS and sends: `{ "type": "auth", "mode": "pair" }`
284
+ 2. Gateway generates a 6-digit pairing code and shows it in the Copilot session timeline via `session.log()`:
285
+ ```
286
+ ※ tap: Provider 'browser' requesting access. Pairing code: 847293
287
+ ```
288
+ 3. The provider displays a prompt asking the user to **type the code they see in Copilot**. The provider does NOT receive the code from the gateway — it must come from the user.
289
+ 4. User reads the code from the Copilot timeline and types it into the provider's UI.
290
+ 5. Provider sends: `{ "type": "auth.confirm", "code": "847293" }`
291
+ 6. Gateway verifies the code. On success, responds with `sessions` including a `token` field for subsequent reconnects. On failure, sends `error` with `AUTH_FAILED`.
292
+
293
+ ```json
294
+ {
295
+ "type": "sessions",
296
+ "token": "ext-tok-9f2b...",
297
+ "active": [
298
+ { "id": "abc123", "label": "PR #42 review" }
299
+ ]
300
+ }
301
+ ```
302
+
303
+ 7. On reconnect, external providers use the issued token: `{ "type": "auth", "token": "ext-tok-9f2b..." }`. Token is valid for the session lifetime, not persisted across gateway restarts.
304
+
305
+ This prevents silent hijacking — the code travels through the user, not the WS connection.
306
+
307
+ #### Identity protection
308
+
309
+ Provider identity (`name` + `instance`) is bound to the issued token. Only a connection with a valid `reconnectToken` can take over an existing identity — the gateway closes the old connection and transfers the binding. A new connection claiming the same identity **without** a valid `reconnectToken` gets `error` with code `DUPLICATE_INSTANCE` and the existing connection is unaffected.
310
+
311
+ ### Provider capabilities
312
+
313
+ Each trust level has a fixed capability set. The gateway enforces these on every message:
314
+
315
+ | Capability | internal | project | external |
316
+ |---|---|---|---|
317
+ | Register tools | ✓ | ✓ | ✓ |
318
+ | Push events (inject/surface/keep) | ✓ | ✓ | ✓ |
319
+ | Register hook gate rules | ✓ | ✓ | ✗ |
320
+ | Register transform callbacks | ✓ | ✓ | ✗ |
321
+ | Register static transforms (append) | ✓ | ✓ | ✓ |
322
+ | Query own streams | ✓ | ✓ | ✓ |
323
+ | Query cross-provider streams | ✓ | ✗ | ✗ |
324
+ | Subscribe to session events | ✓ | ✓ | tools only |
325
+ | Bind to any session / "all" | ✓ | ✗ (spawning session only) | ✗ (paired session only) |
326
+ | Set context / startup_context | ✓ | ✓ | ✗ |
327
+
328
+ Unauthorized messages receive `error` with code `UNAUTHORIZED`.
329
+
330
+ ### Protected prompt sections
331
+
332
+ The system prompt sections `safety` and `identity` are **immutable** — no provider can replace or prepend to them, regardless of trust level. Providers can only `append` to these sections. The gateway applies provider transforms BEFORE the protected sections, ensuring safety content always has the last word.
333
+
334
+ ### Session IDs and correlation IDs
335
+
336
+ All `id` and `callId` values are **globally unique** (UUIDs or equivalent). A provider can safely correlate responses without `sessionId` because IDs never collide across sessions.
337
+
338
+ All session-scoped gateway→provider messages include `sessionId`:
339
+
340
+ - `session.lifecycle`, `session.event`, `tool.call`, `tool.cancel`, `gate.check`, `transform.request`
341
+
342
+ Provider→gateway responses do NOT need `sessionId` — the gateway correlates via the globally unique `id`/`callId`. Exception: `shutdown.ready` includes `sessionId` because it's not a response to a specific call.
343
+
344
+ ### Error responses
345
+
346
+ The gateway sends `error` for any invalid message:
347
+
348
+ ```json
349
+ {
350
+ "type": "error",
351
+ "code": "INVALID_SESSION",
352
+ "message": "Session def456 does not exist",
353
+ "replyTo": "hello"
354
+ }
355
+ ```
356
+
357
+ Error codes: `INVALID_JSON`, `UNKNOWN_TYPE`, `INVALID_SESSION`, `AUTH_FAILED`, `DUPLICATE_INSTANCE`, `TOOL_CONFLICT`, `RATE_LIMITED`, `PAYLOAD_TOO_LARGE`, `UNSUPPORTED_VERSION`, `UNAUTHORIZED`.
358
+
359
+ Errors include `providerId` and `sessionId` when applicable for debugging:
360
+
361
+ ```json
362
+ {
363
+ "type": "error",
364
+ "code": "TOOL_CONFLICT",
365
+ "message": "Tool 'screenshot' already registered by provider 'browser-tab-a'",
366
+ "replyTo": "tools.update",
367
+ "providerId": "p-8f3a",
368
+ "sessionId": "abc123"
369
+ }
370
+ ```
371
+
372
+ #### Error recovery guide
373
+
374
+ | Code | Fatal? | What to do |
375
+ |---|---|---|
376
+ | `AUTH_FAILED` | Yes | Connection will close. Re-pair or check `TAP_PROVIDER_TOKEN`. |
377
+ | `UNSUPPORTED_VERSION` | Yes | Connection will close. Update your provider to a supported protocol version. |
378
+ | `INVALID_SESSION` | No | Session doesn't exist or you're not authorized. Wait for `sessions.updated`, then send new `hello`. |
379
+ | `DUPLICATE_INSTANCE` | No | Another connection has this `name`+`instance`. Pick a new `instance` or reconnect with `reconnectToken`. |
380
+ | `TOOL_CONFLICT` | No | Rename the conflicting tool and send `tools.update`. |
381
+ | `PAYLOAD_TOO_LARGE` | No | Compress/downscale the payload, or use a file ref (local providers only). Retry with smaller data. |
382
+ | `RATE_LIMITED` | No | Back off. Retry after 1 second. |
383
+ | `UNAUTHORIZED` | No | Your trust level doesn't allow this operation. Check the capability matrix. |
384
+ | `INVALID_JSON` | No | Fix the malformed message and retry. |
385
+ | `UNKNOWN_TYPE` | No | Gateway doesn't recognize this message type. Check protocol version. |
386
+
387
+ ### Update acknowledgments
388
+
389
+ All state-changing provider→gateway messages (`tools.update`, `hooks.update`, `context.update`, `filter.set`) MUST include a provider-supplied `requestId`. The gateway responds with `ack`:
390
+
391
+ ```json
392
+ // Provider sends:
393
+ { "type": "tools.update", "requestId": "req-42", "sessionId": "abc123", "tools": [...] }
394
+
395
+ // Gateway responds:
396
+ { "type": "ack", "requestId": "req-42", "sessionId": "abc123", "revision": 3 }
397
+ ```
398
+
399
+ - `requestId` — provider-chosen, echoed in `ack` for correlation
400
+ - `sessionId` — which session this revision applies to. If the update targeted all sessions (omitted `sessionId`), the gateway sends one `ack` per session.
401
+ - `revision` — monotonically increasing per (provider, session). After a provider receives `ack` with revision N, the gateway guarantees all subsequent dispatches to that session use revision N state.
402
+
403
+ Only one state update per (provider, session, message type) can be in-flight at a time. Sending a second `tools.update` for the same session before the first is acked results in `error` with code `RATE_LIMITED`.
404
+
405
+ On failure, the gateway sends `error` with the same `requestId` instead of `ack`.
406
+
407
+ ### Tool concurrency
408
+
409
+ Providers can declare concurrency limits in `hello`:
410
+
411
+ ```json
412
+ {
413
+ "type": "hello",
414
+ "name": "browser",
415
+ "concurrency": { "max": 1, "scope": "instance" },
416
+ ...
417
+ }
418
+ ```
419
+
420
+ - `max` — maximum concurrent in-flight `tool.call`s (default: unlimited)
421
+ - `scope` — what the limit applies to: `"instance"` (per name+instance), `"provider"` (all instances), or `"tool"` (per tool name)
422
+
423
+ When the limit is reached, the gateway queues additional calls and dispatches them in order as results arrive. If the queue exceeds 10, the gateway returns `errorCode: "RATE_LIMITED"` to Copilot for new calls.
424
+
425
+ ### Tool name collisions
426
+
427
+ - Two providers CANNOT register the same tool name in the same session. Second registration gets `error` with code `TOOL_CONFLICT`.
428
+ - Multi-instance providers (same `name`, different `instance`) share tool names — the gateway merges them with auto-injected `target` parameter (see Multi-instance section).
429
+ - Provider tool names MUST NOT start with `list_` followed by another provider's name (reserved for auto-generated meta-tools).
430
+
431
+ ### Terminal message ordering for tool calls
432
+
433
+ A tool call has one terminal state. The first terminal message wins:
434
+
435
+ - `tool.result` arrives → call is complete. Any later `tool.result` for the same `id` is ignored.
436
+ - `tool.cancel` sent → provider MUST respond with `tool.result { errorCode: "CANCELLED" }` as the terminal state. If a non-cancelled `tool.result` arrives after `tool.cancel`, gateway ignores it.
437
+ - Provider disconnects with in-flight calls → gateway returns `errorCode: "DISCONNECTED"` to Copilot. The call is NOT replayed on reconnect.
438
+
439
+ ### Gate timeout behavior: fail closed
440
+
441
+ Gates default to **deny** on timeout, not allow. A provider that registers a gate is asserting safety invariants. Silence = don't proceed.
442
+
443
+ ```
444
+ gate.check sent → 5s timeout → no gate.result → permissionDecision: "deny"
445
+ reason: "Gate provider 'ci-watcher' did not respond in time."
446
+ ```
447
+
448
+ Providers can opt into fail-open per gate rule: `{ "action": "gate", "gateId": "...", "failOpen": true }`.
449
+
450
+ If a provider **disconnects** with a pending `gate.check`, the gate is denied (fail closed). Pending `transform.request` calls fall back to `current` content unchanged on disconnect.
451
+
452
+ ### Reconnect protocol
453
+
454
+ 1. Gateway generates `reconnectToken` in `hello.ack`. Token is valid for 30 seconds.
455
+ 2. On reconnect, provider includes `reconnectToken` in `hello`. Gateway:
456
+ - Validates the token against the original identity (`name` + `instance`)
457
+ - Closes the old connection if still open
458
+ - Restores provider binding (session, tools, hooks)
459
+ 3. Any in-flight `tool.call` at disconnect time is **failed** with `errorCode: "DISCONNECTED"` (not replayed). The provider starts clean.
460
+ 4. Token expires after 30s — reconnect after that is a fresh `hello` (full re-auth required).
461
+ 5. Without a valid `reconnectToken`, a new connection claiming an existing identity gets `DUPLICATE_INSTANCE`. See Identity Protection.
462
+
463
+ ### Push loop prevention
464
+
465
+ The gateway enforces push budgets scoped by **(provider, sessionId)**:
466
+ - Max 10 `push` messages per second per (provider, session).
467
+ - A `push` with `level: "inject"` triggers an AI turn. The gateway will not deliver another `inject`-level push from the same provider to the same session until that session becomes idle.
468
+ - After 3 consecutive inject→response→inject cycles from the same provider in the same session, the gateway pauses that provider's inject pushes to that session and logs a warning.
469
+
470
+ ### Stream access control
471
+
472
+ - All providers can query their **own** streams via `stream.query`.
473
+ - Cross-provider stream reads require **internal** trust level. Project and external providers cannot read other providers' streams.
474
+ - `filter.set` only works on the provider's own streams.
475
+ - The gateway enforces these per-message based on the provider's trust level.
476
+
477
+ ### Session scope enforcement
478
+
479
+ The gateway MUST reject any session-scoped provider→gateway message (`push`, `tools.update`, `hooks.update`, `context.update`, `filter.set`, `stream.query`, `session.ready`, `shutdown.ready`) where the `sessionId` is outside the provider's authorized session set. Violation returns `error` with code `INVALID_SESSION`.
480
+
481
+ ### Payload limits
482
+
483
+ - Max message size: **5 MB** for `tool.result` messages (screenshots, large outputs). **2 MB** for all other messages.
484
+ - For payloads exceeding 5 MB, local providers should write to `persistDir` and return a file reference:
485
+ ```json
486
+ { "type": "tool.result", "id": "call-123", "file": { "path": "/home/user/.copilot/providers/browser/screenshot-abc.png", "mimeType": "image/png", "size": 8421000, "ttl": 300 } }
487
+ ```
488
+ - `path` — absolute path on the local filesystem. Must be within the provider's `persistDir`.
489
+ - `mimeType` — MIME type for the gateway to pass to Copilot.
490
+ - `size` — byte size.
491
+ - `ttl` — seconds until the provider may delete the file. Gateway must read it before TTL expires.
492
+ - Browser providers cannot use file refs (no filesystem access). Browser screenshots should be downscaled or compressed to stay within the 5 MB inline limit.
493
+ - Max tools per provider: **100**.
494
+ - Max hook rules per provider: **50**.
495
+ - Max streams per provider: **20**.
496
+ - EventStream retention: **200 events** per stream (oldest evicted).
497
+ - `stream.query` max `last`: **100**.
498
+ - Max concurrent WS connections: **50**. New connections beyond this are rejected.
499
+ - Max pairing attempts per minute: **5**. Prevents brute-force of pairing codes.
500
+ - Max `hello` rebinds per connection per minute: **10**. Prevents identity-churn attacks.
501
+
502
+ ### `"all"` binding and session churn
503
+
504
+ When a provider is bound to `"all"`:
505
+ - Its tools and context are registered in all **currently active** sessions at `hello` time.
506
+ - When a new session starts, the gateway sends `sessions.updated` to the provider but does **NOT** register tools/hooks/context in the new session yet. The provider must explicitly acknowledge readiness:
507
+ ```json
508
+ { "type": "session.ready", "sessionId": "new-session-id" }
509
+ ```
510
+ Only after `session.ready` does the gateway register the provider's tools/hooks/context in that session. This gives the provider time to initialize session-specific state, caches, or auth. If the provider never sends `session.ready`, its tools never appear in that session.
511
+ - **Fail-closed gate rules** are also deferred until `session.ready`.
512
+ - When a session ends, the provider receives `session.lifecycle: shutdown.pending` with that session's `sessionId`. After cleanup, provider sends `shutdown.ready` with the same `sessionId`. The provider remains connected for other sessions.
513
+
514
+ ### Gateway process model
515
+
516
+ The gateway runs as a **detached background process**, not inside any single Copilot session's extension process.
517
+
518
+ 1. First Copilot session starts → extension checks if gateway is running (attempts WS connect to `:9400`).
519
+ 2. Not running → extension spawns the gateway as a detached process (survives session end). Extension connects to it as an internal client registering its session.
520
+ 3. Already running → extension connects and registers its session.
521
+ 4. Gateway exits when the last session disconnects (after a **30s** grace period — matches reconnect token TTL, so reconnecting providers and late-arriving sessions have time).
522
+
523
+ ### Gateway crash recovery
524
+
525
+ If the gateway process crashes:
526
+
527
+ 1. All WS connections drop. Providers detect disconnect via WS `onclose`.
528
+ 2. All session registrations, provider bindings, reconnect tokens, and revision counters are lost (not persisted).
529
+ 3. The next Copilot session start (or an existing session's heartbeat failure) spawns a new gateway.
530
+ 4. Providers must treat a gateway restart as a **fresh connection**: re-authenticate, send a new `hello`, re-register tools. `reconnectToken` from the old gateway is invalid.
531
+ 5. Copilot sessions must re-register themselves with the new gateway.
532
+
533
+ The gateway is designed to be **stateless and reconstructable**. All durable state lives in providers (their own config files) and sessions (Copilot SDK session state). The gateway is a relay, not a store.
534
+
535
+ ### Ordering guarantees
536
+
537
+ - **Per-connection FIFO**: messages on a single WS connection are delivered in send order (guaranteed by WebSocket/TCP). LocalProviderTransport provides the same guarantee via synchronous dispatch.
538
+ - **No total ordering across sessions**: messages for session A and session B on the same provider connection may interleave freely.
539
+ - **Per-call terminal semantics**: for a given `tool.call` ID, only the first terminal message (`tool.result` or gateway-generated `DISCONNECTED`/`CANCELLED`) is accepted.
540
+ - **Revision barrier**: after `ack(revision=N)` for a (provider, session), all subsequent dispatches to that session use revision N state. No dispatch uses stale state.
541
+ - **Rebind barrier**: when a provider sends a new `hello` (rebind), the gateway completes removal of old state before processing the new registration. No dispatches from the old binding arrive after rebind starts.
542
+
543
+ ### Connection states
544
+
545
+ A provider connection has these states:
546
+
547
+ ```
548
+ Connected → AwaitAuth → AwaitPairing (external only) → AwaitHello → Bound → Disconnected
549
+
550
+ Rebinding
551
+
552
+ Unbound (on error)
553
+ ```
554
+
555
+ Legal messages per state:
556
+
557
+ | State | Legal provider messages |
558
+ |---|---|
559
+ | `AwaitAuth` | `auth` only |
560
+ | `AwaitPairing` | `auth.confirm` only |
561
+ | `AwaitHello` | `hello` only |
562
+ | `Bound` | All provider→gateway messages |
563
+ | `Rebinding` | None (gateway is processing) |
564
+ | `Unbound` | `hello`, `goodbye` only |
565
+
566
+ ### Regex execution safety
567
+
568
+ - `match.args` patterns are compiled with a **1ms execution timeout** (per match attempt). Catastrophic backtracking is terminated.
569
+ - Stringification format for args: `JSON.stringify(args)`. Deterministic across runtimes.
570
+ - `filter.set` rules use the same regex engine with the same timeout.
571
+
572
+ ---
573
+
574
+ ## Messages: Gateway → Provider
575
+
576
+ ### `auth.pairing` — pairing initiated for external providers
577
+
578
+ Sent after receiving `{ "type": "auth", "mode": "pair" }` from an external provider.
579
+
580
+ ```json
581
+ {
582
+ "type": "auth.pairing",
583
+ "prompt": "Enter the pairing code shown in your Copilot session"
584
+ }
585
+ ```
586
+
587
+ The gateway displays the code in the Copilot session timeline. The provider shows a text input asking the user to type the code. The code is NOT sent to the provider — it travels through the user. After the user enters the code, the provider sends `auth.confirm`.
588
+
589
+ ### `sessions` — active session list (sent after successful auth)
590
+
591
+ ```json
592
+ {
593
+ "type": "sessions",
594
+ "token": "ext-tok-9f2b...",
595
+ "active": [
596
+ { "id": "abc123", "label": "PR #42 review", "cwd": "/code/foo" },
597
+ { "id": "def456", "label": "feature/auth", "cwd": "/code/bar" }
598
+ ]
599
+ }
600
+ ```
601
+
602
+ `token` is only present for external providers after successful pairing. Project providers (token-based auth) and internal providers do not receive it. `cwd` is only visible for sessions the provider is authorized to access.
603
+
604
+ ### `sessions.updated` — session list changed
605
+
606
+ Same shape as `sessions`. Sent when a session starts or ends.
607
+
608
+ ### `hello.ack` — registration acknowledged
609
+
610
+ ```json
611
+ {
612
+ "type": "hello.ack",
613
+ "protocolVersion": 2,
614
+ "providerId": "p-8f3a",
615
+ "reconnectToken": "tok-xyz789",
616
+ "persistDir": "/home/user/.copilot/providers/my-provider/"
617
+ }
618
+ ```
619
+
620
+ - `reconnectToken` — include in future `hello` to restore binding after disconnect
621
+ - `persistDir` — filesystem path for cross-session state (local providers only)
622
+
623
+ ### `tool.call` — Copilot invoked a provider's tool
624
+
625
+ ```json
626
+ {
627
+ "type": "tool.call",
628
+ "id": "call-123",
629
+ "sessionId": "abc123",
630
+ "tool": "my_tool",
631
+ "args": { "query": "find active users" }
632
+ }
633
+ ```
634
+
635
+ ### `tool.cancel` — abort an in-flight tool call
636
+
637
+ ```json
638
+ {
639
+ "type": "tool.cancel",
640
+ "id": "call-123",
641
+ "sessionId": "abc123",
642
+ "reason": "timeout"
643
+ }
644
+ ```
645
+
646
+ Sent when a tool call exceeds its timeout or the session is interrupted. Provider should abort and send `tool.result` with `errorCode: "CANCELLED"` as the terminal state.
647
+
648
+ ### `gate.check` — a hook rule matched, provider evaluates
649
+
650
+ ```json
651
+ {
652
+ "type": "gate.check",
653
+ "gateId": "check-before-push",
654
+ "callId": "gate-456",
655
+ "sessionId": "abc123",
656
+ "tool": "shell",
657
+ "args": { "command": "git push origin main" }
658
+ }
659
+ ```
660
+
661
+ Timeout: 5s. If no response, gateway **denies** the action (fail closed). See Protocol Rules.
662
+
663
+ ### `transform.request` — dynamic transform callback
664
+
665
+ Sent during `onUserPromptSubmitted` when a provider registered a `"callback"` transform.
666
+
667
+ ```json
668
+ {
669
+ "type": "transform.request",
670
+ "callId": "tx-789",
671
+ "sessionId": "abc123",
672
+ "section": "custom_instructions",
673
+ "current": "...existing section content..."
674
+ }
675
+ ```
676
+
677
+ Timeout: 2s. Falls back to `current` unchanged if no response.
678
+
679
+ ### `session.event` — forwarded session events (if subscribed)
680
+
681
+ ```json
682
+ {
683
+ "type": "session.event",
684
+ "sessionId": "abc123",
685
+ "event": "user.message",
686
+ "data": { "content": "fix the auth bug" }
687
+ }
688
+ ```
689
+
690
+ #### Event payload shapes
691
+
692
+ | Event | Payload |
693
+ |---|---|
694
+ | `user.message` | `{ content: string }` |
695
+ | `assistant.message` | `{ content: string, toolRequests?: [{ name, args }] }` |
696
+ | `tool.execution_complete` | `{ tool: string, provider?: string, args: object, result: { type: "success"\|"failure", output?: string }, durationMs: number }` |
697
+ | `assistant.intent` | `{ intent: string }` |
698
+
699
+ Providers opt into events in `hello.subscribe`:
700
+
701
+ ```json
702
+ { "subscribe": ["user.message", "assistant.message", "tool.execution_complete"] }
703
+ ```
704
+
705
+ ### `session.lifecycle` — session state changes
706
+
707
+ ```json
708
+ { "type": "session.lifecycle", "sessionId": "abc123", "state": "started" }
709
+ { "type": "session.lifecycle", "sessionId": "abc123", "state": "idle" }
710
+ { "type": "session.lifecycle", "sessionId": "abc123", "state": "shutdown.pending", "deadline": 10000 }
711
+ ```
712
+
713
+ Always sent, no opt-in needed.
714
+
715
+ - `started` — session is ready
716
+ - `idle` — session is idle (no in-flight work). Providers can use this to trigger scheduled work.
717
+ - `shutdown.pending` — session is ending. Provider has `deadline` milliseconds to do async cleanup, then send `shutdown.ready`. Gateway tears down after deadline even if no response.
718
+
719
+ ### Rebinding (repeat `hello` on existing connection)
720
+
721
+ A provider can send a new `hello` on an existing connection to change its session binding (e.g., after its session ends). Behavior:
722
+
723
+ 1. Gateway atomically removes the provider's tools/hooks/context from the old session(s).
724
+ 2. Gateway cancels any in-flight `tool.call`, `gate.check`, or `transform.request` for this provider.
725
+ 3. Gateway processes the new `hello` as a fresh registration (validates session, registers tools).
726
+ 4. Gateway sends a new `hello.ack` with a new `reconnectToken`.
727
+ 5. If the new `hello` fails validation, gateway sends `error` and the provider remains unbound (connected but not registered to any session).
728
+
729
+ ### `stream.history` — response to stream query
730
+
731
+ ```json
732
+ {
733
+ "type": "stream.history",
734
+ "queryId": "q-1",
735
+ "streams": {
736
+ "ci-watch@ci-watcher": [
737
+ { "ts": "2026-04-26T14:01:00Z", "event": "failure on test/auth.spec.ts" },
738
+ { "ts": "2026-04-26T14:00:00Z", "event": "running" }
739
+ ],
740
+ "git-watch@guardian": [
741
+ { "ts": "2026-04-26T14:00:30Z", "event": "behind=2" }
742
+ ]
743
+ }
744
+ }
745
+ ```
746
+
747
+ Stream keys use `stream@provider` format matching the query.
748
+
749
+ ---
750
+
751
+ ## Messages: Provider → Gateway
752
+
753
+ ### `auth` — authenticate on connect
754
+
755
+ ```json
756
+ { "type": "auth", "token": "ptk-a8f3..." }
757
+ ```
758
+
759
+ For project providers (spawned by gateway). Or for external providers using the pairing flow:
760
+
761
+ ```json
762
+ { "type": "auth", "mode": "pair" }
763
+ ```
764
+
765
+ Gateway responds with `auth.pairing` (external) or `sessions` (project/token).
766
+
767
+ ### `auth.confirm` — confirm pairing code
768
+
769
+ ```json
770
+ { "type": "auth.confirm", "code": "847293" }
771
+ ```
772
+
773
+ Sent by external providers after receiving `auth.pairing` and the user has verified the code matches. Gateway responds with `sessions` on success, `error` with `AUTH_FAILED` on wrong code.
774
+
775
+ ### `hello` — register as a provider
776
+
777
+ ```json
778
+ {
779
+ "type": "hello",
780
+ "name": "my-provider",
781
+ "protocolVersion": 2,
782
+ "session": "abc123",
783
+ "instance": "tab-a3f8",
784
+ "reconnectToken": "tok-xyz789",
785
+ "startup_context": "Provider loaded. Monitoring 3 endpoints.",
786
+ "metadata": {
787
+ "url": "https://app.example.com",
788
+ "title": "Dashboard"
789
+ },
790
+ "tools": [
791
+ {
792
+ "name": "my_tool",
793
+ "description": "Does something useful",
794
+ "timeout": 15000,
795
+ "parameters": {
796
+ "type": "object",
797
+ "properties": {
798
+ "query": { "type": "string" }
799
+ },
800
+ "required": ["query"]
801
+ }
802
+ }
803
+ ],
804
+ "hooks": {
805
+ "onPreToolUse": [
806
+ {
807
+ "match": { "tool": "shell", "args": "git push" },
808
+ "action": "gate",
809
+ "gateId": "check-before-push"
810
+ }
811
+ ],
812
+ "transforms": {
813
+ "code_change_rules": { "action": "callback" },
814
+ "custom_instructions": {
815
+ "action": "append",
816
+ "content": "This repo uses pnpm, not npm."
817
+ }
818
+ }
819
+ },
820
+ "subscribe": ["user.message", "assistant.message"],
821
+ "context": "CI is currently passing. No active deploys."
822
+ }
823
+ ```
824
+
825
+ | Field | Required | Description |
826
+ |---|---|---|
827
+ | `name` | yes | Provider identity |
828
+ | `protocolVersion` | yes | Protocol version (currently `2`) |
829
+ | `session` | no | Session to bind to. Omit for internal providers (gateway auto-stamps). `"all"` for broadcast. |
830
+ | `instance` | no | Unique instance ID for multi-instance providers (e.g., browser tabs). Gateway uses `name` + `instance` as compound key. |
831
+ | `reconnectToken` | no | Token from previous `hello.ack` to restore binding after disconnect. |
832
+ | `startup_context` | no | Injected into session start context (not per-prompt). |
833
+ | `metadata` | no | Provider-specific info exposed to Copilot for routing decisions. |
834
+ | `tools` | no | Tool definitions with JSON Schema parameters. `timeout` (ms) per tool is optional. |
835
+ | `hooks` | no | Hook rules and transform declarations. |
836
+ | `subscribe` | no | Session event types to receive. External providers limited to tool events. |
837
+ | `context` | no | Ambient context injected on every user prompt. Not available for external providers. |
838
+
839
+ ### `tool.result` — respond to a tool invocation
840
+
841
+ Success:
842
+ ```json
843
+ {
844
+ "type": "tool.result",
845
+ "id": "call-123",
846
+ "data": { "user": "alice", "role": "admin" }
847
+ }
848
+ ```
849
+
850
+ Failure:
851
+ ```json
852
+ {
853
+ "type": "tool.result",
854
+ "id": "call-123",
855
+ "error": "Element not found: #submit-btn",
856
+ "errorCode": "NOT_FOUND",
857
+ "retryable": false
858
+ }
859
+ ```
860
+
861
+ Error codes: `NOT_FOUND`, `TIMEOUT`, `CANCELLED`, `DISCONNECTED`, `UNAUTHORIZED`, `INTERNAL`. `retryable` hints whether the gateway should retry on another instance.
862
+
863
+ ### `tool.progress` — incremental status for slow tools
864
+
865
+ ```json
866
+ {
867
+ "type": "tool.progress",
868
+ "id": "call-123",
869
+ "message": "Capturing viewport... 60%"
870
+ }
871
+ ```
872
+
873
+ Gateway surfaces via `session.log()`. Final result still comes via `tool.result`.
874
+
875
+ ### `gate.result` — respond to a hook gate check
876
+
877
+ ```json
878
+ {
879
+ "type": "gate.result",
880
+ "gateId": "check-before-push",
881
+ "callId": "gate-456",
882
+ "decision": "deny",
883
+ "reason": "CI is failing on this branch. Fix tests first."
884
+ }
885
+ ```
886
+
887
+ `decision`: `"allow"` | `"deny"` | `"context"` (allow but inject `reason` as additional context).
888
+
889
+ ### `transform.result` — respond to a transform callback
890
+
891
+ ```json
892
+ {
893
+ "type": "transform.result",
894
+ "callId": "tx-789",
895
+ "content": "...existing content plus dynamic additions based on live state..."
896
+ }
897
+ ```
898
+
899
+ ### `push` — send an event into the session
900
+
901
+ ```json
902
+ {
903
+ "type": "push",
904
+ "stream": "ci-watch",
905
+ "event": "CI failed on test/auth.spec.ts",
906
+ "level": "inject",
907
+ "metadata": { "kind": "ci-failure", "runId": 12345 }
908
+ }
909
+ ```
910
+
911
+ | Field | Required | Description |
912
+ |---|---|---|
913
+ | `stream` | no | Named stream. Defaults to provider name if omitted. One provider can manage multiple streams. |
914
+ | `sessionId` | no | Target session. **Required** for `"all"`-bound providers (must specify a session or `"broadcast": true`). Single-session providers can always omit. |
915
+ | `event` | yes (unless `prompt`) | Event text to store/surface/inject. |
916
+ | `prompt` | no | When present, triggers a full AI turn via `session.send({ prompt })`. Use for PromptEmitter-style injections. |
917
+ | `level` | yes | `"inject"` = `session.send()`, triggers AI turn. `"surface"` = `session.log()`, visible in timeline. `"keep"` = store in EventStream only. |
918
+ | `metadata` | no | Structured data for display, deduplication, chaining. |
919
+
920
+ ### `tools.update` — change tool definitions
921
+
922
+ ```json
923
+ {
924
+ "type": "tools.update",
925
+ "sessionId": "abc123",
926
+ "tools": [
927
+ { "name": "new_tool", "description": "Just appeared", "parameters": {} }
928
+ ],
929
+ "remove": ["old_tool"]
930
+ }
931
+ ```
932
+
933
+ `sessionId` is optional. Omit to apply to all bound sessions. `"all"`-bound providers use it to update tools in one session only.
934
+
935
+ ### `hooks.update` — change hook rules or transforms
936
+
937
+ ```json
938
+ {
939
+ "type": "hooks.update",
940
+ "sessionId": "abc123",
941
+ "onPreToolUse": [
942
+ {
943
+ "match": { "tool": "edit", "file": "*.sql" },
944
+ "action": "context",
945
+ "content": "This is a migration file. Ensure backward compatibility."
946
+ }
947
+ ],
948
+ "transforms": {
949
+ "code_change_rules": { "action": "callback" },
950
+ "custom_instructions": null
951
+ }
952
+ }
953
+ ```
954
+
955
+ Setting a transform to `null` removes it. `"callback"` triggers `transform.request` round-trips. `sessionId` is optional — omit to apply to all bound sessions.
956
+
957
+ ### `context.update` — change ambient context
958
+
959
+ ```json
960
+ {
961
+ "type": "context.update",
962
+ "context": "CI is now passing. Deploy v2.4.3 completed."
963
+ }
964
+ ```
965
+
966
+ ### `filter.set` — set gateway-side EventFilter for a stream
967
+
968
+ ```json
969
+ {
970
+ "type": "filter.set",
971
+ "stream": "git-watch",
972
+ "rules": [
973
+ { "match": "behind=0", "outcome": "drop" },
974
+ { "match": "conflicts=[1-9]", "outcome": "inject" },
975
+ { "match": ".*", "outcome": "keep" }
976
+ ]
977
+ }
978
+ ```
979
+
980
+ When a filter exists, the gateway applies it to `push` events on that stream. The `level` field on `push` is overridden by the filter outcome. First-match wins.
981
+
982
+ ### `stream.query` — read EventStream history
983
+
984
+ ```json
985
+ {
986
+ "type": "stream.query",
987
+ "queryId": "q-1",
988
+ "sessionId": "abc123",
989
+ "streams": ["ci-watch@ci-watcher", "git-watch@guardian"],
990
+ "last": 10
991
+ }
992
+ ```
993
+
994
+ `sessionId` is optional for single-session providers, required for `"all"`-bound providers.
995
+
996
+ Stream names use the format `stream@provider` to avoid collisions. Omit `@provider` to query your own streams. Cross-provider reads are restricted to **internal** trust level only (see Provider Capabilities).
997
+
998
+ ### `session.ready` — acknowledge readiness for a new session
999
+
1000
+ ```json
1001
+ {
1002
+ "type": "session.ready",
1003
+ "sessionId": "new-session-id"
1004
+ }
1005
+ ```
1006
+
1007
+ Only used by `"all"`-bound providers. When a new session starts, the gateway sends `sessions.updated` but does NOT register the provider's tools in the new session until `session.ready` is received. This gives the provider time to initialize session-specific state.
1008
+
1009
+ ### `shutdown.ready` — async cleanup complete
1010
+
1011
+ ```json
1012
+ {
1013
+ "type": "shutdown.ready",
1014
+ "sessionId": "abc123"
1015
+ }
1016
+ ```
1017
+
1018
+ Sent after `session.lifecycle: shutdown.pending`. Tells the gateway this provider is done cleaning up.
1019
+
1020
+ ### `goodbye` — graceful disconnect
1021
+
1022
+ ```json
1023
+ {
1024
+ "type": "goodbye",
1025
+ "reason": "shutting down"
1026
+ }
1027
+ ```
1028
+
1029
+ ---
1030
+
1031
+ ## Multi-instance providers (browser tabs)
1032
+
1033
+ When multiple providers share the same `name` (e.g., 5 browser tabs), the gateway:
1034
+
1035
+ 1. Uses `name` + `instance` as the compound key
1036
+ 2. Registers **one** copy of each shared tool with an auto-injected `target` parameter
1037
+ 3. Generates a meta-tool `list_{name}_instances` from connected instances + metadata
1038
+ 4. Routes `tool.call` to the matching instance via `target`
1039
+ 5. If `target` is omitted, routes to the most recently active instance
1040
+
1041
+ ```json
1042
+ // Auto-generated tool schema (gateway creates this)
1043
+ {
1044
+ "name": "browser_screenshot",
1045
+ "description": "Screenshot the viewport",
1046
+ "parameters": {
1047
+ "type": "object",
1048
+ "properties": {
1049
+ "target": {
1050
+ "type": "string",
1051
+ "description": "Tab instance ID. Available: tab-a3f8 (Dashboard — MyApp), tab-b2c1 (Settings)"
1052
+ }
1053
+ }
1054
+ }
1055
+ }
1056
+ ```
1057
+
1058
+ ```json
1059
+ // Auto-generated meta-tool
1060
+ {
1061
+ "name": "list_browser_instances",
1062
+ "description": "List connected browser tab instances",
1063
+ "handler": "returns instance IDs + metadata for all connected browser providers"
1064
+ }
1065
+ ```
1066
+
1067
+ ---
1068
+
1069
+ ## Hook rules — declarative API
1070
+
1071
+ Providers declare rules, gateway evaluates them in-process.
1072
+
1073
+ ### onPreToolUse rules
1074
+
1075
+ | Action | Behavior | Round-trip? |
1076
+ |---|---|---|
1077
+ | `"deny"` | Block the tool call with `reason` | No (static) |
1078
+ | `"context"` | Allow but inject `content` as additional context | No (static) |
1079
+ | `"gate"` | Ask the provider to evaluate via `gate.check`/`gate.result` | Yes (5s timeout) |
1080
+
1081
+ ```json
1082
+ {
1083
+ "match": { "tool": "shell", "args": "git push" },
1084
+ "action": "gate",
1085
+ "gateId": "check-ci-status"
1086
+ }
1087
+ ```
1088
+
1089
+ `match.tool` is the tool name. `match.args` is a regex tested against stringified args. `match.provider` scopes to a specific provider's tools (omit for all tools).
1090
+
1091
+ ### Transform rules
1092
+
1093
+ | Action | Behavior | Round-trip? |
1094
+ |---|---|---|
1095
+ | `"append"` | Append static `content` to section | No |
1096
+ | `"prepend"` | Prepend static `content` to section | No |
1097
+ | `"replace"` | Replace section with static `content` | No |
1098
+ | `"callback"` | Ask provider at prompt time via `transform.request`/`transform.result` | Yes (2s timeout) |
1099
+
1100
+ Multiple providers can append to the same section. Gateway concatenates in registration order.
1101
+
1102
+ ---
1103
+
1104
+ ## Summary: the complete interface
1105
+
1106
+ ### Gateway → Provider (13 message types)
1107
+
1108
+ | Message | When | Round-trip? |
1109
+ |---|---|---|
1110
+ | `auth.pairing` | After external provider requests pairing | Expects `auth.confirm` |
1111
+ | `sessions` | After successful auth | — |
1112
+ | `sessions.updated` | Session starts/ends | — |
1113
+ | `hello.ack` | After `hello` (includes `providerId`, `protocolVersion`) | — |
1114
+ | `ack` | After `tools.update`, `hooks.update`, `context.update`, `filter.set` | — |
1115
+ | `error` | Invalid message from provider (includes `providerId`, `sessionId`) | — |
1116
+ | `tool.call` | Copilot invokes a tool | Expects `tool.result` |
1117
+ | `tool.cancel` | Tool timed out or session interrupted | — |
1118
+ | `gate.check` | Hook rule matched with `action: "gate"` | Expects `gate.result` (5s, fail closed) |
1119
+ | `transform.request` | Prompt submitted, provider has callback transform | Expects `transform.result` (2s) |
1120
+ | `session.event` | Session event (if subscribed) | — |
1121
+ | `session.lifecycle` | Session state change (includes `sessionId`) | — |
1122
+ | `stream.history` | Response to `stream.query` | — |
1123
+
1124
+ ### Provider → Gateway (18 message types)
1125
+
1126
+ | Message | When |
1127
+ |---|---|
1128
+ | `auth` | First message on connect (token or pairing mode) |
1129
+ | `auth.confirm` | Confirm pairing code (external providers) |
1130
+ | `hello` | After receiving `sessions` (includes `protocolVersion`) |
1131
+ | `goodbye` | Graceful disconnect |
1132
+ | `session.ready` | Acknowledge readiness for a new session (`"all"`-bound providers) |
1133
+ | `tool.result` | Responding to `tool.call` |
1134
+ | `tool.progress` | Incremental status for slow tools |
1135
+ | `gate.result` | Responding to `gate.check` |
1136
+ | `transform.result` | Responding to `transform.request` |
1137
+ | `push` | Unsolicited event or prompt |
1138
+ | `tools.update` | Add/remove tools |
1139
+ | `hooks.update` | Change hook rules or transforms |
1140
+ | `context.update` | Change ambient context |
1141
+ | `filter.set` | Set/update EventFilter rules on a stream |
1142
+ | `stream.query` | Read EventStream history |
1143
+ | `shutdown.ready` | Async cleanup complete (includes `sessionId`) |
1144
+
1145
+ ### Total: 31 message types
1146
+
1147
+ ---
1148
+
1149
+ ## What a minimal provider looks like
1150
+
1151
+ These examples are the **normative happy path** — they include all required fields.
1152
+
1153
+ ### Node.js (project provider — token auth)
1154
+
1155
+ ```js
1156
+ import WebSocket from "ws";
1157
+
1158
+ const TOKEN = process.env.TAP_PROVIDER_TOKEN;
1159
+ const ws = new WebSocket("ws://localhost:9400");
1160
+
1161
+ ws.on("open", () => {
1162
+ ws.send(JSON.stringify({ type: "auth", token: TOKEN }));
1163
+ });
1164
+
1165
+ ws.on("message", (raw) => {
1166
+ const msg = JSON.parse(raw);
1167
+
1168
+ if (msg.type === "sessions") {
1169
+ ws.send(JSON.stringify({
1170
+ type: "hello",
1171
+ name: "hello-provider",
1172
+ protocolVersion: 2,
1173
+ session: msg.active[0]?.id ?? "all",
1174
+ tools: [{
1175
+ name: "say_hello",
1176
+ description: "Says hello to someone",
1177
+ parameters: {
1178
+ type: "object",
1179
+ properties: { name: { type: "string" } },
1180
+ required: ["name"]
1181
+ }
1182
+ }]
1183
+ }));
1184
+ }
1185
+
1186
+ if (msg.type === "tool.call" && msg.tool === "say_hello") {
1187
+ ws.send(JSON.stringify({
1188
+ type: "tool.result",
1189
+ id: msg.id,
1190
+ data: `Hello, ${msg.args.name}!`
1191
+ }));
1192
+ }
1193
+
1194
+ if (msg.type === "tool.cancel") {
1195
+ ws.send(JSON.stringify({
1196
+ type: "tool.result",
1197
+ id: msg.id,
1198
+ error: "Cancelled",
1199
+ errorCode: "CANCELLED"
1200
+ }));
1201
+ }
1202
+
1203
+ if (msg.type === "error") {
1204
+ console.error(`Gateway error: ${msg.code} — ${msg.message}`);
1205
+ }
1206
+ });
1207
+ ```
1208
+
1209
+ ### Browser (external provider — pairing auth)
1210
+
1211
+ ```js
1212
+ const GATEWAY = "localhost:9400";
1213
+ let ws, sessions = [], registered = false;
1214
+ let reconnectToken = null, authToken = null;
1215
+ const INSTANCE = "tab-" + Math.random().toString(36).slice(2, 6);
1216
+
1217
+ // Step 1: connect and request pairing
1218
+ fetch(`http://${GATEWAY}/secret`)
1219
+ .catch(() => null); // no secret endpoint anymore — we use pairing
1220
+
1221
+ function connect() {
1222
+ ws = new WebSocket(`ws://${GATEWAY}`);
1223
+
1224
+ ws.onopen = () => {
1225
+ // Step 2: authenticate (use issued token on reconnect, pair on first connect)
1226
+ if (authToken) {
1227
+ ws.send(JSON.stringify({ type: "auth", token: authToken }));
1228
+ } else {
1229
+ ws.send(JSON.stringify({ type: "auth", mode: "pair" }));
1230
+ }
1231
+ };
1232
+
1233
+ ws.onmessage = (e) => {
1234
+ const msg = JSON.parse(e.data);
1235
+
1236
+ // Step 3: handle pairing — show input for user to type the code from Copilot
1237
+ if (msg.type === "auth.pairing") {
1238
+ const code = prompt("Enter the pairing code from your Copilot session:");
1239
+ if (code) {
1240
+ ws.send(JSON.stringify({ type: "auth.confirm", code }));
1241
+ }
1242
+ return;
1243
+ }
1244
+
1245
+ // Step 4: receive session list or ack
1246
+ if (msg.type === "sessions" || msg.type === "sessions.updated") {
1247
+ sessions = msg.active;
1248
+ if (msg.token) authToken = msg.token; // store issued token for reconnect
1249
+ if (!registered) {
1250
+ if (sessions.length === 0) showOverlay("Waiting for Copilot session...");
1251
+ else if (sessions.length === 1) register(sessions[0].id);
1252
+ else showSessionPicker(sessions, (s) => register(s.id));
1253
+ }
1254
+ return;
1255
+ }
1256
+
1257
+ if (msg.type === "hello.ack") {
1258
+ reconnectToken = msg.reconnectToken; // persist for reconnect
1259
+ return;
1260
+ }
1261
+
1262
+ if (msg.type === "tool.call") handleToolCall(msg);
1263
+ if (msg.type === "tool.cancel") handleCancel(msg);
1264
+ };
1265
+
1266
+ ws.onclose = () => {
1267
+ registered = false;
1268
+ setTimeout(connect, 5000); // auto-reconnect
1269
+ };
1270
+ }
1271
+
1272
+ function register(sessionId) {
1273
+ registered = true;
1274
+ ws.send(JSON.stringify({
1275
+ type: "hello",
1276
+ name: "browser",
1277
+ protocolVersion: 2,
1278
+ instance: INSTANCE,
1279
+ reconnectToken,
1280
+ session: sessionId,
1281
+ metadata: { url: location.href, title: document.title },
1282
+ tools: [
1283
+ { name: "page_title", description: "Get page title", parameters: {} },
1284
+ { name: "screenshot", description: "Screenshot viewport (downscaled to <5MB)", timeout: 15000, parameters: {} }
1285
+ ]
1286
+ }));
1287
+ }
1288
+
1289
+ function handleToolCall(msg) {
1290
+ if (msg.tool === "page_title") {
1291
+ ws.send(JSON.stringify({ type: "tool.result", id: msg.id, data: document.title }));
1292
+ }
1293
+ if (msg.tool === "screenshot") {
1294
+ ws.send(JSON.stringify({ type: "tool.progress", id: msg.id, message: "Capturing..." }));
1295
+ html2canvas(document.body, { scale: 0.5 }).then(canvas => {
1296
+ ws.send(JSON.stringify({
1297
+ type: "tool.result", id: msg.id,
1298
+ data: { image: canvas.toDataURL("image/jpeg", 0.7) }
1299
+ }));
1300
+ });
1301
+ }
1302
+ }
1303
+
1304
+ function handleCancel(msg) {
1305
+ // Best-effort: send CANCELLED result
1306
+ ws.send(JSON.stringify({
1307
+ type: "tool.result", id: msg.id,
1308
+ error: "Cancelled", errorCode: "CANCELLED", retryable: false
1309
+ }));
1310
+ }
1311
+ ```
1312
+
1313
+ ### Python (project provider — token auth)
1314
+
1315
+ ```python
1316
+ import asyncio, json, os, websockets
1317
+
1318
+ TOKEN = os.environ["TAP_PROVIDER_TOKEN"]
1319
+
1320
+ async def provider():
1321
+ async with websockets.connect("ws://localhost:9400") as ws:
1322
+ await ws.send(json.dumps({"type": "auth", "token": TOKEN}))
1323
+
1324
+ async for raw in ws:
1325
+ msg = json.loads(raw)
1326
+
1327
+ if msg["type"] == "sessions":
1328
+ await ws.send(json.dumps({
1329
+ "type": "hello",
1330
+ "name": "python-provider",
1331
+ "protocolVersion": 2,
1332
+ "session": msg["active"][0]["id"] if msg["active"] else "all",
1333
+ "tools": [{
1334
+ "name": "compute",
1335
+ "description": "Evaluate a Python expression",
1336
+ "parameters": {
1337
+ "type": "object",
1338
+ "properties": {"expr": {"type": "string"}},
1339
+ "required": ["expr"]
1340
+ }
1341
+ }]
1342
+ }))
1343
+
1344
+ if msg["type"] == "tool.call" and msg["tool"] == "compute":
1345
+ try:
1346
+ result = eval(msg["args"]["expr"])
1347
+ except Exception as e:
1348
+ result = str(e)
1349
+ await ws.send(json.dumps({
1350
+ "type": "tool.result",
1351
+ "id": msg["id"],
1352
+ "data": result
1353
+ }))
1354
+
1355
+ if msg["type"] == "tool.cancel":
1356
+ await ws.send(json.dumps({
1357
+ "type": "tool.result",
1358
+ "id": msg["id"],
1359
+ "error": "Cancelled",
1360
+ "errorCode": "CANCELLED"
1361
+ }))
1362
+
1363
+ asyncio.run(provider())
1364
+ ```