@flue/sdk 0.3.4 → 0.3.6

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
@@ -4,11 +4,13 @@
4
4
 
5
5
  # Flue
6
6
 
7
- Flue is **The Sandbox Agent Framework.** If you know how to use Claude Code (or OpenCode, Codex, Gemini, etc)... then you already know the basics of how to build agents with Flue.
7
+ Flue is **The Agent Harness Framework.** If you know how to use Claude Code (or OpenCode, Codex, Gemini, etc)... then you already know the basics of how to build agents with Flue.
8
8
 
9
- A [Sandbox Agent](https://developers.openai.com/api/docs/guides/agents/sandboxes) pairs an **agent harness** (like Claude Code) with a secure, isolated container workspace. Sandbox Agents can edit files, write and execute code, spin up subagents, run terminal commands, and drive themselves autonomously to solve any given task. This pattern unlocks more powerful, intelligent agents that traditional AI frameworks wouldn't otherwise let you build.
9
+ Flue is a TypeScript framework for building the next generation of agents, designed around a built-in **agent harness**. It's like Claude Code, but 100% headless and programmable. There's no baked-in assumption like requiring a human operator to function. No TUI. No GUI. Just TypeScript.
10
10
 
11
- Our take is that 1) any agent can be represented as a Sandbox Agent, and 2) any agent is _best_ represented as a Sandbox Agent. So we designed Flue to deliver on this vision.
11
+ But using Flue feels like using Claude Code. The agents you build act autonomously to solve problems and complete tasks. They require very little code to run most of the "logic" lives in Markdown: skills, context, and `AGENTS.md`.
12
+
13
+ Flue isn't another AI SDK. It's a proper runtime-agnostic framework — think Astro or Next.js, but for agents. Write once, build, and deploy your agents anywhere (Node.js, Cloudflare, GitHub Actions, GitLab CI/CD, etc).
12
14
 
13
15
  ## Packages
14
16
 
@@ -287,7 +287,7 @@ function createBashTool(env) {
287
287
  }),
288
288
  async execute(_toolCallId, params, signal) {
289
289
  throwIfAborted(signal);
290
- return formatBashResult(await env.exec(params.command), params.command);
290
+ return formatBashResult(await env.exec(params.command, { timeout: params.timeout }), params.command);
291
291
  }
292
292
  };
293
293
  }
package/dist/client.d.mts CHANGED
@@ -1,5 +1,5 @@
1
- import { A as TaskOptions, C as SessionEnv, D as ShellResult, E as ShellOptions, M as ToolParameters, S as SessionData, T as SessionStore, _ as FlueSessions, a as BashLike, d as FileStat, f as FlueAgent, g as FlueSession, h as FlueEventCallback, i as BashFactory, j as ToolDef, k as SkillOptions, l as Command, m as FlueEvent, p as FlueContext, r as AgentInit, t as AgentConfig, v as PromptOptions, w as SessionOptions, x as SandboxFactory, y as PromptResponse } from "./types-T8pE1xIS.mjs";
2
- import { i as connectMcpServer, n as McpServerOptions, r as McpTransport, t as McpServerConnection } from "./mcp-BVF-sOBZ.mjs";
1
+ import { A as TaskOptions, C as SessionEnv, D as ShellResult, E as ShellOptions, M as ToolParameters, S as SessionData, T as SessionStore, _ as FlueSessions, a as BashLike, d as FileStat, f as FlueAgent, g as FlueSession, h as FlueEventCallback, i as BashFactory, j as ToolDef, k as SkillOptions, l as Command, m as FlueEvent, p as FlueContext, r as AgentInit, t as AgentConfig, v as PromptOptions, w as SessionOptions, x as SandboxFactory, y as PromptResponse } from "./types-CItTrBsU.mjs";
2
+ import { i as connectMcpServer, n as McpServerOptions, r as McpTransport, t as McpServerConnection } from "./mcp-EZy-Vb5M.mjs";
3
3
  import { Type } from "@mariozechner/pi-ai";
4
4
 
5
5
  //#region src/client.d.ts
package/dist/client.mjs CHANGED
@@ -1,7 +1,7 @@
1
- import { r as discoverSessionContext } from "./agent-BB4lwAd5.mjs";
2
- import { a as assertRoleExists } from "./session-DukL3zwF.mjs";
1
+ import { r as discoverSessionContext } from "./agent-BTB0809P.mjs";
2
+ import { a as assertRoleExists } from "./session-BaaSQTWS.mjs";
3
3
  import { bashFactoryToSessionEnv, createCwdSessionEnv } from "./sandbox.mjs";
