@particle-academy/agent-integrations 0.6.0 → 0.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -158,7 +158,20 @@ For an end-to-end runnable, see the sandbox demo at `/whiteboard-agent` (added i
158
158
 
159
159
  ## External agent via relay
160
160
 
161
- The relay is host-implemented this package only defines the JSON envelope. See `docs/relay-protocol.md`.
161
+ External MCP agents (Claude Code, Cursor, Claude Desktop, custom clients)
162
+ reach a browser-hosted `MicroMcpServer` via a relay broker. The relay just
163
+ shuttles JSON-RPC frames — it doesn't run tools or hold state.
164
+
165
+ **Three ways to wire one up:**
166
+
167
+ - **`docs/agent-hookable-demos.md`** — full end-to-end workflow (browser →
168
+ relay → external agent), including a complete drop-in **Laravel 10+**
169
+ reference implementation (~200 LOC controller + routes + CSRF entry).
170
+ - **`docs/relay-server.md`** — the bundled Node relay (`agent-integrations-relay`
171
+ bin, `createNodeRelay()` factory, Dockerfile). Use this when your site is
172
+ not a Laravel/Rails/PHP app and you want a one-command deploy.
173
+ - **`docs/relay-protocol.md`** — the on-the-wire JSON envelope, with notes on
174
+ the three transports the protocol supports (Reverb, WebRTC, SSE+POST).
162
175
 
163
176
  Pattern (Reverb):
164
177
 
@@ -0,0 +1,402 @@
1
+ # Building agent-hookable demos on your site
2
+
3
+ End-to-end workflow for shipping a public-facing UI surface where visitors can
4
+ hand control to an MCP agent (Claude Code, Cursor, Claude Desktop, custom)
5
+ in real time. The piece you implement varies by site stack; the wire protocol
6
+ doesn't.
7
+
8
+ ## The architecture, plainly
9
+
10
+ ```
11
+ ┌────────────────────────┐ ┌──────────────────────┐
12
+ │ Visitor's browser tab │ │ External agent │
13
+ │ ────────────────── │ │ (Claude Code, etc.) │
14
+ │ React app │ └────────┬─────────────┘
15
+ │ MicroMcpServer │ │
16
+ │ (tools touch the │ │ HTTP POST + SSE
17
+ │ live UI surface) │ │
18
+ └──────────┬─────────────┘ │
19
+ │ │
20
+ │ SSE stream │
21
+ │ + HTTP POST │
22
+ ▼ ▼
23
+ ┌──────────────────────────────────────┐
24
+ │ Relay broker │
25
+ │ ────────────── │
26
+ │ - Holds session→token mapping │
27
+ │ - Fans frames between subscribers │
28
+ │ - In-memory queues + sliding TTL │
29
+ │ - No tool logic, no state │
30
+ └──────────────────────────────────────┘
31
+ ```
32
+
33
+ Three pieces. You write zero of them yourself if you follow this guide:
34
+
35
+ 1. **The browser-side MCP server.** Already shipped: `MicroMcpServer` + bridges +
36
+ `SseRelayTransport` from this package.
37
+
38
+ 2. **The relay broker.** *Pick one.* Either the bundled Node server (see
39
+ [relay-server.md](./relay-server.md)) or a same-stack reference
40
+ implementation (see § "Same-stack relays" below — currently includes a
41
+ complete Laravel controller).
42
+
43
+ 3. **The agent's client.** Out of your control — visitors paste your session
44
+ URL into whatever MCP client they already use.
45
+
46
+ ## End-user UX
47
+
48
+ This is what visitors actually experience. Every demo follows the same shape:
49
+
50
+ 1. Visitor opens `https://your-site.example/demos/some-surface`.
51
+ 2. Surface is interactive on its own — clicking around works, the in-page
52
+ `MicroMcpServer` is already running.
53
+ 3. Visitor clicks **Start share**.
54
+ 4. The page mints a per-session token, registers it with the relay, and shows
55
+ a copyable share URL.
56
+ 5. Visitor pastes the URL into their MCP client (`.mcp.json` for Claude Code,
57
+ Cursor's MCP settings, etc.). The client connects to the relay.
58
+ 6. Agent calls tools → tools mutate the host page's React state → visitor
59
+ watches the surface change in real time. Optional: agent cursor + tool-call
60
+ feed render alongside.
61
+ 7. **Stop share** tears the session down.
62
+
63
+ ## Browser-side wiring (any site)
64
+
65
+ ```tsx
66
+ import {
67
+ MicroMcpServer,
68
+ attachInProcess,
69
+ attachSseRelay,
70
+ createSessionDescriptor,
71
+ buildShareUrl,
72
+ textResult,
73
+ } from "@particle-academy/agent-integrations";
74
+
75
+ // Once per page mount:
76
+ const server = new MicroMcpServer({
77
+ info: { name: "your-demo", version: "0.1.0" },
78
+ });
79
+
80
+ server.registerTool(
81
+ { name: "your_tool", description: "...", inputSchema: { /* JSON Schema */ } },
82
+ async (args) => {
83
+ // Touch React state, return a CallToolResult
84
+ return textResult("ok");
85
+ },
86
+ );
87
+
88
+ attachInProcess(server); // lets in-page UI also call tools
89
+
90
+ // When the user clicks "Start share":
91
+ async function startShare(relayBaseUrl: string) {
92
+ const desc = createSessionDescriptor();
93
+ await fetch(`${relayBaseUrl}/register`, {
94
+ method: "POST",
95
+ headers: { "content-type": "application/json" },
96
+ body: JSON.stringify({ session: desc.id, token: desc.token }),
97
+ });
98
+ attachSseRelay(server, {
99
+ baseUrl: relayBaseUrl,
100
+ sessionId: desc.id,
101
+ token: desc.token,
102
+ });
103
+ const url = buildShareUrl(window.location.origin + relayBaseUrl, desc);
104
+ // Show `url` to the user in a copy-able UI
105
+ }
106
+ ```
107
+
108
+ The components that bundle this pattern out of the box:
109
+
110
+ - `<SharedWhiteboard>` (subpath `./components/shared-whiteboard`) — whiteboard
111
+ + share controls + agent panel + presence cursor in one drop-in.
112
+ - The pa-ux-sandbox repo has reference React components for composer, sheets,
113
+ flow, code-editor surfaces under `resources/js/react-demos/pages/*Agent*.tsx`.
114
+
115
+ ## Relay broker — pick one
116
+
117
+ ### Option A: bundled Node server (recommended for non-PHP hosts)
118
+
119
+ `@particle-academy/agent-integrations@^0.6.0` ships a complete Node HTTP
120
+ implementation, exposed three ways: standalone CLI, embeddable factory, and
121
+ Dockerfile. **Full docs:** [relay-server.md](./relay-server.md).
122
+
123
+ ```bash
124
+ npx -p @particle-academy/agent-integrations agent-integrations-relay --port 8787
125
+ ```
126
+
127
+ Point your demo's `relayBaseUrl` at this server's origin and you're done.
128
+
129
+ ### Option B: same-stack reference implementation
130
+
131
+ If your site already runs Laravel/Rails/Django/etc., implementing the relay
132
+ in the same stack avoids the operational cost of a separate Node process. The
133
+ wire protocol is small (~5 endpoints); a port is ~200 LOC.
134
+
135
+ A complete **Laravel 10+** reference follows. It's framework-agnostic in
136
+ intent — adapt the route registration and cache binding to your framework's
137
+ conventions; the logic is the same shape everywhere.
138
+
139
+ #### Laravel reference implementation
140
+
141
+ **1. Controller** — drop into `app/Http/Controllers/McpRelayController.php`:
142
+
143
+ ```php
144
+ <?php
145
+
146
+ namespace App\Http\Controllers;
147
+
148
+ use Illuminate\Http\JsonResponse;
149
+ use Illuminate\Http\Request;
150
+ use Illuminate\Support\Facades\Cache;
151
+ use Symfony\Component\HttpFoundation\StreamedResponse;
152
+
153
+ class McpRelayController extends Controller
154
+ {
155
+ private const TTL_SECONDS = 14400; // 4h — refreshed on every authenticated touch.
156
+ private const POLL_INTERVAL_MS = 200;
157
+
158
+ public function register(Request $request): JsonResponse
159
+ {
160
+ $data = $request->validate([
161
+ 'session' => ['required', 'string', 'regex:/^[A-Za-z0-9_-]{4,64}$/'],
162
+ 'token' => ['required', 'string', 'min:16', 'max:128'],
163
+ ]);
164
+ Cache::put($this->tokenKey($data['session']), hash('sha256', $data['token']), self::TTL_SECONDS);
165
+ return response()->json(['ok' => true]);
166
+ }
167
+
168
+ public function unregister(Request $request, string $session): JsonResponse
169
+ {
170
+ if (! $this->validateToken($session, (string) $request->query('token'))) {
171
+ return response()->json(['error' => 'invalid_token'], 401);
172
+ }
173
+ Cache::forget($this->tokenKey($session));
174
+ return response()->json(['ok' => true]);
175
+ }
176
+
177
+ public function inbox(Request $request, string $session): JsonResponse
178
+ {
179
+ if (! $this->validateToken($session, (string) $request->query('token'))) {
180
+ return response()->json(['error' => 'invalid_token'], 401);
181
+ }
182
+ $payload = $request->getContent();
183
+ if ($payload === '' || ! str_contains($payload, '"jsonrpc"')) {
184
+ return response()->json(['error' => 'invalid_frame'], 400);
185
+ }
186
+ $this->fanOut($session, 'inbound', $payload);
187
+ return response()->json(['ok' => true]);
188
+ }
189
+
190
+ public function outbox(Request $request, string $session): JsonResponse
191
+ {
192
+ if (! $this->validateToken($session, (string) $request->query('token'))) {
193
+ return response()->json(['error' => 'invalid_token'], 401);
194
+ }
195
+ $payload = $request->getContent();
196
+ if ($payload === '' || ! str_contains($payload, '"jsonrpc"')) {
197
+ return response()->json(['error' => 'invalid_frame'], 400);
198
+ }
199
+ $this->fanOut($session, 'outbound', $payload);
200
+ return response()->json(['ok' => true]);
201
+ }
202
+
203
+ public function events(Request $request, string $session): StreamedResponse
204
+ {
205
+ $token = (string) $request->query('token');
206
+ if (! $this->validateToken($session, $token)) {
207
+ return response()->stream(
208
+ fn () => print "event: error\ndata: invalid_token\n\n",
209
+ 401,
210
+ ['content-type' => 'text/event-stream'],
211
+ );
212
+ }
213
+ $direction = $request->query('direction', 'inbound') === 'outbound' ? 'outbound' : 'inbound';
214
+ $subscriberId = bin2hex(random_bytes(8));
215
+
216
+ return response()->stream(function () use ($session, $direction, $subscriberId) {
217
+ @set_time_limit(0);
218
+ @ini_set('output_buffering', 'off');
219
+ @ini_set('zlib.output_compression', '0');
220
+
221
+ $key = $this->queueKey($session, $direction, $subscriberId);
222
+ $subsKey = $this->subscribersKey($session, $direction);
223
+ $subs = Cache::get($subsKey, []);
224
+ $subs[$subscriberId] = time();
225
+ Cache::put($subsKey, $subs, self::TTL_SECONDS);
226
+
227
+ if ($direction === 'outbound') {
228
+ $this->fanOut($session, 'inbound', json_encode([
229
+ 'jsonrpc' => '2.0',
230
+ 'method' => 'notifications/peer_joined',
231
+ 'params' => ['subscriberId' => $subscriberId, 'ts' => time() * 1000],
232
+ ]));
233
+ }
234
+
235
+ echo "retry: 2000\n\n";
236
+ $this->flush();
237
+
238
+ $lastBeat = time();
239
+ while (! connection_aborted()) {
240
+ $frames = Cache::pull($key, []);
241
+ foreach ($frames as $frame) {
242
+ echo "event: mcp\ndata: {$frame}\n\n";
243
+ }
244
+ if (! empty($frames)) {
245
+ $this->flush();
246
+ }
247
+ if ((time() - $lastBeat) >= 15) {
248
+ echo ": keepalive\n\n";
249
+ $this->flush();
250
+ $lastBeat = time();
251
+ }
252
+ usleep(self::POLL_INTERVAL_MS * 1000);
253
+ }
254
+
255
+ $subs = Cache::get($subsKey, []);
256
+ unset($subs[$subscriberId]);
257
+ Cache::put($subsKey, $subs, self::TTL_SECONDS);
258
+ Cache::forget($key);
259
+
260
+ if ($direction === 'outbound') {
261
+ $this->fanOut($session, 'inbound', json_encode([
262
+ 'jsonrpc' => '2.0',
263
+ 'method' => 'notifications/peer_left',
264
+ 'params' => ['subscriberId' => $subscriberId, 'ts' => time() * 1000],
265
+ ]));
266
+ }
267
+ }, 200, [
268
+ 'content-type' => 'text/event-stream',
269
+ 'cache-control' => 'no-cache',
270
+ 'x-accel-buffering' => 'no',
271
+ ]);
272
+ }
273
+
274
+ private function fanOut(string $session, string $direction, string $payload): void
275
+ {
276
+ $subsKey = $this->subscribersKey($session, $direction);
277
+ $subs = Cache::get($subsKey, []);
278
+ foreach (array_keys($subs) as $subscriberId) {
279
+ $key = $this->queueKey($session, $direction, $subscriberId);
280
+ $existing = Cache::get($key, []);
281
+ $existing[] = $payload;
282
+ Cache::put($key, $existing, self::TTL_SECONDS);
283
+ }
284
+ Cache::put($subsKey, $subs, self::TTL_SECONDS);
285
+ }
286
+
287
+ private function validateToken(string $session, string $token): bool
288
+ {
289
+ if ($session === '' || $token === '') return false;
290
+ $key = $this->tokenKey($session);
291
+ $stored = Cache::get($key);
292
+ if ($stored === null) return false;
293
+ if (! hash_equals((string) $stored, hash('sha256', $token))) return false;
294
+ Cache::put($key, $stored, self::TTL_SECONDS);
295
+ return true;
296
+ }
297
+
298
+ private function tokenKey(string $session): string
299
+ {
300
+ return "mcp-relay:token:{$session}";
301
+ }
302
+
303
+ private function subscribersKey(string $session, string $direction): string
304
+ {
305
+ return "mcp-relay:subs:{$session}:{$direction}";
306
+ }
307
+
308
+ private function queueKey(string $session, string $direction, string $subscriberId): string
309
+ {
310
+ return "mcp-relay:queue:{$session}:{$direction}:{$subscriberId}";
311
+ }
312
+
313
+ private function flush(): void
314
+ {
315
+ if (function_exists('ob_get_level') && ob_get_level() > 0) {
316
+ @ob_flush();
317
+ }
318
+ @flush();
319
+ }
320
+ }
321
+ ```
322
+
323
+ **2. Routes** — `routes/web.php`:
324
+
325
+ ```php
326
+ Route::post('/mcp-relay/register', [McpRelayController::class, 'register']);
327
+ Route::post('/mcp-relay/{session}/unregister', [McpRelayController::class, 'unregister']);
328
+ Route::post('/mcp-relay/{session}/inbox', [McpRelayController::class, 'inbox']);
329
+ Route::post('/mcp-relay/{session}/outbox', [McpRelayController::class, 'outbox']);
330
+ Route::get ('/mcp-relay/{session}/events', [McpRelayController::class, 'events']);
331
+ ```
332
+
333
+ **3. CSRF exemption** — `bootstrap/app.php` (Laravel 11+):
334
+
335
+ ```php
336
+ ->withMiddleware(function (Middleware $middleware) {
337
+ $middleware->validateCsrfTokens(except: [
338
+ 'mcp-relay/*',
339
+ ]);
340
+ })
341
+ ```
342
+
343
+ (Laravel 10: add to `VerifyCsrfToken::$except` in `app/Http/Middleware/`.)
344
+
345
+ **4. Operational notes**
346
+
347
+ - Storage uses the default cache driver — `file` works for single-server
348
+ setups, but for any production deploy use `redis` so the broker survives
349
+ PHP-FPM worker recycles and load-balances correctly across processes.
350
+ - The SSE `events()` action holds a long-lived HTTP connection. Confirm your
351
+ proxy / PHP-FPM timeouts allow this:
352
+ - Nginx: `proxy_read_timeout` ≥ 6h, `proxy_buffering off` for SSE
353
+ - PHP-FPM: `request_terminate_timeout = 0` or `>= 4h`
354
+ - The 15-second keepalive ping (`: keepalive\n\n`) prevents most proxies from
355
+ killing idle connections. Validate after deploy.
356
+ - This implementation handles ~50 concurrent sessions on a single Laravel
357
+ worker. For more, run multiple workers behind a load balancer with a
358
+ redis cache backend.
359
+
360
+ ### Option C: implement in another stack
361
+
362
+ Same protocol, same five endpoints, same wire format. Reference the Laravel
363
+ controller above and the [relay-protocol.md](./relay-protocol.md) wire spec.
364
+ For Rails: `ActionController::Live` for SSE + `Rails.cache` for storage. For
365
+ Django: `StreamingHttpResponse` + `django.core.cache`. For Express/Fastify:
366
+ just use the bundled `createNodeRelay()` factory from this package.
367
+
368
+ ## Choosing between A and B
369
+
370
+ | You want… | Pick |
371
+ |---|---|
372
+ | Add a couple of demos to an existing Laravel app | **B (Laravel)** — no new infrastructure |
373
+ | Demo site is static / no backend yet | **A (Node)** — `agent-integrations-relay` container |
374
+ | Multiple Fancy UI demos across multiple sites | **A**, deployed once at a central origin |
375
+ | Already running Rails/Django/etc. | **C** — port the Laravel reference |
376
+ | Edge / serverless | **A** with `createNodeRelay` adapted to your runtime, or bring your own `RelayBroker` + `Store` |
377
+
378
+ The browser-side code doesn't change between options. The relay broker URL is
379
+ the only difference.
380
+
381
+ ## Worked example — the particle.academy site
382
+
383
+ The marketing site for this kit hosts two agent-hookable demos at
384
+ `/ui/demos/composer` and `/ui/demos/agent-presence`. Stack: Laravel 12 +
385
+ Livewire + Tailwind v4, plus a React island for the demo surfaces. The relay
386
+ runs in-app via Option B (the Laravel reference above lives at
387
+ `app/Http/Controllers/McpRelayController.php` in the
388
+ [Particle-Academy/website](https://github.com/Particle-Academy/website) repo).
389
+
390
+ The pattern there matches this doc exactly: a controlled React component
391
+ mounts via `data-fancy-demo` placeholders; `MicroMcpServer` registers
392
+ composer/whiteboard tools that touch local state; clicking *Start share*
393
+ hits the relay's `/register` and opens an SSE subscription. Visitors paste
394
+ the resulting URL into their MCP client and the demo becomes agent-driven.
395
+
396
+ ## See also
397
+
398
+ - [relay-protocol.md](./relay-protocol.md) — the wire format, including the
399
+ three transports the protocol supports (Reverb, WebRTC, SSE+POST)
400
+ - [relay-server.md](./relay-server.md) — the bundled Node relay
401
+ - [`SharedWhiteboard`](../src/components/SharedWhiteboard/SharedWhiteboard.tsx) —
402
+ source for a fully-composed agent-hookable surface, useful as a template
@@ -75,12 +75,232 @@ docker build -t agent-integrations-relay .
75
75
  docker run -p 8787:8787 agent-integrations-relay
76
76
  ```
77
77
 
78
- Deploy targets that just want a container:
78
+ ## Deployment recipes
79
+
80
+ The relay is a tiny stateless Node HTTP server. Any platform that can host a
81
+ long-running Node process works. Pick whichever matches the rest of your
82
+ infrastructure — verification steps are at the bottom of each recipe.
83
+
84
+ ### Laravel Forge (Node site or daemon)
85
+
86
+ Forge supports both Node sites and standalone daemons, either fits.
87
+
88
+ **Option A — Forge "Static" site running Node:**
89
+
90
+ 1. In Forge, create a new site on your server. Set **Project Type** to
91
+ *Static / Node*. Web directory: `/public` (unused — we'll serve from the
92
+ relay port).
93
+ 2. Add a domain (e.g. `relay.particle.academy`) and an LE SSL cert.
94
+ 3. Connect the site to a deploy repo — point it at this package's git URL or
95
+ a thin wrapper repo containing just:
96
+ ```
97
+ .
98
+ ├── package.json (just "scripts": { "start": "agent-integrations-relay --port 8787" }
99
+ │ and "dependencies": { "@particle-academy/agent-integrations": "^0.6.1" })
100
+ └── README.md
101
+ ```
102
+ 4. Deploy script:
103
+ ```bash
104
+ cd $FORGE_SITE_PATH
105
+ npm install --omit=dev
106
+ ```
107
+ 5. In **Daemons** (sidebar), add:
108
+ - **Command:** `npx agent-integrations-relay --port 8787 --cors https://your-site.example`
109
+ - **Directory:** `$FORGE_SITE_PATH`
110
+ - **User:** `forge`
111
+ Daemon auto-restarts on crash.
112
+ 6. In the site's **Nginx config**, replace the upstream block with:
113
+ ```nginx
114
+ location / {
115
+ proxy_pass http://127.0.0.1:8787;
116
+ proxy_http_version 1.1;
117
+ proxy_set_header Host $host;
118
+ proxy_set_header X-Real-IP $remote_addr;
119
+
120
+ # SSE needs these — otherwise the stream is buffered and never reaches the agent.
121
+ proxy_buffering off;
122
+ proxy_cache off;
123
+ proxy_read_timeout 6h;
124
+ proxy_send_timeout 6h;
125
+ chunked_transfer_encoding on;
126
+ }
127
+ ```
128
+ 7. Restart Nginx via the Forge UI button or `sudo nginx -s reload`.
129
+
130
+ **Option B — daemon alongside an existing Laravel app on the same server:**
131
+
132
+ If you'd rather not give it its own subdomain, run it as a Forge daemon on
133
+ an internal port and proxy from an existing site's Nginx config:
134
+
135
+ ```nginx
136
+ # Inside an existing Forge Laravel site
137
+ location /mcp-relay/ {
138
+ proxy_pass http://127.0.0.1:8787/;
139
+ proxy_http_version 1.1;
140
+ proxy_buffering off;
141
+ proxy_read_timeout 6h;
142
+ chunked_transfer_encoding on;
143
+ }
144
+ ```
145
+
146
+ **Verify:**
147
+
148
+ ```bash
149
+ curl https://relay.particle.academy/ # → {"ok":true,"service":"…"}
150
+ curl -X POST -H 'content-type: application/json' \
151
+ -d '{"session":"smoke-001","token":"abcdef0123456789abcdef0123456789"}' \
152
+ https://relay.particle.academy/register # → {"ok":true}
153
+ ```
154
+
155
+ ### Fly.io
156
+
157
+ ```bash
158
+ git clone https://github.com/Particle-Academy/agent-integrations
159
+ cd agent-integrations
160
+ npm install && npm run build
161
+ docker build -t agent-integrations-relay .
162
+
163
+ # Init + deploy (first time only):
164
+ fly launch \
165
+ --name relay-particle-academy \
166
+ --no-deploy \
167
+ --copy-config \
168
+ --image agent-integrations-relay \
169
+ --internal-port 8787 \
170
+ --region iad
171
+ fly deploy
172
+ ```
173
+
174
+ Public URL prints at the end, e.g. `https://relay-particle-academy.fly.dev`.
175
+
176
+ ### Railway
177
+
178
+ ```bash
179
+ # Commit the Dockerfile to your relay repo, then:
180
+ railway login
181
+ railway init
182
+ railway up
183
+ ```
184
+
185
+ In the Railway dashboard, enable a public domain on the service; copy the
186
+ generated `*.up.railway.app` URL.
187
+
188
+ ### Render
189
+
190
+ 1. New → **Web Service**
191
+ 2. Connect a git repo containing the Dockerfile
192
+ 3. Runtime: **Docker**
193
+ 4. Port: `8787`
194
+ 5. Add `Header: Cache-Control: no-cache` on the service so Render's CDN
195
+ doesn't buffer SSE
196
+
197
+ ### Google Cloud Run
198
+
199
+ ```bash
200
+ gcloud builds submit --tag gcr.io/$PROJECT/agent-integrations-relay
201
+ gcloud run deploy agent-integrations-relay \
202
+ --image gcr.io/$PROJECT/agent-integrations-relay \
203
+ --port 8787 \
204
+ --allow-unauthenticated \
205
+ --min-instances 1 \
206
+ --timeout 3600
207
+ ```
208
+
209
+ Cloud Run's default request timeout is 60s — bump it via `--timeout 3600`
210
+ (max 3600s on managed Cloud Run) so SSE streams aren't cut off. For longer
211
+ sessions, use **Cloud Run for Anthos / GKE** or a Compute Engine VM.
212
+
213
+ ### Bare server (systemd)
214
+
215
+ If the relay is going on a VM you already own, `systemd`:
216
+
217
+ ```ini
218
+ # /etc/systemd/system/mcp-relay.service
219
+ [Unit]
220
+ Description=MCP relay broker
221
+ After=network.target
222
+
223
+ [Service]
224
+ Type=simple
225
+ User=relay
226
+ WorkingDirectory=/opt/relay
227
+ ExecStart=/usr/bin/npx agent-integrations-relay --port 8787 --cors https://your-site.example
228
+ Restart=on-failure
229
+ RestartSec=5
230
+ Environment=NODE_ENV=production
231
+
232
+ [Install]
233
+ WantedBy=multi-user.target
234
+ ```
235
+
236
+ ```bash
237
+ sudo systemctl daemon-reload
238
+ sudo systemctl enable --now mcp-relay
239
+ sudo systemctl status mcp-relay
240
+ ```
241
+
242
+ Front with Nginx using the same SSE-friendly proxy block as the Forge
243
+ recipe.
244
+
245
+ ## Smoke testing any deploy
246
+
247
+ After you have a public URL, regardless of host:
248
+
249
+ ```bash
250
+ RELAY=https://relay.example.com
251
+
252
+ # 1. Health
253
+ curl $RELAY/
254
+
255
+ # 2. Register a session
256
+ curl -X POST -H 'content-type: application/json' \
257
+ -d '{"session":"smoke-001","token":"abcdef0123456789abcdef0123456789"}' \
258
+ $RELAY/register
259
+
260
+ # 3. POST a frame
261
+ curl -X POST -H 'content-type: application/json' \
262
+ -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' \
263
+ "$RELAY/smoke-001/inbox?token=abcdef0123456789abcdef0123456789"
264
+
265
+ # 4. SSE stream — should hang open + emit keepalive comments every 15s
266
+ curl -N "$RELAY/smoke-001/events?token=abcdef0123456789abcdef0123456789&direction=inbound"
267
+ ```
268
+
269
+ If `curl -N` returns immediately, your proxy is buffering. Re-check
270
+ `proxy_buffering off` (Nginx) or the equivalent on your edge.
271
+
272
+ ## Hooking into your demo site
273
+
274
+ Set the relay base URL in your demo's environment. For a Laravel host (like
275
+ particle.academy):
276
+
277
+ ```env
278
+ # .env on the demo site
279
+ MCP_RELAY_BASE_URL=https://relay.particle.academy
280
+ ```
281
+
282
+ Bind it to a config and read it from your Livewire/Blade layer:
283
+
284
+ ```php
285
+ // config/mcp.php
286
+ return [
287
+ 'relay_base_url' => env('MCP_RELAY_BASE_URL', ''),
288
+ ];
289
+ ```
290
+
291
+ Then pass it to the React mount placeholder:
292
+
293
+ ```blade
294
+ <div
295
+ data-fancy-demo="composer"
296
+ data-relay-base="{{ config('mcp.relay_base_url') }}"
297
+ ></div>
298
+ ```
79
299
 
80
- - **Fly.io:** `fly launch --image agent-integrations-relay --internal-port 8787`
81
- - **Railway:** `railway up` after committing the Dockerfile
82
- - **Render:** point a Web Service at the Dockerfile, expose 8787
83
- - **Cloud Run:** `gcloud run deploy --image agent-integrations-relay --port 8787 --allow-unauthenticated`
300
+ The React side reads `node.dataset.relayBase`, passes it to the demo
301
+ component, and the component uses it for `attachSseRelay({ baseUrl: ... })`.
302
+ See [agent-hookable-demos.md](./agent-hookable-demos.md) for the
303
+ end-to-end pattern.
84
304
 
85
305
  ## Wire protocol
86
306
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@particle-academy/agent-integrations",
3
- "version": "0.6.0",
3
+ "version": "0.6.2",
4
4
  "description": "MCP-driven agent presence in collab sessions: per-session micro-MCP server, pluggable bridges to fancy-* packages, and agent UX components (panel + on-canvas cursor).",
5
5
  "repository": {
6
6
  "type": "git",