@holokai/neuron-sdk 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,628 @@
1
+ # @holokai/neuron-sdk
2
+
3
+ SDK for building neurons — processes that connect to the BigBrain gateway and execute tasks on behalf of workflows.
4
+
5
+ A neuron advertises one or more **capabilities** (typed task handlers), opens a persistent connection to the gateway, and receives work via SSE. The gateway routes tasks to the right neuron based on capability type and scope. The built-in `apps/worker` uses this SDK; so will any external neuron (desktop app, on-prem agent, custom integration).
6
+
7
+ ---
8
+
9
+ ## Installation
10
+
11
+ ```sh
12
+ pnpm add @holokai/neuron-sdk
13
+ # or: npm install @holokai/neuron-sdk
14
+ # or: yarn add @holokai/neuron-sdk
15
+ ```
16
+
17
+ Runs in **Node 20+** (uses the global `fetch` and `ReadableStream`) and **modern browsers** (Chromium, Firefox, Safari latest). The package has no `node:*` imports — Holokai Desktop / Electron and the in-browser neuron-tester both consume the same SDK build.
18
+
19
+ ---
20
+
21
+ ## Quick start
22
+
23
+ ```ts
24
+ import { Neuron } from '@holokai/neuron-sdk';
25
+
26
+ const neuron = new Neuron({
27
+ gatewayUrl: 'https://api.example.com',
28
+ neuronId: 'my-worker-001', // stable per process/device
29
+ auth: () => process.env.GATEWAY_TOKEN!, // or an async refresh function
30
+ });
31
+
32
+ neuron.handle(
33
+ { type: 'core/script.python', scope: 'any', concurrency: 4 },
34
+ async (input, ctx) => {
35
+ ctx.log.info({}, 'running python script');
36
+ const result = await runPython(input as { code: string }, ctx.signal);
37
+ return { output: result };
38
+ },
39
+ );
40
+
41
+ await neuron.start();
42
+
43
+ process.on('SIGTERM', () => neuron.stop({ drain: true, timeoutMs: 30_000 }));
44
+ ```
45
+
46
+ ---
47
+
48
+ ## Core concepts
49
+
50
+ ### Capability
51
+
52
+ A capability is a named contract a neuron can fulfill. It has:
53
+
54
+ | Field | Type | Description |
55
+ |---|---|---|
56
+ | `type` | `string` | Namespaced identifier: `{owner}/{package}[/{name}][.{variant}]` |
57
+ | `scope` | `'any' \| { onBehalfOf: userId }` | Who this handler serves |
58
+ | `concurrency` | `number` | Max parallel tasks for this capability |
59
+
60
+ Examples:
61
+ - `core/script.python` — server-side Python execution, any user
62
+ - `desktop/mcp/drive.read` — user's Google Drive, scoped to that user
63
+ - `org/01HBQ8ZK/custom-tool` — org-specific capability
64
+
65
+ ### Namespace authorization
66
+
67
+ The gateway enforces which identities may advertise which namespaces:
68
+
69
+ | Namespace prefix | Permitted advertisers | Required scope |
70
+ |---|---|---|
71
+ | `core/*` | Server service identities | `any` |
72
+ | `desktop/*` | Attested desktop binaries | `{ onBehalfOf: userId }` |
73
+ | `org/{orgId}/*` | Tenant-installed extensions | `any` within org |
74
+ | `user/{userId}/*` | Personal extensions | `{ onBehalfOf: userId }` |
75
+
76
+ Registration is rejected (HTTP 403) if the JWT is not authorized for the namespace.
77
+
78
+ ### Scope
79
+
80
+ - **`'any'`** — the neuron handles tasks for any user. Shared queue; no per-user binding.
81
+ - **`{ onBehalfOf: userId }`** — the neuron handles tasks for that specific user only. The JWT's user binding must match; the gateway enforces this at registration time.
82
+
83
+ One neuron may advertise mixed-scope capabilities:
84
+
85
+ ```ts
86
+ neuron.handle({ type: 'desktop/mcp/drive.read', scope: { onBehalfOf: 'user:abc' }, concurrency: 1 }, driveHandler);
87
+ neuron.handle({ type: 'core/script.python', scope: 'any', concurrency: 4 }, pythonHandler);
88
+ ```
89
+
90
+ ### Lease
91
+
92
+ A lease is how the gateway delivers a task. When the broker dispatches a task to a neuron, the gateway:
93
+ 1. Writes a DB row with `leaseId` and expiry.
94
+ 2. Sends a `lease` frame over SSE.
95
+
96
+ The SDK validates input, invokes your handler, and sends either `ack` (success) or `nack` (failure) over HTTPS POST. Until the ack arrives, the message remains unacknowledged in the broker — if the neuron disconnects, the broker redelivers automatically.
97
+
98
+ **Handlers must be idempotent.** A task that completed a side effect but failed to ack will be retried. Use capability-native idempotency keys (e.g., Google Drive `requestId`, HTTP `Idempotency-Key`).
99
+
100
+ ### Neuron ID
101
+
102
+ `neuronId` must be **stable across restarts** for the same process/device. The gateway uses it to correlate reconnects. Pick something meaningful and persistent:
103
+
104
+ ```ts
105
+ // Server process
106
+ neuronId: `worker-${process.env.HOSTNAME}-${process.pid}`
107
+
108
+ // Desktop app — persist a UUID to disk
109
+ neuronId: await readOrCreateStoredId('/var/lib/myapp/neuron-id')
110
+ ```
111
+
112
+ ---
113
+
114
+ ## API reference
115
+
116
+ ### `new Neuron(opts: NeuronOptions)`
117
+
118
+ ```ts
119
+ interface NeuronOptions {
120
+ gatewayUrl: string; // Base URL, e.g. 'https://api.example.com'
121
+ neuronId: string; // Stable per process/device
122
+ auth: AuthCallback; // Token provider (see below)
123
+ logger?: NeuronLogger; // Plug in pino, console, etc. Default: silent
124
+ heartbeatIntervalMs?: number; // Default 10_000 (10s)
125
+ initialReconnectDelayMs?: number; // Default 500ms
126
+ maxReconnectDelayMs?: number; // Default 30_000 (30s)
127
+ fetch?: typeof fetch; // Override for tests
128
+ }
129
+ ```
130
+
131
+ ---
132
+
133
+ ### `neuron.handle(reg, handler)`
134
+
135
+ Register a handler for a capability. Must be called **before** `neuron.start()`.
136
+
137
+ ```ts
138
+ neuron.handle(reg: CapabilityRegistration, handler: Handler): void
139
+ ```
140
+
141
+ ```ts
142
+ interface CapabilityRegistration {
143
+ type: string; // Namespaced capability type
144
+ scope: 'any' | { onBehalfOf: string };
145
+ concurrency: number; // Max parallel executions
146
+ inputSchema?: JsonSchema; // Optional; validated before invoking handler
147
+ outputSchema?: JsonSchema; // Optional; validated before acking
148
+ }
149
+
150
+ type Handler<TInput = unknown, TOutput = unknown> = (
151
+ input: TInput,
152
+ ctx: HandlerContext,
153
+ ) => Promise<TOutput> | TOutput;
154
+ ```
155
+
156
+ If `inputSchema` is provided and the lease input fails validation, the handler is never called — the SDK sends a `schema-mismatch` nack automatically. Same for `outputSchema` on the return value.
157
+
158
+ ---
159
+
160
+ ### `neuron.start()`
161
+
162
+ Opens the SSE stream, posts `register` with all advertised capabilities, and starts the heartbeat loop.
163
+
164
+ ```ts
165
+ await neuron.start(): Promise<void>
166
+ ```
167
+
168
+ Throws if no handlers have been registered. Subsequent calls are no-ops.
169
+
170
+ ---
171
+
172
+ ### `neuron.stop(opts?)`
173
+
174
+ Graceful shutdown: refuse new leases, drain in-flight handlers, disconnect.
175
+
176
+ ```ts
177
+ await neuron.stop({ drain?: boolean, timeoutMs?: number }): Promise<void>
178
+ ```
179
+
180
+ | Option | Default | Description |
181
+ |---|---|---|
182
+ | `drain` | `true` | Wait for in-flight handlers to finish |
183
+ | `timeoutMs` | `30_000` | After this, abort remaining in-flight tasks |
184
+
185
+ Queued leases (received but not yet started due to concurrency limits) are nacked `retryable` on shutdown so the broker redelivers immediately.
186
+
187
+ ---
188
+
189
+ ### `neuron.updateCapability(reg)`
190
+
191
+ Change concurrency for an already-registered capability at runtime. Useful when available resources change (e.g., desktop plugged into power).
192
+
193
+ ```ts
194
+ await neuron.updateCapability({ type: string, concurrency: number }): Promise<void>
195
+ ```
196
+
197
+ Notifies the gateway via `update-capability` so it can tune AMQP prefetch. Safe to call while tasks are in flight — in-flight tasks finish at the old concurrency; new slots open at the new limit.
198
+
199
+ ---
200
+
201
+ ### `HandlerContext`
202
+
203
+ The second argument to every handler:
204
+
205
+ ```ts
206
+ interface HandlerContext {
207
+ task: TaskPayload; // Full task metadata
208
+ leaseId: string; // Gateway lease ID (for logging/debugging)
209
+ signal: AbortSignal; // Fires on gateway cancel or SDK shutdown
210
+ log: NeuronLogger; // Logger scoped to this lease
211
+ progress(update: { percent?: number; message?: string; data?: unknown }): void;
212
+ fail(reason: string, opts?: { code?: string }): never;
213
+ }
214
+ ```
215
+
216
+ #### `ctx.signal`
217
+
218
+ Pass this to any fetch/HTTP calls so they abort cleanly:
219
+
220
+ ```ts
221
+ const resp = await fetch(url, { signal: ctx.signal });
222
+ ```
223
+
224
+ When the gateway sends a `cancel` frame (workflow was cancelled by the user), `ctx.signal` fires and your handler should throw/return promptly. An `AbortError` from a cancelled signal is treated as `nack(kind='cancelled')` — benign, not counted as a failure.
225
+
226
+ #### `ctx.progress()`
227
+
228
+ Report progress for long-running tasks. Non-blocking; safe to call many times.
229
+
230
+ ```ts
231
+ ctx.progress({ percent: 50, message: 'halfway done' });
232
+ ctx.progress({ data: { rowsProcessed: 1200 } });
233
+ ```
234
+
235
+ #### `ctx.fail()`
236
+
237
+ Terminal failure — nacks the lease with `kind='terminal'` (no retry). Use when the task can never succeed regardless of retries.
238
+
239
+ ```ts
240
+ if (!validApiKey) {
241
+ ctx.fail('invalid API key', { code: 'INVALID_API_KEY' });
242
+ // unreachable — fail() throws internally
243
+ }
244
+ ```
245
+
246
+ ---
247
+
248
+ ### `AuthCallback`
249
+
250
+ ```ts
251
+ type AuthCallback = () => Promise<string> | string;
252
+ ```
253
+
254
+ Called on every POST and SSE open. The SDK retries once on 401, so implement token refresh inside the callback:
255
+
256
+ ```ts
257
+ let token = await fetchInitialToken();
258
+
259
+ const auth = async () => {
260
+ if (isExpired(token)) {
261
+ token = await refreshToken();
262
+ }
263
+ return token.accessToken;
264
+ };
265
+ ```
266
+
267
+ ---
268
+
269
+ ### `NeuronLogger`
270
+
271
+ ```ts
272
+ interface NeuronLogger {
273
+ debug(meta: Record<string, unknown>, msg?: string): void;
274
+ info(meta: Record<string, unknown>, msg?: string): void;
275
+ warn(meta: Record<string, unknown>, msg?: string): void;
276
+ error(meta: Record<string, unknown>, msg?: string): void;
277
+ }
278
+ ```
279
+
280
+ Matches pino's call signature directly. To use pino:
281
+
282
+ ```ts
283
+ import pino from 'pino';
284
+ const logger = pino({ level: 'info' });
285
+ const neuron = new Neuron({ ..., logger });
286
+ ```
287
+
288
+ ---
289
+
290
+ ### `defineCapability()`
291
+
292
+ A typed helper that produces a capability definition from zod schemas, with handler inputs/outputs fully inferred:
293
+
294
+ ```ts
295
+ import { defineCapability, Neuron } from '@holokai/neuron-sdk';
296
+ import { z } from 'zod';
297
+
298
+ const pythonCap = defineCapability({
299
+ type: 'core/script.python',
300
+ description: 'Run a Python script in a sandbox',
301
+ input: z.object({ code: z.string(), args: z.record(z.unknown()).optional() }),
302
+ output: z.object({ stdout: z.string(), exitCode: z.number() }),
303
+ scope: 'any', // optional default; overridable at handle() time
304
+ concurrency: 4, // optional default; overridable at handle() time
305
+ });
306
+
307
+ // Handler input/output inferred — no manual type annotations:
308
+ neuron.handle(pythonCap, async (input, ctx) => {
309
+ // input: { code: string; args?: Record<string, unknown> }
310
+ return { stdout: '...', exitCode: 0 };
311
+ });
312
+
313
+ // Or override defaults per deployment:
314
+ neuron.handle(pythonCap, { scope: { onBehalfOf: 'user-123' }, concurrency: 8 }, fn);
315
+ ```
316
+
317
+ Validation uses the **original zod schemas** (so `.refine()`, `.transform()`, `.preprocess()`, `.brand()` all run as expected). The JSON Schema produced by `zod-to-json-schema` is shipped on the wire to the gateway and planner but is not used as the runtime validator — that avoids silently dropping zod features that don't translate.
318
+
319
+ The fall-back `neuron.handle(registration, fn)` shape (raw JSON Schema + capability fields) is still accepted for low-level callers that don't have zod schemas on hand.
320
+
321
+ ---
322
+
323
+ ### Events
324
+
325
+ `Neuron` extends `EventEmitter` from [`eventemitter3`](https://github.com/primus/eventemitter3) — a browser-clean, type-safe drop-in for Node's `EventEmitter`. Subscribe with `.on()`, `.once()`, or `.off()`:
326
+
327
+ ```ts
328
+ const off = neuron.on('frame', (frame) => {
329
+ log.append(frame); // see every parsed SSE frame
330
+ });
331
+ off(); // unsubscribe
332
+
333
+ neuron.once('connection:registered', ({ sessionId }) => {
334
+ console.log(`registered as ${sessionId}`);
335
+ });
336
+
337
+ neuron.on('connection:reconnecting', ({ attempt, delayMs, reason }) => {
338
+ statusBar.show(`reconnecting (attempt ${attempt}, ${delayMs}ms)`, reason);
339
+ });
340
+ ```
341
+
342
+ The full event map:
343
+
344
+ | Event | Payload | When |
345
+ |---|---|---|
346
+ | `connection:open` | _none_ | SSE handshake succeeded; before `register` is posted |
347
+ | `connection:registered` | `{ sessionId: string; capabilities: number }` | Gateway accepted the register frame |
348
+ | `connection:close` | `reason: string` | SSE stream ended (clean shutdown or transient error) |
349
+ | `connection:reconnecting` | `{ attempt, delayMs, reason }` | Reconnect scheduled, before backoff delay |
350
+ | `error` | `err: Error` | Transport / parse error (reconnect handling is automatic) |
351
+ | `frame` | `frame: ServerToNeuronFrame` | Pass-through of every parsed inbound SSE frame |
352
+
353
+ The `frame` event is a firehose mirror of the gateway's `/neuron/events` SSE stream. It fires for **every** frame including ones the SDK handles internally (`lease`, `cancel`) — useful for debug UIs, observability, and audit logs. The high-level `neuron.handle()` API is implemented as a `frame` listener filtered to `lease` frames, so `handle()` and direct `frame` subscribers compose without conflict.
354
+
355
+ Subscribe **before** calling `neuron.start()` if you care about events that fire as part of the connection sequence (`connection:open`, the initial `registered` frame). Late subscribers will still see subsequent events.
356
+
357
+ The event surface is designed to grow — future entries (e.g. `'workflow:task-completed'` for cross-neuron notifications) slot in without breaking subscribers.
358
+
359
+ ---
360
+
361
+ ## Examples
362
+
363
+ ### Simple any-scoped capability
364
+
365
+ ```ts
366
+ import { Neuron } from '@holokai/neuron-sdk';
367
+
368
+ const neuron = new Neuron({
369
+ gatewayUrl: process.env.GATEWAY_URL!,
370
+ neuronId: `worker-${process.env.HOSTNAME}`,
371
+ auth: () => process.env.GATEWAY_TOKEN!,
372
+ });
373
+
374
+ neuron.handle(
375
+ { type: 'core/http', scope: 'any', concurrency: 8 },
376
+ async (input, ctx) => {
377
+ const { url, method = 'GET', headers = {}, body } = input as {
378
+ url: string;
379
+ method?: string;
380
+ headers?: Record<string, string>;
381
+ body?: string;
382
+ };
383
+
384
+ const resp = await fetch(url, { method, headers, body, signal: ctx.signal });
385
+ const text = await resp.text();
386
+
387
+ return { status: resp.status, body: text };
388
+ },
389
+ );
390
+
391
+ await neuron.start();
392
+ ```
393
+
394
+ ### User-scoped capability (desktop / MCP)
395
+
396
+ ```ts
397
+ const userId = await getUserIdFromToken(authToken);
398
+
399
+ neuron.handle(
400
+ { type: 'desktop/mcp/drive.read', scope: { onBehalfOf: userId }, concurrency: 1 },
401
+ async (input, ctx) => {
402
+ const { fileId } = input as { fileId: string };
403
+ ctx.progress({ message: 'fetching file metadata' });
404
+
405
+ const file = await driveClient.files.get({ fileId, signal: ctx.signal });
406
+ ctx.progress({ percent: 50, message: 'downloading content' });
407
+
408
+ const content = await driveClient.files.download({ fileId, signal: ctx.signal });
409
+ return { name: file.name, mimeType: file.mimeType, content };
410
+ },
411
+ );
412
+ ```
413
+
414
+ ### Long-running task with progress
415
+
416
+ ```ts
417
+ neuron.handle(
418
+ { type: 'core/data.transform', scope: 'any', concurrency: 2 },
419
+ async (input, ctx) => {
420
+ const { rows } = input as { rows: unknown[] };
421
+ const results: unknown[] = [];
422
+
423
+ for (let i = 0; i < rows.length; i++) {
424
+ if (ctx.signal.aborted) break; // respect cancellation
425
+
426
+ results.push(await processRow(rows[i]));
427
+ ctx.progress({ percent: Math.round(((i + 1) / rows.length) * 100) });
428
+ }
429
+
430
+ return { results };
431
+ },
432
+ );
433
+ ```
434
+
435
+ ### Error handling
436
+
437
+ ```ts
438
+ neuron.handle(
439
+ { type: 'core/llm', scope: 'any', concurrency: 4 },
440
+ async (input, ctx) => {
441
+ const { prompt, model } = input as { prompt: string; model: string };
442
+
443
+ let response;
444
+ try {
445
+ response = await llmClient.complete({ prompt, model, signal: ctx.signal });
446
+ } catch (err) {
447
+ if ((err as Error).name === 'AbortError') {
448
+ throw err; // let the SDK handle cancellation
449
+ }
450
+ if ((err as any).status === 400) {
451
+ // Bad request — no point retrying
452
+ ctx.fail(`invalid LLM request: ${(err as Error).message}`, { code: 'INVALID_REQUEST' });
453
+ }
454
+ // Anything else: throw to get a retryable nack
455
+ throw err;
456
+ }
457
+
458
+ return { text: response.text, tokensUsed: response.usage.total };
459
+ },
460
+ );
461
+ ```
462
+
463
+ ### Graceful shutdown
464
+
465
+ ```ts
466
+ await neuron.start();
467
+
468
+ const shutdown = async (signal: string) => {
469
+ console.log(`${signal} received — draining...`);
470
+ await neuron.stop({ drain: true, timeoutMs: 30_000 });
471
+ process.exit(0);
472
+ };
473
+
474
+ process.on('SIGTERM', () => shutdown('SIGTERM'));
475
+ process.on('SIGINT', () => shutdown('SIGINT'));
476
+ ```
477
+
478
+ ### Runtime concurrency update
479
+
480
+ ```ts
481
+ import { powerMonitor } from 'electron'; // or any power/resource API
482
+
483
+ powerMonitor.on('on-ac', async () => {
484
+ await neuron.updateCapability({ type: 'core/script.python', concurrency: 8 });
485
+ });
486
+
487
+ powerMonitor.on('on-battery', async () => {
488
+ await neuron.updateCapability({ type: 'core/script.python', concurrency: 1 });
489
+ });
490
+ ```
491
+
492
+ ---
493
+
494
+ ## Configuration reference
495
+
496
+ | Option | Default | Notes |
497
+ |---|---|---|
498
+ | `gatewayUrl` | — | Base URL, no trailing slash |
499
+ | `neuronId` | — | Stable per device/process; reused on reconnect |
500
+ | `auth` | — | Called on every POST + SSE open |
501
+ | `logger` | silent no-op | Plug in pino or console |
502
+ | `heartbeatIntervalMs` | `10_000` | Sent every 10s; renews lease expiries |
503
+ | `initialReconnectDelayMs` | `500` | First reconnect delay |
504
+ | `maxReconnectDelayMs` | `30_000` | Reconnect delay ceiling (exponential backoff) |
505
+
506
+ ---
507
+
508
+ ## Protocol details
509
+
510
+ This section covers enough to debug; the wire-level frame schemas are bundled into the SDK and exported alongside the public types.
511
+
512
+ ### Transport
513
+
514
+ - **Server → Neuron**: SSE stream at `GET /neuron/events?neuronId={id}`
515
+ - **Neuron → Server**: HTTPS POST endpoints (one per frame type)
516
+
517
+ No WebSockets — SSE + HTTPS POST traverses enterprise proxies cleanly.
518
+
519
+ ### Frame types
520
+
521
+ **Neuron → Server (POST):**
522
+
523
+ | Frame | Path | When |
524
+ |---|---|---|
525
+ | `register` | `/neuron/register` | On SSE open |
526
+ | `update-capability` | `/neuron/update-capability` | On concurrency change |
527
+ | `heartbeat` | `/neuron/heartbeat` | Every 10s while connected |
528
+ | `ack` | `/neuron/ack` | Handler succeeded |
529
+ | `nack` | `/neuron/nack` | Handler failed |
530
+ | `progress` | `/neuron/progress` | During handler execution |
531
+
532
+ **Server → Neuron (SSE):**
533
+
534
+ | Frame | When |
535
+ |---|---|
536
+ | `registered` | Gateway accepted the register frame; effective capabilities returned |
537
+ | `lease` | Task to execute |
538
+ | `cancel` | Gateway cancels a specific lease (workflow cancelled) |
539
+ | `capability-revoked` | A capability was deauthorized mid-session |
540
+ | `disconnect` | Gateway is closing the connection cleanly |
541
+
542
+ ### Heartbeat
543
+
544
+ Every ~10s the SDK posts:
545
+
546
+ ```json
547
+ {
548
+ "type": "heartbeat",
549
+ "neuronId": "worker-host-1234",
550
+ "inFlight": [
551
+ { "leaseId": "01HB...", "taskId": "task-abc", "startedAt": "2024-01-01T12:00:00Z" }
552
+ ],
553
+ "sentAt": "2024-01-01T12:00:10Z"
554
+ }
555
+ ```
556
+
557
+ The gateway renews lease expiries for every `leaseId` in `inFlight` in a single DB round-trip. **Only running leases appear in `inFlight`** — queued leases (admitted but waiting for a concurrency slot) are excluded so the reaper doesn't mistakenly extend their TTL.
558
+
559
+ ### Lease lifecycle
560
+
561
+ ```
562
+ Gateway Neuron SDK
563
+ │ │
564
+ │── lease (SSE) ──────────────────────────▶ │
565
+ │ │ validate input schema
566
+ │ │ wait for pool slot
567
+ │ │ invoke handler
568
+ │◀── heartbeats (POST every 10s) ────────── │
569
+ │ │ handler returns
570
+ │ │ validate output schema
571
+ │◀── ack (POST) ────────────────────────── │
572
+ │
573
+ │ [or on failure]
574
+ │◀── nack(kind='retryable') ────────────── │ broker redelivers
575
+ │◀── nack(kind='terminal') ─────────────── │ workflow fails
576
+ │◀── nack(kind='schema-mismatch') ──────── │ structured error surfaced
577
+ │◀── nack(kind='cancelled') ────────────── │ benign; signal fired
578
+ ```
579
+
580
+ ### Reconnection
581
+
582
+ The SDK uses exponential backoff (default: 500ms initial, doubling, 30s ceiling). On reconnect:
583
+ 1. SSE stream re-opens.
584
+ 2. `register` is posted with the full capability list.
585
+ 3. Heartbeat loop resumes.
586
+
587
+ In-flight leases from before the disconnect were never acked — the broker redelivers them. The reaper flips stale `assigned` DB rows back to `pending`. Your handlers will receive the same task again; make them idempotent.
588
+
589
+ ---
590
+
591
+ ## Troubleshooting
592
+
593
+ ### `Neuron.handle() must be called before start()`
594
+
595
+ Register all capabilities before calling `start()`. For runtime changes, use `updateCapability()`.
596
+
597
+ ### `Neuron.start() requires at least one handler`
598
+
599
+ You must call `neuron.handle(...)` at least once before `start()`.
600
+
601
+ ### Registration rejected with HTTP 403
602
+
603
+ Your JWT is not authorized for the namespace you're trying to advertise. Check:
604
+ - The capability type uses the correct namespace prefix (`core/*` requires a service identity, not a user JWT).
605
+ - For user-scoped capabilities, the `onBehalfOf` value matches the user in your JWT.
606
+
607
+ ### Tasks not being received
608
+
609
+ 1. Check the `registered` log line — it shows the **effective** capabilities after the gateway's policy/auth filters. If a capability is missing, the gateway filtered it.
610
+ 2. Check `neuronId` — if it changes across restarts, the gateway may have stale session state.
611
+ 3. Check heartbeat logs — if heartbeats are failing, the lease reaper may be reclaiming tasks faster than they're running.
612
+
613
+ ### Handler keeps getting re-delivered
614
+
615
+ The handler completed but the `ack` POST failed (gateway returned 4xx or 5xx). Look for `ack POST rejected by gateway` in logs. Root causes:
616
+ - Output failed the `outputSchema` check — fix your handler's return shape.
617
+ - Gateway was restarting — the next delivery will succeed once it's back.
618
+
619
+ ### Tasks stuck in `assigned` state
620
+
621
+ Heartbeats aren't reaching the gateway, so the reaper hasn't reclaimed them. Check:
622
+ - Is the SSE stream still connected? (`transport reconnecting` log entries)
623
+ - Are heartbeat POSTs failing? (`heartbeat post failed` log entries)
624
+ - Is `heartbeatIntervalMs` misconfigured to a very large value?
625
+
626
+ ### Auth 401 on every request
627
+
628
+ The `auth` callback isn't returning a valid token. If using a refresh flow, ensure the callback fetches a fresh token on each call (the SDK caches nothing — it's your callback's job).