4
- import { n as AgentClient, t as connectMcpServer } from "./mcp-DOgMtp8y.mjs";
4
+ import { n as AgentClient, t as connectMcpServer } from "./mcp-DTFRe9vh.mjs";
5
5
  import { Type } from "@mariozechner/pi-ai";
6
6
 
7
7
  //#region src/client.ts
@@ -1,5 +1,5 @@
1
- import { C as SessionEnv, T as SessionStore, l as Command } from "../types-T8pE1xIS.mjs";
2
- import { t as CommandExecutor } from "../command-helpers-DdAfbnom.mjs";
1
+ import { C as SessionEnv, T as SessionStore, l as Command } from "../types-CItTrBsU.mjs";
2
+ import { t as CommandExecutor } from "../command-helpers-CXzopT_-.mjs";
3
3
 
4
4
  //#region src/cloudflare/virtual-sandbox.d.ts
5
5
  interface VirtualSandboxOptions {
@@ -1,5 +1,5 @@
1
- import "../agent-BB4lwAd5.mjs";
2
- import "../session-DukL3zwF.mjs";
1
+ import "../agent-BTB0809P.mjs";
2
+ import "../session-BaaSQTWS.mjs";
3
3
  import { createSandboxSessionEnv } from "../sandbox.mjs";
4
4
  import { t as normalizeExecutor } from "../command-helpers-hTZKWK13.mjs";
5
5
  import { Workspace, WorkspaceFileSystem } from "@cloudflare/shell";
@@ -195,9 +195,11 @@ async function cfSandboxToSessionEnv(sandbox, cwd = "/workspace") {
195
195
  } else await sandbox.deleteFile(path);
196
196
  },
197
197
  async exec(command, execOpts) {
198
+ const timeoutMs = typeof execOpts?.timeout === "number" ? execOpts.timeout * 1e3 : void 0;
198
199
  const result = await sandbox.exec(command, {
199
200
  cwd: execOpts?.cwd,
200
- env: execOpts?.env
201
+ env: execOpts?.env,
202
+ timeout: timeoutMs
201
203
  });
202
204
  return {
203
205
  stdout: result.stdout ?? "",
@@ -1,4 +1,4 @@
1
- import { D as ShellResult } from "./types-T8pE1xIS.mjs";
1
+ import { D as ShellResult } from "./types-CItTrBsU.mjs";
2
2
 
3
3
  //#region src/command-helpers.d.ts
4
4
  /**
package/dist/index.d.mts CHANGED
@@ -1,4 +1,4 @@
1
- import { A as TaskOptions, C as SessionEnv, D as ShellResult, E as ShellOptions, M as ToolParameters, O as Skill, S as SessionData, T as SessionStore, _ as FlueSessions, a as BashLike, b as Role, c as BuildPlugin, d as FileStat, f as FlueAgent, g as FlueSession, h as FlueEventCallback, i as BashFactory, j as ToolDef, k as SkillOptions, l as Command, m as FlueEvent, n as AgentInfo, o as BuildContext, p as FlueContext, r as AgentInit, s as BuildOptions, t as AgentConfig, u as CommandDef, v as PromptOptions, w as SessionOptions, x as SandboxFactory, y as PromptResponse } from "./types-T8pE1xIS.mjs";
1
+ import { A as TaskOptions, C as SessionEnv, D as ShellResult, E as ShellOptions, M as ToolParameters, O as Skill, S as SessionData, T as SessionStore, _ as FlueSessions, a as BashLike, b as Role, c as BuildPlugin, d as FileStat, f as FlueAgent, g as FlueSession, h as FlueEventCallback, i as BashFactory, j as ToolDef, k as SkillOptions, l as Command, m as FlueEvent, n as AgentInfo, o as BuildContext, p as FlueContext, r as AgentInit, s as BuildOptions, t as AgentConfig, u as CommandDef, v as PromptOptions, w as SessionOptions, x as SandboxFactory, y as PromptResponse } from "./types-CItTrBsU.mjs";
2
2
  import { AgentTool, AgentToolResult } from "@mariozechner/pi-agent-core";
3
3
 
4
4
  //#region src/build.d.ts
package/dist/index.mjs CHANGED
@@ -1,9 +1,10 @@
1
- import { a as parseFrontmatterFile, n as createTools, t as BUILTIN_TOOL_NAMES } from "./agent-BB4lwAd5.mjs";
1
+ import { a as parseFrontmatterFile, n as createTools, t as BUILTIN_TOOL_NAMES } from "./agent-BTB0809P.mjs";
2
2
  import * as esbuild from "esbuild";
3
3
  import * as fs from "node:fs";
4
4
  import * as path from "node:path";
5
5
  import { packageUpSync } from "package-up";
6
6
  import { spawn } from "node:child_process";
7
+ import { randomUUID } from "node:crypto";
7
8
  import { parseEnv } from "node:util";
8
9
 
9
10
  //#region src/cloudflare-wrangler-merge.ts
@@ -267,11 +268,11 @@ function stripNoisyWranglerDefaults(merged) {
267
268
  }
268
269
  /**
269
270
  * Return the list of `class_name`s declared in the user's wrangler
270
- * `durable_objects.bindings` that contain the literal substring `Sandbox`
271
+ * `durable_objects.bindings` that end with the literal suffix `Sandbox`
271
272
  * (case-sensitive).
272
273
  *
273
274
  * This is Flue's convention for wiring `@cloudflare/sandbox`: any DO binding
274
- * whose class name contains `Sandbox` triggers an automatic re-export in the
275
+ * whose class name ends with `Sandbox` triggers an automatic re-export in the
275
276
  * generated Worker entry:
276
277
  *
277
278
  * export { Sandbox as <class_name> } from '@cloudflare/sandbox';
@@ -281,6 +282,13 @@ function stripNoisyWranglerDefaults(merged) {
281
282
  * `@cloudflare/sandbox` package. Each distinct `class_name` can be paired with
282
283
  * a different container image in the user's `containers[]` config.
283
284
  *
285
+ * The match is intentionally a suffix (not substring) so that user-defined
286
+ * classes whose names merely contain "Sandbox" mid-word — e.g. `MySandboxV2`,
287
+ * `MySandboxedAgent`, `LegacySandboxedThing` — are not silently overridden
288
+ * by the `@cloudflare/sandbox` re-export. Note that classes whose names
289
+ * still end in `Sandbox` (e.g. `MockSandbox`, `NotASandbox`) will match;
290
+ * to opt out, rename the class to not end in `Sandbox`.
291
+ *
284
292
  * Returns unique, sorted class names. Non-object bindings or bindings without
285
293
  * a string `class_name` are ignored.
286
294
  */
@@ -294,7 +302,7 @@ function detectSandboxBindings(userConfig) {
294
302
  if (typeof entry !== "object" || entry === null) continue;
295
303
  const className = entry.class_name;
296
304
  if (typeof className !== "string") continue;
297
- if (className.includes("Sandbox")) found.add(className);
305
+ if (className.endsWith("Sandbox")) found.add(className);
298
306
  }
299
307
  return Array.from(found).sort();
300
308
  }
@@ -328,7 +336,7 @@ function assertSandboxPackageInstalled(sandboxClassNames, searchDirs) {
328
336
  current = path.dirname(current);
329
337
  }
330
338
  }
