copilot-tap-extension 2.0.8 → 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.
- package/README.md +2 -1
- package/SOUL.md +51 -0
- package/bin/install.mjs +2 -1
- package/dist/copilot-instructions.md +5 -0
- package/dist/extension.mjs +361 -20
- package/dist/version.json +1 -1
- package/docs/adr/0001-persistent-config-default-ownership.md +33 -0
- package/docs/adr/0002-local-provider-gateway-runtime-security.md +36 -0
- package/docs/adr/0003-emitter-delivery-lifecycle.md +68 -0
- package/docs/adr/0004-persistent-config-canonical-streams.md +86 -0
- package/docs/adr/0005-provider-sdk-push-and-dynamic-tools.md +48 -0
- package/docs/adr/0006-command-emitter-cwd-workspace-boundary.md +46 -0
- package/docs/adr/0007-runtime-session-workspace-context.md +62 -0
- package/docs/evals.md +41 -0
- package/docs/evolution-of-tap-icon.html +989 -0
- package/docs/providers.md +242 -0
- package/docs/recipes/adaptive-agent.md +303 -0
- package/docs/recipes/agent-brainstorm/100-extension-ideas.md +288 -0
- package/docs/recipes/agent-brainstorm/deep-ideas.md +216 -0
- package/docs/recipes/ambient-guardian.md +314 -0
- package/docs/recipes/browser-bridge.md +162 -0
- package/docs/recipes/codex-goals-for-tap-goal.md +136 -0
- package/docs/recipes/copilot-sdk-canvas.md +147 -0
- package/docs/recipes/deferred-cognition.md +310 -0
- package/docs/recipes/provider-integration-patterns.md +93 -0
- package/docs/recipes/provider-interface-advanced.md +1364 -0
- package/docs/recipes/provider-interface-core-profile.md +568 -0
- package/docs/recipes/tap-control-plane-roadmap.md +60 -0
- package/docs/recipes/universal-tool-gateway.md +202 -0
- package/docs/reference.md +229 -0
- package/docs/use-cases.md +348 -0
- package/package.json +4 -1
- package/providers/detour/README.md +84 -0
- package/providers/detour/bridge.js +219 -0
- package/providers/detour/index.mjs +322 -0
- package/providers/detour/package-lock.json +577 -0
- package/providers/detour/package.json +19 -0
- package/providers/detour/scripts/build.mjs +31 -0
- package/providers/detour/src/bridge.js +256 -0
- package/providers/detour/src/contracts.js +40 -0
- package/providers/detour/src/inspector.js +260 -0
- package/providers/detour/src/inspector.test.mjs +53 -0
- package/providers/detour/src/panel.js +465 -0
- package/providers/detour/src/provider-core.js +233 -0
- package/providers/detour/src/provider-core.test.mjs +185 -0
- package/providers/detour/src/react-context-core.js +143 -0
- package/providers/detour/src/react-context.js +44 -0
- package/providers/detour/src/react-context.test.mjs +41 -0
- package/providers/templates/README.md +23 -0
- package/providers/templates/ci-review-provider.mjs +46 -0
- package/providers/templates/detour-workflow-provider.mjs +41 -0
- package/providers/templates/jira-github-provider.mjs +42 -0
- package/providers/templates/provider-utils.mjs +45 -0
- 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
|
+
```
|