331
- throw new Error(`[flue] Your wrangler config declares DO binding(s) whose class_name contains "Sandbox" (${sandboxClassNames.join(", ")}), but @cloudflare/sandbox is not in your package.json. Install it: \`npm install @cloudflare/sandbox\`.`);
339
+ throw new Error(`[flue] Your wrangler config declares DO binding(s) whose class_name ends with "Sandbox" (${sandboxClassNames.join(", ")}), but @cloudflare/sandbox is not in your package.json. Install it: \`npm install @cloudflare/sandbox\`.`);
332
340
  }
333
341
  /**
334
342
  * Write the wrangler deploy-redirect file at `<outputDir>/.wrangler/deploy/config.json`
@@ -396,16 +404,23 @@ var CloudflarePlugin = class {
396
404
  }`;
397
405
  }).join("\n\n");
398
406
  const { config: userConfig } = await this.getUserConfig(ctx.outputDir);
407
+ const sandboxReExports = detectSandboxBindings(userConfig).map((name) => `export { Sandbox as ${name} } from '@cloudflare/sandbox';`).join("\n");
399
408
  return `
400
409
  // Auto-generated by @flue/sdk build (cloudflare)
401
410
  import { Agent, routeAgentRequest } from 'agents';
402
- import { DurableObject } from 'cloudflare:workers';
403
411
  import { Bash, InMemoryFs } from 'just-bash';
404
412
  import {
405
413
  createFlueContext,
406
414
  InMemorySessionStore,
407
415
  bashFactoryToSessionEnv,
408
416
  resolveModel,
417
+ parseJsonBody,
418
+ toHttpResponse,
419
+ toSseData,
420
+ AgentNotFoundError,
421
+ MethodNotAllowedError,
422
+ RouteNotFoundError,
423
+ InvalidRequestError,
409
424
  } from '@flue/sdk/internal';
410
425
  import { runWithCloudflareContext, cfSandboxToSessionEnv } from '@flue/sdk/cloudflare';
411
426
 
@@ -418,6 +433,12 @@ const skills = {};
418
433
  const systemPrompt = '';
419
434
  const manifest = ${manifest};
420
435
 
436
+ // Set of webhook-accessible agent names (raw form, as used in URL segments).
437
+ // Used by the worker fetch handler to pre-route requests and reject unknown
438
+ // agents with a JSON 404 envelope before the request hits the partyserver
439
+ // dispatcher (which would otherwise return text/plain "Invalid request").
440
+ const webhookAgentNames = new Set(${JSON.stringify(webhookAgents.map((a) => a.name))});
441
+
421
442
  // ─── Infrastructure ─────────────────────────────────────────────────────────
422
443
 
423
444
  // No build-time model default. The user sets model at runtime via
@@ -456,24 +477,34 @@ async function createLocalEnv() {
456
477
  * RPC stub, null otherwise.
457
478
  *
458
479
  * NOTE on detection: The value returned by \`getSandbox()\` is a workerd RPC
459
- * Proxy. \`in\` and \`typeof\` against it return \`true\`/\`'function'\` for any
460
- * property name, so structural duck-typing is unreliable.
480
+ * Proxy. None of the obvious detection strategies work:
461
481
  *
462
- * \`instanceof <UserSandboxClass>\` ALSO does not work: the RPC stub's
463
- * prototype chain is workerd's internal \`DurableObject\` runtime class, not
464
- * the user-defined \`Sandbox\` class (the user's class only exists on the
465
- * in-DO side; the caller side gets a generic stub). Empirically:
466
- * typeof stub === 'object'
467
- * Object.getPrototypeOf(stub).constructor.name === 'DurableObject'
482
+ * - Structural duck-typing (\`'X' in stub\`, \`typeof stub.X === 'function'\`):
483
+ * the proxy lies positively for any property name, so any check returns
484
+ * \`true\` regardless of what's actually on the remote.
485
+ * - \`instanceof <UserSandboxClass>\` (e.g. \`Sandbox\` from
486
+ * \`@cloudflare/sandbox\`): the user's class only exists on the in-DO
487
+ * side; over RPC the caller gets a generic stub.
488
+ * - \`instanceof DurableObject\` (imported from \`cloudflare:workers\`): the
489
+ * stub's prototype chain has a class *named* \`DurableObject\`, but it's a
490
+ * workerd-internal class with a different identity than the importable
491
+ * one. \`instanceof\` checks identity, not name, so it returns \`false\`.
468
492
  *
469
- * \`instanceof DurableObject\` (imported from \`cloudflare:workers\`) is the
470
- * one signal that holds: it walks the prototype chain via the runtime and
471
- * matches any DO RPC stub. We treat any DO stub passed to \`init({ sandbox })\`
472
- * as intended for \`@cloudflare/sandbox\`, since that's the only documented
473
- * use case for that argument shape on the Cloudflare target.
493
+ * The one signal that does work — verified by runtime probe — is the string
494
+ * name of the prototype's constructor. Workerd's internal RPC stub class is
495
+ * named \`DurableObject\`, and \`Object.getPrototypeOf(stub).constructor.name\`
496
+ * returns that string. This is a heuristic (it relies on a workerd-internal
497
+ * naming convention, not a contractual API), but it's empirically correct
498
+ * today and will misroute only if a user passes some other DO stub to
499
+ * \`init({ sandbox })\` — in which case \`cfSandboxToSessionEnv\` will fail
500
+ * loudly on first method call.
474
501
  */
475
502
  function resolveSandbox(sandbox) {
476
- if (sandbox instanceof DurableObject) {
503
+ if (
504
+ sandbox &&
505
+ typeof sandbox === 'object' &&
506
+ Object.getPrototypeOf(sandbox)?.constructor?.name === 'DurableObject'
507
+ ) {
477
508
  return cfSandboxToSessionEnv(sandbox);
478
509
  }
479
510
  return null;
@@ -588,19 +619,16 @@ async function handleAgentRequest(request, doInstance, agentName, handler) {
588
619
  // Agent id is the DO "room name" set by routeAgentRequest
589
620
  const id = doInstance.name;
590
621
 
591
- // Parse payload
592
- let payload;
593
622
  try {
594
- payload = await request.json();
595
- } catch {
596
- payload = {};
597
- }
623
+ // Parse the request body. Throws on invalid Content-Type or malformed
624
+ // JSON; returns {} for genuinely empty bodies (so no-payload agents
625
+ // still work).
626
+ const payload = await parseJsonBody(request);
598
627
 
599
- const accept = request.headers.get('accept') || '';
600
- const isWebhook = request.headers.get('x-webhook') === 'true';
601
- const isSSE = accept.includes('text/event-stream') && !isWebhook;
628
+ const accept = request.headers.get('accept') || '';
629
+ const isWebhook = request.headers.get('x-webhook') === 'true';
630
+ const isSSE = accept.includes('text/event-stream') && !isWebhook;
602
631
 
603
- try {
604
632
  // Fire-and-forget (webhook mode)
605
633
  if (isWebhook) {
606
634
  const requestId = crypto.randomUUID();
@@ -619,7 +647,13 @@ async function handleAgentRequest(request, doInstance, agentName, handler) {
619
647
  });
620
648
  }
621
649
 
622
- // SSE streaming mode
650
+ // SSE streaming mode. Two error regimes meet here:
651
+ // - Pre-stream errors (body parsing, etc.) have already thrown above
652
+ // and are caught by the outer try/catch — rendered as plain HTTP
653
+ // responses by toHttpResponse, since headers haven't been sent yet.
654
+ // - Errors during agent execution surface as in-stream \`error\`
655
+ // events with the canonical envelope (via toSseData), since by
656
+ // then the 200 + text/event-stream headers are already on the wire.
623
657
  if (isSSE) {
624
658
  const { readable, writable } = new TransformStream();
625
659
  const writer = writable.getWriter();
@@ -631,7 +665,7 @@ async function handleAgentRequest(request, doInstance, agentName, handler) {
631
665
  const lines = [];
632
666
  if (event) lines.push('event: ' + event);
633
667
  lines.push('id: ' + eventId++);
634
- lines.push('data: ' + JSON.stringify(data));
668
+ lines.push('data: ' + (typeof data === 'string' ? data : JSON.stringify(data)));
635
669
  lines.push('', '');
636
670
  await writer.write(encoder.encode(lines.join('\\n')));
637
671
  };
@@ -653,10 +687,7 @@ async function handleAgentRequest(request, doInstance, agentName, handler) {
653
687
  'result',
654
688
  );
655
689
  } catch (err) {
656
- await writeSSE(
657
- { type: 'error', error: String(err) },
658
- 'error',
659
- );
690
+ await writeSSE(toSseData(err), 'error');
660
691
  if (!isIdle) {
661
692
  await writeSSE({ type: 'idle' }, 'idle');
662
693
  }
@@ -687,11 +718,10 @@ async function handleAgentRequest(request, doInstance, agentName, handler) {
687
718
  ctx.setEventCallback(undefined);
688
719
  }
689
720
  } catch (err) {
690
- console.error('[flue] Agent error:', agentName, err);
691
- return new Response(
692
- JSON.stringify({ error: String(err) }),
693
- { status: 500, headers: { 'content-type': 'application/json' } },
694
- );
721
+ // toHttpResponse logs unknowns via flueLog.error no extra console.error
722
+ // needed here. The agentName tag is captured in the wrapped error's
723
+ // server-side log line via flueLog's prefix.
724
+ return toHttpResponse(err);
695
725
  }
696
726
  }
697
727
 
@@ -701,38 +731,77 @@ ${agentClasses}
701
731
 
702
732
  // ─── User-declared Sandbox re-exports ──────────────────────────────────────
703
733
  // One line per DO binding in the user's wrangler.jsonc whose class_name
704
- // contains "Sandbox". Flue aliases the single \`Sandbox\` class shipped by
734
+ // ends with "Sandbox". Flue aliases the single \`Sandbox\` class shipped by
705
735
  // \`@cloudflare/sandbox\` so each user-chosen class_name resolves at the
706
736
  // bundle's top level. The binding + container image configuration is owned
707
737
  // by the user's wrangler.jsonc.
708
- ${detectSandboxBindings(userConfig).map((name) => `export { Sandbox as ${name} } from '@cloudflare/sandbox';`).join("\n")}
738
+ ${sandboxReExports}
709
739
 
710
740
  // ─── Worker Fetch Handler ───────────────────────────────────────────────────
711
741
 
712
742
  export default {
713
743
  async fetch(request, env) {
714
- const url = new URL(request.url);
744
+ try {
745
+ const url = new URL(request.url);
746
+ const method = request.method;
715
747
 
716
- // Health check
717
- if (url.pathname === '/health') {
718
- return new Response(JSON.stringify({ status: 'ok' }), {
719
- headers: { 'content-type': 'application/json' },
720
- });
721
- }
748
+ // Health check
749
+ if (url.pathname === '/health') {
750
+ return new Response(JSON.stringify({ status: 'ok' }), {
751
+ headers: { 'content-type': 'application/json' },
752
+ });
753
+ }
722
754
 
723
- // Agent manifest
724
- if (url.pathname === '/agents' && request.method === 'GET') {
725
- return new Response(JSON.stringify(manifest), {
726
- headers: { 'content-type': 'application/json' },
727
- });
728
- }
755
+ // Agent manifest
756
+ if (url.pathname === '/agents' && method === 'GET') {
757
+ return new Response(JSON.stringify(manifest), {
758
+ headers: { 'content-type': 'application/json' },
759
+ });
760
+ }
729
761
 
730
- // Route to per-agent DOs via the Agents SDK
731
- // URL: /agents/<agent-name>/<id>
732
- const response = await routeAgentRequest(request, env);
733
- if (response) return response;
762
+ // Webhook agent route: /agents/<name>/<id>
763
+ //
764
+ // We pre-check method and agent registration here, BEFORE delegating to
765
+ // routeAgentRequest. Without this, partyserver (the transitive
766
+ // dispatcher behind routeAgentRequest) returns a text/plain
767
+ // "Invalid request" 400 for unknown agent namespaces — visibly
768
+ // inconsistent with the rest of the API and with the Node target.
769
+ // Pre-routing means every error path in the agent route flows through
770
+ // the same JSON envelope.
771
+ const agentRouteMatch = url.pathname.match(/^\\/agents\\/([^/]+)\\/([^/]+)\\/?$/);
772
+ if (agentRouteMatch) {
773
+ if (method !== 'POST') {
774
+ throw new MethodNotAllowedError({ method, allowed: ['POST'] });
775
+ }
776
+ const name = decodeURIComponent(agentRouteMatch[1]);
777
+ const id = decodeURIComponent(agentRouteMatch[2]);
778
+ if (name.trim() === '' || id.trim() === '') {
779
+ throw new InvalidRequestError({
780
+ reason: 'Webhook URLs must have the shape /agents/<name>/<id> with non-empty segments.',
781
+ });
782
+ }
783
+ if (!webhookAgentNames.has(name)) {
784
+ throw new AgentNotFoundError({
785
+ name,
786
+ available: Array.from(webhookAgentNames),
787
+ });
788
+ }
789
+ // All gating passed. Delegate to the Agents SDK / partyserver, which
790
+ // dispatches into the per-agent DO's onRequest → handleAgentRequest.
791
+ // routeAgentRequest may still return null for shape mismatches we
792
+ // didn't anticipate; treat that as a route_not_found with a hint.
793
+ const response = await routeAgentRequest(request, env);
794
+ if (response) return response;
795
+ throw new RouteNotFoundError({ method, path: url.pathname });
796
+ }
734
797
 
735
- return new Response('Not found', { status: 404 });
798
+ // Anything else: canonical 404 envelope.
799
+ throw new RouteNotFoundError({ method, path: url.pathname });
800
+ } catch (err) {
801
+ // toHttpResponse logs unknowns via flueLog.error — no extra
802
+ // console.error needed at this layer.
803
+ return toHttpResponse(err);
804
+ }
736
805
  },
737
806
  };
738
807
  `;
@@ -757,7 +826,7 @@ export default {
757
826
  const sandboxClassNames = detectSandboxBindings(userConfig);
758
827
  if (sandboxClassNames.length > 0) {
759
828
  assertSandboxPackageInstalled(sandboxClassNames, [ctx.outputDir, ctx.workspaceDir]);
760
- for (const className of sandboxClassNames) console.log(`[flue] Detected Sandbox-named DO binding "${className}" re-exporting from @cloudflare/sandbox.`);
829
+ for (const className of sandboxClassNames) console.log(`[flue] Auto-wiring DO binding "${className}" to @cloudflare/sandbox's Sandbox class.`);
761
830
  }
762
831
  const merged = mergeFlueAdditions(userConfig, additions);
763
832
  stripNoisyWranglerDefaults(merged);
@@ -801,6 +870,11 @@ import {
801
870
  InMemorySessionStore,
802
871
  bashFactoryToSessionEnv,
803
872
  resolveModel,
873
+ parseJsonBody,
874
+ validateAgentRequest,
875
+ toHttpResponse,
876
+ toSseData,
877
+ RouteNotFoundError,
804
878
  } from '@flue/sdk/internal';
805
879
  import { randomUUID } from 'node:crypto';
806
880
 
@@ -818,7 +892,10 @@ const handlers = {
818
892
  ${agents.map((a) => ` ${JSON.stringify(a.name)}: ${agentVarName(a.name)},`).join("\n")}
819
893
  };
820
894
 
821
- const webhookAgents = new Set(${JSON.stringify(webhookAgents.map((a) => a.name))});
895
+ // Set of webhook-accessible agent names. Named distinctly from the
896
+ // validateAgentRequest \`webhookAgents\` parameter to keep the call site
897
+ // below readable.
898
+ const webhookAgentSet = new Set(${JSON.stringify(webhookAgents.map((a) => a.name))});
822
899
 
823
900
  // When the CLI starts this server via \`flue run\`, it sets FLUE_MODE=local.
824
901
  // In local mode the HTTP route accepts any registered agent (including
@@ -893,31 +970,29 @@ const app = new Hono();
893
970
  app.get('/health', (c) => c.json({ status: 'ok' }));
894
971
  app.get('/agents', (c) => c.json(manifest));
895
972
 
896
- // Agent id is required in the URL
897
- app.post('/agents/:name', (c) => {
898
- return c.json({
899
- error: 'Agent id is required. Use /agents/:name/:id',
900
- }, 400);
901
- });
902
-
903
- app.post('/agents/:name/:id', async (c) => {
973
+ // Catch any method on the agent route so non-POSTs become 405 (instead of
974
+ // Hono's default 404 for unmatched method). Throws are translated by the
975
+ // onError handler into the canonical error envelope.
976
+ app.all('/agents/:name/:id', async (c) => {
904
977
  const name = c.req.param('name');
905
978
  const id = c.req.param('id');
906
979
 
907
- if (!handlers[name]) {
908
- return c.json({ error: 'Agent not found' }, 404);
909
- }
910
- if (!webhookAgents.has(name) && !isLocalMode) {
911
- return c.json({ error: 'Agent "' + name + '" is not web-accessible (no webhook trigger)' }, 404);
912
- }
980
+ // Validate method, name shape, registration, webhook-accessibility.
981
+ // Throws FlueHttpError on any failure; caught by app.onError below.
982
+ validateAgentRequest({
983
+ method: c.req.method,
984
+ name,
985
+ id,
986
+ registeredAgents: Object.keys(handlers),
987
+ webhookAgents: Array.from(webhookAgentSet),
988
+ allowNonWebhook: isLocalMode,
989
+ });
913
990
 
914
991
  const handler = handlers[name];
915
- let payload;
916
- try {
917
- payload = await c.req.json();
918
- } catch {
919
- payload = {};
920
- }
992
+
993
+ // Parse the request body. Throws on invalid Content-Type or malformed JSON;
994
+ // returns {} for genuinely empty bodies (so no-payload agents still work).
995
+ const payload = await parseJsonBody(c.req.raw);
921
996
 
922
997
  const accept = c.req.header('accept') || '';
923
998
  const isWebhook = c.req.header('x-webhook') === 'true';
@@ -940,7 +1015,13 @@ app.post('/agents/:name/:id', async (c) => {
940
1015
  return c.json({ status: 'accepted', requestId }, 202);
941
1016
  }
942
1017
 
943
- // SSE streaming mode
1018
+ // SSE streaming mode. Two error regimes meet here:
1019
+ // - Pre-stream errors (validation, body parsing, agent lookup) have
1020
+ // already thrown above and are rendered as plain HTTP responses by
1021
+ // app.onError — headers haven't been sent yet, so this works.
1022
+ // - Errors during agent execution surface as in-stream \`error\` events
1023
+ // with the canonical envelope (via toSseData), since by then the
1024
+ // 200 + text/event-stream headers are already on the wire.
944
1025
  if (isSSE) {
945
1026
  return streamSSE(c, async (stream) => {
946
1027
  let eventId = 0;
@@ -964,7 +1045,7 @@ app.post('/agents/:name/:id', async (c) => {
964
1045
  });
965
1046
  } catch (err) {
966
1047
  await stream.writeSSE({
967
- data: JSON.stringify({ type: 'error', error: String(err) }),
1048
+ data: toSseData(err),
968
1049
  event: 'error',
969
1050
  id: String(eventId++),
970
1051
  });
@@ -978,18 +1059,29 @@ app.post('/agents/:name/:id', async (c) => {
978
1059
  });
979
1060
  }
980
1061
 
981
- // Sync mode (default)
1062
+ // Sync mode (default). Errors propagate to app.onError.
1063
+ const ctx = createContextForRequest(id, payload);
982
1064
  try {
983
- const ctx = createContextForRequest(id, payload);
984
1065
  const result = await handler(ctx);
985
- ctx.setEventCallback(undefined);
986
1066
  return c.json({ result: result !== undefined ? result : null });
987
- } catch (err) {
988
- console.error('[flue] Agent error:', name, err);
989
- return c.json({ error: String(err) }, 500);
1067
+ } finally {
1068
+ ctx.setEventCallback(undefined);
990
1069
  }
991
1070
  });
992
1071
 
1072
+ // 404 handler — fires for any URL that didn't match a registered route.
1073
+ app.notFound((c) => {
1074
+ // Throw rather than return so the onError handler is the single source of
1075
+ // truth for error-envelope shaping.
1076
+ throw new RouteNotFoundError({ method: c.req.method, path: new URL(c.req.url).pathname });
1077
+ });
1078
+
1079
+ // Single-source-of-truth error renderer. Every thrown FlueError (and every
1080
+ // thrown unknown) is converted to the canonical JSON envelope here.
1081
+ // toHttpResponse takes care of logging unknowns — no extra console.error
1082
+ // needed at this layer.
1083
+ app.onError((err) => toHttpResponse(err));
1084
+
993
1085
  // ─── Start ──────────────────────────────────────────────────────────────────
994
1086
 
995
1087
  const port = parseInt(process.env.PORT || '3000', 10);
@@ -1585,6 +1677,29 @@ var CloudflareReloader = class {
1585
1677
  port;
1586
1678
  configPath;
1587
1679
  envFiles;
1680
+ /**
1681
+ * Stable container build ID for the lifetime of this reloader instance.
1682
+ *
1683
+ * `unstable_startWorker` does NOT default this field — only wrangler's CLI
1684
+ * path does, via `generateContainerBuildId()`. When the user's wrangler
1685
+ * config declares `containers[]` (e.g. via `@cloudflare/sandbox`), the
1686
+ * first `onBundleComplete` calls `getImageNameFromDOClassName(...)` which
1687
+ * asserts that `options.containerBuildId` is set; without this, the
1688
+ * assertion throws, the `ProxyController` never gets `reloadComplete`,
1689
+ * and every request hangs (including `/health`). See issue #22.
1690
+ *
1691
+ * We generate it once per reloader and reuse it across reloads so that
1692
+ * wrangler's container-prep cache hits when nothing about the image
1693
+ * changed. Format matches wrangler's own helper: an 8-char UUID slice.
1694
+ */
1695
+ containerBuildId;
1696
+ /**
1697
+ * Bound listener for `DevEnv` `'error'` events. Stored so we can detach
1698
+ * it on `disposeWorker()` — the underlying `EventEmitter` outlives the
1699
+ * worker handle, so if the listener stayed attached we'd leak (and
1700
+ * double-fire) across reloads.
1701
+ */
1702
+ errorListener = null;
1588
1703
  url;
1589
1704
  constructor(wrangler, opts) {
1590
1705
  this.wrangler = wrangler;
@@ -1592,6 +1707,7 @@ var CloudflareReloader = class {
1592
1707
  this.port = opts.port;
1593
1708
  this.envFiles = opts.envFiles;
1594
1709
  this.configPath = path.join(this.outputDir, "dist", "wrangler.jsonc");
1710
+ this.containerBuildId = randomUUID().slice(0, 8);
1595
1711
  }
1596
1712
  async start() {
1597
1713
  await this.startWorker();
@@ -1650,9 +1766,18 @@ var CloudflareReloader = class {
1650
1766
  port: this.port
1651
1767
  },
1652
1768
  watch: false,
1653
- logLevel: "info"
1769
+ logLevel: "info",
1770
+ containerBuildId: this.containerBuildId
1654
1771
  }
1655
1772
  });
1773
+ this.errorListener = (event) => {
1774
+ const reason = event?.reason ?? "unknown error";
1775
+ const cause = event?.cause;
1776
+ const causeMsg = cause && typeof cause === "object" && "message" in cause ? cause.message : void 0;
1777
+ console.error(`[flue] Wrangler error (${event?.source ?? "unknown"}): ${reason}`);
1778
+ if (causeMsg) console.error(`[flue] ${causeMsg}`);
1779
+ };
1780
+ this.worker.raw.on("error", this.errorListener);
1656
1781
  try {
1657
1782
  this.url = (await this.worker.url).toString().replace(/\/$/, "");
1658
1783
  } catch {
@@ -1661,8 +1786,13 @@ var CloudflareReloader = class {
1661
1786
  }
1662
1787
  async disposeWorker() {
1663
1788
  const worker = this.worker;
1789
+ const listener = this.errorListener;
1664
1790
  this.worker = null;
1791
+ this.errorListener = null;
1665
1792
  if (!worker) return;
1793
+ if (listener) try {
1794
+ worker.raw.off("error", listener);
1795
+ } catch {}
1666
1796
  try {
1667
1797
  await worker.dispose();
1668
1798
  } catch (err) {