blokctl 1.0.0 → 1.2.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.
Files changed (32) hide show
  1. package/dist/commands/create/project.d.ts +2 -0
  2. package/dist/commands/create/project.js +47 -28
  3. package/dist/commands/create/utils/Examples.d.ts +3 -3
  4. package/dist/commands/create/utils/Examples.js +91 -121
  5. package/dist/commands/create/workflow.js +8 -6
  6. package/dist/commands/dev/index.js +26 -0
  7. package/dist/commands/install/node.d.ts +1 -0
  8. package/dist/commands/install/node.js +3 -39
  9. package/dist/commands/migrate/index.js +25 -0
  10. package/dist/commands/migrate/nodesTs.d.ts +31 -0
  11. package/dist/commands/migrate/nodesTs.js +484 -0
  12. package/dist/commands/migrate/refs.d.ts +15 -0
  13. package/dist/commands/migrate/refs.js +706 -0
  14. package/dist/commands/nodes/index.js +10 -0
  15. package/dist/commands/nodes/listNodes.d.ts +2 -0
  16. package/dist/commands/nodes/listNodes.js +20 -15
  17. package/dist/commands/nodes/syncNodes.d.ts +7 -0
  18. package/dist/commands/nodes/syncNodes.js +138 -0
  19. package/dist/commands/nodes/syncNodes.test.d.ts +1 -0
  20. package/dist/commands/nodes/syncNodes.test.js +322 -0
  21. package/dist/commands/publish/workflow.d.ts +1 -0
  22. package/dist/commands/publish/workflow.js +9 -0
  23. package/dist/commands/publish/workflow.test.d.ts +1 -0
  24. package/dist/commands/publish/workflow.test.js +22 -0
  25. package/dist/services/observability-stack.real-prometheus.test.d.ts +1 -0
  26. package/dist/services/observability-stack.real-prometheus.test.js +95 -0
  27. package/dist/services/observability-stack.real-tempo.test.d.ts +1 -0
  28. package/dist/services/observability-stack.real-tempo.test.js +86 -0
  29. package/dist/studio-dist/assets/index-QEvKRE4T.js +42 -0
  30. package/dist/studio-dist/index.html +1 -1
  31. package/package.json +3 -2
  32. package/dist/studio-dist/assets/index-CnFqCRQe.js +0 -42
@@ -1,5 +1,7 @@
1
1
  import type { OptionValues } from "commander";
2
2
  export declare function createProject(opts: OptionValues, version: string, currentPath?: boolean, localRepoPath?: string): Promise<void>;
3
+ export declare function generateSharedNodesFile(_triggers: string[], _repoSource: string): string;
4
+ export declare function generateSharedWorkflowsFile(triggers: string[], runtimeKinds?: string[], examples?: boolean): string;
3
5
  export declare function updateQueueProvider(triggerDestDir: string, provider: string, explicit: boolean): void;
4
6
  export declare function getProviderDependencies(triggers: string[], pubsubProvider: string, queueProvider: string, explicitQueueProvider?: boolean): Record<string, string>;
5
7
  export declare function getProviderEnvVars(triggers: string[], pubsubProvider: string, queueProvider: string, explicitQueueProvider?: boolean): string;
@@ -22,7 +22,7 @@ const exec = util.promisify(child_process.exec);
22
22
  const HOME_DIR = `${os.homedir()}/.blok`;
23
23
  const GITHUB_REPO_LOCAL = `${HOME_DIR}/blok`;
24
24
  const GITHUB_REPO_REMOTE = "https://github.com/well-prado/blok.git";
25
- const GITHUB_REPO_RELEASE_TAG = "v1.0.0";
25
+ const GITHUB_REPO_RELEASE_TAG = "v1.2.0";
26
26
  const RUNTIME_HELLO_EXAMPLES = {
27
27
  go: "runtime-go-hello.ts",
28
28
  rust: "runtime-rust-hello.ts",
@@ -551,7 +551,7 @@ export async function createProject(opts, version, currentPath = false, localRep
551
551
  "@blokjs/trigger-worker": "triggers/worker",
552
552
  "@blokjs/core": "core/core",
553
553
  };
554
- const BLOKJS_DEP_RANGE = "^1.0.0";
554
+ const BLOKJS_DEP_RANGE = "^1.2.0";
555
555
  for (const depGroup of ["dependencies", "devDependencies", "peerDependencies"]) {
556
556
  const deps = packageJsonContent[depGroup];
557
557
  if (!deps)
@@ -564,6 +564,10 @@ export async function createProject(opts, version, currentPath = false, localRep
564
564
  }
565
565
  }
566
566
  for (const [pkg, ver] of Object.entries(deps)) {
567
+ if (localRepoPath && workspacePackageMap[pkg] && typeof ver === "string" && /^[~^]?\d/.test(ver)) {
568
+ deps[pkg] = `file:${path.resolve(repoSource, workspacePackageMap[pkg])}`;
569
+ continue;
570
+ }
567
571
  if (typeof ver === "string" && ver.startsWith("workspace:")) {
568
572
  if (localRepoPath && workspacePackageMap[pkg]) {
569
573
  deps[pkg] = `file:${path.resolve(repoSource, workspacePackageMap[pkg])}`;
@@ -774,8 +778,15 @@ export async function createProject(opts, version, currentPath = false, localRep
774
778
  supervisordConfContent += generateSupervisordConfig(runtimeConfigs);
775
779
  }
776
780
  fsExtra.writeFileSync(supervisordConfPath, supervisordConfContent);
777
- fsExtra.writeFileSync(`${dirPath}/AGENTS.md`, agents_md.trimStart());
778
- fsExtra.writeFileSync(`${dirPath}/CLAUDE.md`, claude_md.trimStart());
781
+ const docsTemplateDir = `${repoSource}/packages/cli/scaffold-templates`;
782
+ if (fsExtra.existsSync(`${docsTemplateDir}/AGENTS.md`)) {
783
+ fsExtra.copySync(`${docsTemplateDir}/AGENTS.md`, `${dirPath}/AGENTS.md`);
784
+ fsExtra.copySync(`${docsTemplateDir}/CLAUDE.md`, `${dirPath}/CLAUDE.md`);
785
+ }
786
+ else {
787
+ fsExtra.writeFileSync(`${dirPath}/AGENTS.md`, agents_md.trimStart());
788
+ fsExtra.writeFileSync(`${dirPath}/CLAUDE.md`, claude_md.trimStart());
789
+ }
779
790
  s.message("Installing packages...");
780
791
  const cmd_install_ts_response = await exec(manager.INSTALL, { cwd: dirPath });
781
792
  s.message("Packages installed successfully!");
@@ -847,39 +858,47 @@ export async function createProject(opts, version, currentPath = false, localRep
847
858
  console.log(error.message);
848
859
  }
849
860
  }
850
- function generateSharedNodesFile(triggers, _repoSource) {
851
- const nodeImports = new Set();
852
- const nodeExports = new Map();
853
- let spreadHelperNodes = false;
854
- nodeImports.add('import ApiCall from "@blokjs/api-call";');
855
- nodeImports.add('import IfElse from "@blokjs/if-else";');
856
- nodeImports.add('import type { BlokService } from "@blokjs/runner";');
857
- nodeExports.set("@blokjs/api-call", "ApiCall");
858
- nodeExports.set("@blokjs/if-else", "IfElse");
859
- if (triggers.includes("sse") || triggers.includes("websocket")) {
860
- nodeImports.add('import { HELPER_NODES } from "@blokjs/helpers";');
861
- spreadHelperNodes = true;
862
- }
863
- const importLines = Array.from(nodeImports).join("\n");
864
- const exportEntries = Array.from(nodeExports.entries())
865
- .map(([key, value]) => `\t"${key}": ${value},`)
866
- .join("\n");
867
- const recordBody = spreadHelperNodes ? `\t...HELPER_NODES,\n${exportEntries}` : exportEntries;
868
- return `${importLines}
861
+ export function generateSharedNodesFile(_triggers, _repoSource) {
862
+ const imports = [
863
+ 'import { dirname, join } from "node:path";',
864
+ 'import { fileURLToPath } from "node:url";',
865
+ 'import ApiCall from "@blokjs/api-call";',
866
+ 'import IfElse from "@blokjs/if-else";',
867
+ 'import { HELPER_NODES } from "@blokjs/helpers";',
868
+ 'import { discoverNodes } from "@blokjs/runner";',
869
+ 'import type { NodeBase } from "@blokjs/shared";',
870
+ ];
871
+ const explicit = [
872
+ "ApiCall as unknown as NodeBase",
873
+ "IfElse as unknown as NodeBase",
874
+ "...(Object.values(HELPER_NODES) as unknown as NodeBase[])",
875
+ ];
876
+ return `${imports.join("\n")}
869
877
 
870
- const nodes: Record<string, BlokService<unknown>> = {
871
- ${recordBody}
872
- };
878
+ // Published nodes (npm) are registered explicitly below. Your OWN nodes under
879
+ // 'nodes/<name>/index.ts' are AUTO-DISCOVERED and registered by their
880
+ // defineNode({ name }) — you never edit this file to add a node.
881
+ const here = dirname(fileURLToPath(import.meta.url));
882
+ const local = await discoverNodes(join(here, "nodes"));
883
+
884
+ // Map keys are cosmetic — the runner registers each node under its own node.name
885
+ // (the canonical 'use:' ref). Duplicate refs throw at startup.
886
+ const nodes: { [key: string]: NodeBase } = {};
887
+ for (const node of [${explicit.join(", ")}, ...local]) {
888
+ \tnodes[(node as { name: string }).name] = node;
889
+ }
873
890
 
874
891
  export default nodes;
875
892
  `;
876
893
  }
877
- function generateSharedWorkflowsFile(triggers, runtimeKinds = [], examples = false) {
894
+ export function generateSharedWorkflowsFile(triggers, runtimeKinds = [], examples = false) {
878
895
  const imports = [];
879
896
  const workflowEntries = [];
880
897
  for (const trigger of triggers) {
881
898
  if (trigger === "http") {
882
- imports.push("// HTTP workflows are auto-discovered from workflows/json/");
899
+ imports.push("// HTTP JSON workflows are auto-discovered from workflows/json/");
900
+ imports.push('import CountriesHandleDsl from "./workflows/http/countries-handle-dsl";');
901
+ workflowEntries.push('\t"countries-dsl": await CountriesHandleDsl,');
883
902
  }
884
903
  else if (trigger === "sse") {
885
904
  imports.push('import SSEStreamDemo from "./workflows/sse/events/stream-demo";');
@@ -1,4 +1,4 @@
1
- declare const node_file = "import ApiCall from \"@blokjs/api-call\";\nimport IfElse from \"@blokjs/if-else\";\nimport type { NodeBase } from \"@blokjs/shared\";\nimport ChainInit from \"./nodes/chain-init/index\";\nimport ChainVerify from \"./nodes/chain-verify/index\";\nimport ExampleNodes from \"./nodes/examples/index\";\nimport RuntimeBridge from \"./nodes/runtime-bridge/index\";\n\nconst nodes: {\n\t[key: string]: NodeBase;\n} = {\n\t\"@blokjs/api-call\": ApiCall,\n\t\"@blokjs/if-else\": IfElse,\n\t\"chain-init\": ChainInit,\n\t\"chain-verify\": ChainVerify,\n\t\"runtime-bridge\": RuntimeBridge,\n\t...ExampleNodes,\n};\n\nexport default nodes;\n";
1
+ declare const node_file = "import { dirname, join } from \"node:path\";\nimport { fileURLToPath } from \"node:url\";\nimport ApiCall from \"@blokjs/api-call\";\nimport IfElse from \"@blokjs/if-else\";\nimport { discoverNodes } from \"@blokjs/runner\";\nimport type { NodeBase } from \"@blokjs/shared\";\nimport ExampleNodes from \"./nodes/examples/index\";\n\n// Published nodes (npm) + the example bundle are registered explicitly below.\n// Your OWN nodes under 'nodes/<name>/index.ts' are AUTO-DISCOVERED and registered\n// by their defineNode({ name }) \u2014 you never edit this file to add a node.\nconst here = dirname(fileURLToPath(import.meta.url));\nconst local = await discoverNodes(join(here, \"nodes\"));\n\nconst explicit: NodeBase[] = [ApiCall, IfElse, ...(Object.values(ExampleNodes) as NodeBase[])];\n\n// Map keys are cosmetic \u2014 the runner registers each node under its own node.name\n// (the canonical 'use:' ref). Duplicate refs throw at startup.\nconst nodes: { [key: string]: NodeBase } = {};\nfor (const node of [...explicit, ...local]) {\n\tnodes[(node as { name: string }).name] = node;\n}\n\nexport default nodes;\n";
2
2
  declare const package_dependencies: {
3
3
  ai: string;
4
4
  "@ai-sdk/openai": string;
@@ -14,7 +14,7 @@ declare const package_dev_dependencies: {
14
14
  };
15
15
  declare const python3_file = "from pydantic import BaseModel\n\nfrom blok import node\nfrom blok.types.context import Context\n\n\nclass Input(BaseModel):\n \"\"\"Validated inputs for the {{NODE_NAME}} node.\"\"\"\n\n name: str = \"world\"\n\n\nclass Output(BaseModel):\n message: str\n\n\n@node(\"{{NODE_NAME}}\", \"Describe what {{NODE_NAME}} does\")\ndef run(ctx: Context, input: Input) -> Output:\n # `input` is already validated against Input; the return is validated\n # against Output and serialized for you.\n return Output(message=f\"Hello, {input.name}!\")\n";
16
16
  declare const examples_url = "\nExamples:\n1- Open \"workflow-docs.json\" in your browser at http://localhost:4000/workflow-docs\n2- Open \"db-manager.json\" in your browser at http://localhost:4000/db-manager\n3- Open \"dashboard-gen.json\" in your browser at http://localhost:4000/dashboard-gen\n4- Open \"countries.json\" in your browser at http://localhost:4000/countries\n5- Open \"chat.json\" in your browser at http://localhost:4000/chat (set OPENROUTER_API_KEY first)\n6- Open \"chat-memory.json\" in your browser at http://localhost:4000/chat-memory (needs OPENROUTER_API_KEY + Redis at REDIS_URL)\n7- Webhook router: POST /webhooks/{stripe,github,linear} with signed bodies \u2014 set the matching *_WEBHOOK_SECRET env vars (needs --triggers webhook)\n8- LLM agent w/ tool calls: open http://localhost:4000/agent \u2014 model picks between get_weather and calculate tools (needs OPENROUTER_API_KEY + Redis)\n9- Worker fan-out: POST /fanout/jobs with body '{items:[...], tenantId?:\"...\"}' to enqueue N worker jobs (needs --triggers worker; BLOK_WORKER_ADAPTER=in-memory works single-process)\n10- Trigger references (NOT http): workflows/json/{cron-heartbeat,pubsub-on-order,websocket-echo}.json demonstrate the cron, pubsub, and websocket triggers \u2014 read AGENTS.md \"Choosing a trigger\" to pick the right one by intent instead of defaulting to HTTP.\n\nFor more documentation, visit src/nodes/examples/README.md. The first three examples require a PostgreSQL database to function.\n";
17
- declare const workflow_template = "\n{\n\t\"name\": \"My Workflow\",\n\t\"description\": \"What this workflow does\",\n\t\"version\": \"1.0.0\",\n\t\"trigger\": {\n\t\t\"http\": {\n\t\t\t\"method\": \"GET\",\n\t\t\t\"accept\": \"application/json\"\n\t\t}\n\t},\n\t\"steps\": [\n\t\t{\n\t\t\t\"id\": \"echo\",\n\t\t\t\"use\": \"@blokjs/respond\",\n\t\t\t\"inputs\": {\n\t\t\t\t\"body\": \"$.req.body\"\n\t\t\t}\n\t\t}\n\t]\n}\n";
17
+ declare const workflow_template = "import { http, node, step, workflow } from \"@blokjs/core\";\n\n/**\n * {{WORKFLOW_NAME}}\n *\n * Add steps with step(\"id\", node, inputs). Read a step's output downstream via\n * the handle it returns (const user = step(...); ... user.name). Control flow:\n * branch / forEach / switchOn / tryCatch \u2014 all from @blokjs/core.\n */\nexport default workflow(\n\t\"{{WORKFLOW_NAME}}\",\n\t{ version: \"1.0.0\", trigger: http.get(\"{{WORKFLOW_PATH}}\") },\n\t(req) => {\n\t\t// Echo the request body back. @blokjs/respond writes the HTTP response,\n\t\t// so mark it ephemeral (no state slot needed).\n\t\tstep(\"echo\", node(\"@blokjs/respond\"), { body: req.body }, { ephemeral: true });\n\t},\n);\n";
18
18
  declare const supervisord_nodejs = "\n[supervisord]\nnodaemon=true\n\n[program:nodejs_app]\ncommand=npm start\ndirectory=/app\nautostart=true\nautorestart=true\nstderr_logfile=/var/log/nodejs.err.log\nstdout_logfile=/var/log/nodejs.out.log\n";
19
19
  declare const supervisord_python = "\n[program:python_app]\ncommand=python3 /app/.blok/runtimes/python3/server.py\ndirectory=/app\nautostart=true\nautorestart=true\nstderr_logfile=/var/log/python.err.log\nstdout_logfile=/var/log/python.out.log\n";
20
20
  declare const go_node_file = "// {{NODE_NAME}} \u2014 a Blok node for the Go runtime.\npackage {{NODE_PKG}}\n\nimport (\n\tblok \"github.com/nickincloud/blok-go\"\n)\n\n// {{NODE_NAME_PASCAL}}Node implements blok.NodeHandler.\ntype {{NODE_NAME_PASCAL}}Node struct{}\n\n// Execute runs the node. Input arrives on ctx.Request; config holds the step's\n// inputs from the workflow. Return any JSON-serialisable value (or an error).\nfunc (n *{{NODE_NAME_PASCAL}}Node) Execute(ctx *blok.Context, config map[string]interface{}) (interface{}, error) {\n\tname := \"World\"\n\tif body := ctx.Request.BodyMap(); body != nil {\n\t\tif v, ok := body[\"name\"].(string); ok {\n\t\t\tname = v\n\t\t}\n\t}\n\n\tprefix := \"Hello\"\n\tif v, ok := config[\"prefix\"].(string); ok {\n\t\tprefix = v\n\t}\n\n\treturn map[string]interface{}{\n\t\t\"message\": prefix + \", \" + name + \"!\",\n\t\t\"language\": \"Go\",\n\t}, nil\n}\n\n// Register wires this node into the runtime registry. The generated\n// register_user_nodes.go calls it for every node under runtimes/go/nodes.\nfunc Register(registry *blok.NodeRegistry) {\n\tregistry.Register(\"{{NODE_NAME}}\", &{{NODE_NAME_PASCAL}}Node{})\n}\n";
@@ -23,7 +23,7 @@ declare const rust_node_file = "use async_trait::async_trait;\nuse blok::registr
23
23
  declare const csharp_node_file = "using System.Text.Json;\nusing Blok.Core.Node;\nusing Blok.Core.Types;\n\nnamespace Blok.Core.Nodes;\n\n/// <summary>\n/// {{NODE_NAME_PASCAL}}Node \u2014 a Blok node for the C# runtime. Discovered under\n/// runtimes/csharp/nodes/ and registered into the shared gRPC server alongside\n/// the built-in nodes (same model as the other SDKs). Run `blokctl dev`; no\n/// per-node csproj/Dockerfile \u2014 it isn't a standalone service.\n/// </summary>\npublic class {{NODE_NAME_PASCAL}}Node : INodeHandler\n{\n /// <summary>\n /// Execute the node. Input arrives on ctx.Request; config holds the step's\n /// inputs from the workflow. Return any JSON-serialisable value.\n /// </summary>\n public Task<JsonElement> ExecuteAsync(Context ctx, Dictionary<string, JsonElement> config)\n {\n var name = \"World\";\n if (ctx.Request.Body.ValueKind == JsonValueKind.Object &&\n ctx.Request.Body.TryGetProperty(\"name\", out var nameEl) &&\n nameEl.ValueKind == JsonValueKind.String)\n {\n name = nameEl.GetString() ?? \"World\";\n }\n\n var prefix = \"Hello\";\n if (config.TryGetValue(\"prefix\", out var prefixEl) &&\n prefixEl.ValueKind == JsonValueKind.String)\n {\n prefix = prefixEl.GetString() ?? \"Hello\";\n }\n\n var message = $\"{prefix}, {name}!\";\n ctx.SetVar(\"greeting\", message);\n\n var result = JsonSerializer.SerializeToElement(new\n {\n message,\n timestamp = DateTime.UtcNow.ToString(\"o\"),\n language = \"C#\"\n });\n\n return Task.FromResult(result);\n }\n}\n";
24
24
  declare const php_node_file = "<?php\n\ndeclare(strict_types=1);\n\nnamespace Blok\\Blok\\Nodes\\{{NODE_NAME_PASCAL}};\n\nuse Blok\\Blok\\Node\\NodeHandler;\nuse Blok\\Blok\\Types\\Context;\n\n/**\n * {{NODE_NAME}} \u2014 a Blok node for the PHP runtime.\n *\n * Input arrives on $ctx->request; $config holds the step's inputs from the\n * workflow. Return any JSON-serialisable value (array, scalar, etc.).\n */\nfinal class {{NODE_NAME_PASCAL}}Node implements NodeHandler\n{\n public function execute(Context $ctx, array $config): mixed\n {\n $name = $ctx->request->bodyStr('name') ?? 'World';\n $prefix = isset($config['prefix']) && is_string($config['prefix'])\n ? $config['prefix']\n : 'Hello';\n\n return [\n 'message' => sprintf('%s, %s!', $prefix, $name),\n 'language' => 'PHP',\n ];\n }\n}\n";
25
25
  declare const ruby_node_file = "# frozen_string_literal: true\n\n# {{NODE_NAME_PASCAL}}Node \u2014 registered as \"{{NODE_NAME}}\" by the Ruby runtime.\n#\n# Request body arrives on ctx.request (use body_str(key) for a String|nil);\n# config holds the step's inputs from the workflow. Return any\n# JSON-serialisable Hash. Publish to downstream steps with ctx.set_var(k, v).\nclass {{NODE_NAME_PASCAL}}Node < Blok::Node::NodeHandler\n def execute(ctx, config)\n name = ctx.request.body_str(\"name\") || \"World\"\n prefix = config[\"prefix\"] || \"Hello\"\n\n message = \"#{prefix}, #{name}!\"\n ctx.set_var(\"greeting\", message)\n\n {\n \"message\" => message,\n \"language\" => \"Ruby\"\n }\n end\nend";
26
- declare const agents_md = "\n# AGENTS.md \u2014 Blok Framework AI Context\n\nBlok is a **multi-trigger, multi-runtime workflow framework**. A workflow is a declarative list of steps; each step runs a node; the runner resolves data between steps and persists state. Two facts shape everything you author here:\n\n- **HTTP is ONE of 9 triggers, NOT the default.** Every workflow declares exactly one trigger. Picking `http` reflexively is the most common mistake \u2014 start with the decision table below.\n- **Nodes can be written in 8 runtimes.** TypeScript runs in-process; the other 7 (`go`, `rust`, `java`, `csharp`, `php`, `ruby`, `python3`) run as gRPC sidecar processes. A step routes to a sidecar via `type: \"runtime.<lang>\"`.\n\nThe 9 trigger types: `http`, `worker`, `cron`, `pubsub`, `sse`, `websocket`, `webhook`, `mcp`, `grpc`.\nThe 8 runtimes: `typescript` (in-process), `go`, `rust`, `java`, `csharp`, `php`, `ruby`, `python3`.\n\nThe canonical TypeScript form is the **typed-handle DSL** from `@blokjs/core`: `workflow(name, { version, trigger }, (entry) => { ... })`, where each `step()` returns a typed handle you reference directly \u2014 **no `$`, no `js/`, no raw `ctx` strings.** The object-style `workflow({ name, version, trigger, steps: [...] })` from `@blokjs/helper` and JSON workflows are equivalent (all three compile to the same IR) and remain fully supported. The same shape works for all 9 triggers \u2014 only the `trigger:` block changes.\n\n---\n\n## 0. THE TYPED-HANDLE DSL (`@blokjs/core`) \u2014 preferred for TypeScript\n\n```ts\nimport { workflow, step, branch, forEach, switchOn, tryCatch, http, tpl, gt } from \"@blokjs/core\";\n\nexport default workflow(\"order-intake\", { version: \"1.0.0\", trigger: http.post(\"/orders\") }, (req) => {\n // step(id, node, inputs) \u2192 a TYPED handle shaped like the node's output.\n const order = step(\"validate\", validateOrder, { qty: req.body.qty });\n\n // Reference a handle field anywhere downstream \u2014 it records a ref the runner resolves.\n step(\"summary\", summarize, { line: tpl`order of ${order.qty} item(s)` });\n\n // Control flow: a comparator (gt/eq/lt/...) builds the condition; arms are callbacks.\n branch(\"lane\", gt(order.qty, 10), {\n then: () => { step(\"bulk\", routeOrder, { lane: \"bulk\" }); },\n else: () => { step(\"standard\", routeOrder, { lane: \"standard\" }); },\n });\n});\n```\n\n**Trigger config:** `http.{get,post,put,delete,patch,any}(path?, opts?)` for HTTP (omit `path` for file-based routing). For any other trigger pass the raw block as `trigger:` \u2014 e.g. `{ worker: { queue: \"jobs\" } }`, `{ cron: { schedule: \"0 2 * * *\" } }`, `{ webhook: { source: \"stripe\" } }`.\n\n**The entry handle** (the callback's argument) is the trigger payload \u2014 typed and conventionally named per trigger: `http`\u2192`req`, `webhook`\u2192`event`, `cron`\u2192`tick`, `worker`\u2192`job`, `pubsub`\u2192`msg`, `grpc`\u2192`rpc` (others get a loose handle). Read `req.body`, `req.params.id`, `req.query.q`, `req.headers[\"x-\u2026\"]`.\n\n**Handles & persistence:**\n- `const h = step(\"id\", node, inputs)` \u2014 every step auto-persists to `ctx.state[\"id\"]` on success; `h` / `h.field` is how you reference it later (never `$.state.id`).\n- 4th arg `opts`: `{ as: \"name\" }` roots the handle at `state[\"name\"]`; `{ spread: true }` flattens `result.data` into state (mutually exclusive with `as`); `{ ephemeral: true }` skips persistence (handle then unreadable downstream); also `idempotencyKey`, `retry`, `maxDuration`, `type: \"runtime.<lang>\"`.\n- `tpl`\u2026${h.field}\u2026`` builds a string with embedded handle refs.\n- Comparators (typed): `eq, ne, gt, gte, lt, lte, not`. A bare boolean handle is a truthiness check.\n- `defineNode` is re-exported from `@blokjs/core` \u2014 author nodes exactly as before; just RETURN output (never write `ctx.state`/`ctx.vars`).\n\n**Control-flow primitives** (all from `@blokjs/core`):\n- `branch(id, cond, { then: () => {\u2026}, else?: () => {\u2026} })` \u2014 `cond` is a comparator or a boolean handle.\n- `forEach(iterable, (item, index) => { step(\u2026) }, { as?, mode?, concurrency? })` \u2014 `iterable` is a handle; `item`/`index` are per-iteration handles; `mode: \"parallel\"` bounds with `concurrency` (default 10).\n- `switchOn(discriminant, { cases: [{ when: \"push\", do: () => {\u2026} }], default?: () => {\u2026} }, { id })` \u2014 `when` labels are STATIC literals (a handle never matches).\n- `tryCatch(id, { try: () => {\u2026}, catch: (error) => { step(\"log\", logger, { msg: error.message }); }, finally?: () => {\u2026} })` \u2014 `error` is a typed handle (`.message`/`.name`/`.stack`/`.code`/`.stepId`).\n\n**The four footguns** (full detail: `docs/d/primitives/handles-and-footguns.mdx`):\n1. **Arm-scoped handles don't escape their arm.** A handle minted inside a `branch`/`switchOn`/`tryCatch`/`forEach` arm is unreadable outside it. Return a value from the control-flow step, or have both arms write one `as:` key.\n2. **Ephemeral handles are unreadable.** Reading an `{ ephemeral: true }` step's handle resolves to `undefined`.\n3. **Never reuse a step `id`** \u2014 including across mutually-exclusive arms. Ids are a flat per-workflow map; the runner throws at load time. Use `as:` when two arms must write the same downstream key.\n4. **Never name a `forEach` `as:`/`asIndex` after an existing step id** (or another loop's `as`) \u2014 they share `ctx.state`; the runner throws at load time.\n\n---\n\n## 1. CHOOSING A TRIGGER (do this first, every time)\n\n**Before writing `trigger: { http: ... }`, read this table and pick by intent.**\n\n| Intent / what you're building | Trigger | Why NOT http |\n|---|---|---|\n| Respond to an HTTP/REST request; JSON API; HTML page; file download | **`http`** | \u2014 |\n| Process a background / queued / async job; offload slow work | **`worker`** | http blocks the caller; jobs need a queue + retries + DLQ |\n| Run on a schedule / recurring time-based job (nightly, hourly, cron) | **`cron`** | http only fires on a request; nothing calls it on a timer |\n| React to messages on a cloud topic/subscription (cross-service events) | **`pubsub`** | http isn't subscribed to a broker; events would be dropped |\n| Stream / push live updates one-way to a browser (tokens, progress, feed) | **`sse`** | a plain http response is one-shot; it can't keep pushing |\n| Bidirectional realtime (chat rooms, live cursors, client\u2194server messages) | **`websocket`** | http is half-duplex request/response, no server push back-channel |\n| Receive a signed provider webhook (Stripe / GitHub / Slack / Shopify / Svix / custom HMAC) | **`webhook`** | http won't verify the HMAC signature or do replay protection |\n| Expose a workflow as a tool/resource to an AI/LLM client (Cursor, Claude) | **`mcp`** | http isn't MCP; the client can't discover or call it as a tool |\n| High-throughput typed RPC between services with a proto contract | **`grpc`** | http/REST overhead is too high; no typed contract |\n\n**Tie-breakers:**\n- **One-way stream \u2192 `sse`; two-way \u2192 `websocket`.** SSE is cheaper and simpler; reach for `websocket` only when the client must send messages back over the same connection.\n- **In-process pub/sub (single Node process, HTTP+SSE chains) \u2192 the `sse` bus, NOT `pubsub`.** `pubsub` is the multi-process / multi-cloud sibling backed by an external broker.\n- **Queue consumer \u2192 `worker`, never `queue`.** `trigger.queue` is **DEAD** \u2014 it has a schema but no runtime and throws at workflow construction time. Always use `worker` (`{ worker: { queue: \"<name>\" } }`).\n\n### Read `.blok/config.json` first\n\nThe project records which triggers and runtimes were actually scaffolded in **`.blok/config.json`**. **Author for those \u2014 do not assume HTTP.** If the project was scaffolded with the worker trigger and the Go runtime, the user almost certainly wants a worker workflow and/or a Go node, not an HTTP endpoint. When in doubt, read that file and match the existing workflows under `src/workflows/`.\n\n### Same-port vs cross-process families\n\n- **Same-port family** \u2014 `http`, `sse`, `websocket`, `webhook`, `mcp` all mount on the **same Hono HTTP server / port** (default 4000) and share an in-process event bus.\n- **Cross-process family** \u2014 `worker`, `cron`, `pubsub`, `grpc` each run in their **own Node process** and coordinate via external brokers / their own ports.\n\nRegardless of kind, every trigger populates `ctx.request.{body,headers,params,query,method}`, so the workflow body is structurally identical across triggers \u2014 only the `trigger:` block differs.\n\n---\n\n## 2. THE 9 TRIGGERS\n\nEach trigger below: one-line purpose, USE-WHEN / DON'T, config shape, and a canonical `workflow({...})` example.\n\n### 2.1 HTTP \u2014 `trigger: { http: {...} }`\n\n**Purpose:** Turn a workflow into an inbound HTTP/REST endpoint. Owns the listening server (default port 4000) that sse/websocket/webhook/mcp mount onto.\n\n**USE WHEN:** synchronous request\u2192response; JSON APIs; HTML UI (`accept: \"text/html\"`); file downloads. **DON'T USE FOR:** background jobs (\u2192`worker`), scheduled work (\u2192`cron`), broker events (\u2192`pubsub`), live push (\u2192`sse`/`websocket`), signed callbacks (\u2192`webhook`).\n\n```ts\ntrigger: { http: {\n method: \"GET\"|\"POST\"|\"PUT\"|\"DELETE\"|\"PATCH\"|\"HEAD\"|\"OPTIONS\"|\"ANY\", // required; use \"ANY\" not \"*\"\n path?: string, // optional; omit \u2192 derived from file path\n accept?: string, // default \"application/json\"; \"text/html\" for UI\n headers?: Record<string,string>, // required-headers gate; missing \u2192 400 before any step\n middleware?: string[],\n // shared concurrency/scheduling: concurrencyKey, concurrencyLimit, onLimit, delay, ttl, debounce\n}}\n```\n\n```ts\nimport { workflow, $ } from \"@blokjs/helper\";\n\nexport default workflow({\n name: \"Get User\", version: \"1.0.0\",\n trigger: { http: { method: \"GET\", path: \"/users/:id\" } },\n steps: [\n { id: \"lookup\", use: \"@blokjs/api-call\",\n inputs: { url: \"js/`https://internal/users/${ctx.request.params.id}`\" } },\n { id: \"respond\", use: \"@blokjs/respond\", inputs: { body: $.state.lookup }, ephemeral: true },\n ],\n});\n```\n\n### 2.2 WORKER \u2014 `trigger: { worker: {...} }`\n\n**Purpose:** Consume background jobs from a queue, one workflow run per delivery. Runs in its own Node process. **This is the trigger to use whenever you'd reach for a queue \u2014 `queue` is dead.**\n\n**USE WHEN:** offloading slow/async work; queue consumers; fan-out job processing. **DON'T USE FOR:** synchronous responses (\u2192`http`); time schedules (\u2192`cron`); cloud fan-out topics (\u2192`pubsub`).\n\n```ts\ntrigger: { worker: {\n queue: string, // required \u2014 queue/topic/stream name\n provider?: \"in-memory\"|\"nats\"|\"bullmq\"|\"kafka\"|\"rabbitmq\"|\"sqs\"|\"redis\"|\"pg-boss\", // default in-memory\n concurrency?: number, // default 1 \u2014 concurrent jobs per process\n timeout?: number, // ms \u2014 per-attempt hard timeout\n retries?: number, // default 3 \u2014 then DLQ\n priority?: number, consumerGroup?: string, ack?: boolean,\n deadLetterQueue?: string, fromBeginning?: boolean,\n // shared concurrency/scheduling: concurrencyKey, concurrencyLimit, onLimit, delay, ttl, debounce, middleware\n}}\n```\n\n```ts\nimport { workflow } from \"@blokjs/helper\";\n\nexport default workflow({\n name: \"Process Background Job\", version: \"1.0.0\",\n trigger: { worker: { queue: \"background-jobs\" } },\n steps: [\n { id: \"process-job\", use: \"@blokjs/api-call\", type: \"module\",\n inputs: { url: \"https://example.com/process\", method: \"POST\", body: \"js/ctx.request.body\" } },\n ],\n});\n```\n\n**Worker context mapping:** `ctx.request.body` \u2192 job payload; `ctx.request.params.{queue,jobId,attempt}` \u2192 job metadata; `ctx.vars._worker_job` \u2192 full job record. Producers enqueue with `@blokjs/worker-publish`. Non-`in-memory` providers need their client as a peer dep (`nats`, `bullmq`+`ioredis`, `ioredis`, `@aws-sdk/client-sqs`, `kafkajs`, `amqplib`, `pg-boss`).\n\n### 2.3 CRON \u2014 `trigger: { cron: {...} }`\n\n**Purpose:** Run a workflow on a time schedule (standard cron expression). Dedicated process.\n\n**USE WHEN:** recurring/scheduled work \u2014 nightly cleanup, hourly polls, daily digests, periodic syncs. **DON'T USE FOR:** anything triggered by an external event or request.\n\n```ts\ntrigger: { cron: {\n schedule: string, // required \u2014 \"m h dom mon dow\", e.g. \"0 2 * * *\"\n timezone?: string, // default \"UTC\" \u2014 IANA tz e.g. \"America/New_York\"\n overlap?: boolean, // default false \u2014 allow overlapping executions\n // also: concurrencyKey, concurrencyLimit, middleware\n}}\n```\n\n```ts\nimport { workflow } from \"@blokjs/helper\";\n\nexport default workflow({\n name: \"Daily Cleanup\", version: \"1.0.0\",\n trigger: { cron: { schedule: \"0 2 * * *\", timezone: \"America/New_York\" } },\n steps: [\n { id: \"purge-stale\", use: \"@blokjs/api-call\",\n inputs: { url: \"https://api.example.com/cleanup\", method: \"POST\" } },\n ],\n});\n```\n\n`ctx.request.body` is `{}`; fire metadata is on `ctx.request.params.{schedule,firedAt}`. To serialize overlapping runs use `concurrencyKey: \"self\"`, `concurrencyLimit: 1`.\n\n### 2.4 PUBSUB \u2014 `trigger: { pubsub: {...} }`\n\n**Purpose:** Consume messages from a cloud/broker pub-sub topic, one run per delivery. Dedicated process. Fan-out (1:N) by default; competing-consumer (1-of-N) when `consumerGroup` is set.\n\n**USE WHEN:** cross-service / multi-process event handling over a real broker (GCP Pub/Sub, AWS SNS+SQS, Azure Service Bus, NATS, Redis Streams, Kafka). **DON'T USE FOR:** in-process pub/sub for HTTP+SSE chains (\u2192`sse` bus); plain job queues with competing consumers + retries (\u2192`worker`).\n\n```ts\ntrigger: { pubsub: {\n provider?: \"nats\"|\"redis-streams\"|\"kafka\"|\"gcp\"|\"aws\"|\"azure\", // default BLOK_PUBSUB_ADAPTER\n topic: string, // required \u2014 topic/subject/stream (wildcards ok: \"orders.*.created\")\n subscription?: string, // required for gcp/aws/azure; derived from consumerGroup otherwise\n consumerGroup?: string, // set \u2192 competing-consumer; unset \u2192 fan-out\n durable?: boolean, startFrom?: \"earliest\"|\"latest\"|{seq:number}|{timestamp:number},\n ack?: boolean, // default true\n maxMessages?: number, // default 10\n ackDeadline?: number, // default 30 (s)\n deadLetterTopic?: string, filter?: string,\n}}\n```\n\n```ts\nimport { workflow } from \"@blokjs/helper\";\n\nexport default workflow({\n name: \"On Order Placed\", version: \"1.0.0\",\n trigger: { pubsub: { provider: \"gcp\", topic: \"orders.placed\", subscription: \"fulfillment-svc\" } },\n steps: [\n { id: \"fulfill\", use: \"@blokjs/api-call\",\n idempotencyKey: \"js/ctx.request.params.messageId\", // dedup redeliveries\n inputs: { url: \"https://fulfillment.internal/api/orders\", method: \"POST\", body: \"js/ctx.request.body\" } },\n ],\n});\n```\n\n`messageId` on `ctx.request.params` is the natural `idempotencyKey`. Provider env vars \u2014 GCP: `GOOGLE_APPLICATION_CREDENTIALS`+`PUBSUB_PROJECT_ID`; AWS: standard credential chain (`topic`=SNS ARN, `subscription`=SQS URL); Azure: `AZURE_SERVICEBUS_CONNECTION_STRING`.\n\n### 2.5 SSE \u2014 `trigger: { sse: {...} }`\n\n**Purpose:** One-way server\u2192browser streaming via `EventSource`. Mounts on the shared HTTP port; pumps in-process bus events to connected clients.\n\n**USE WHEN:** pushing live updates one-way \u2014 token streaming from an LLM, progress feeds, notification streams. **DON'T USE FOR:** client\u2192server messages (\u2192`websocket`); one-shot JSON responses (\u2192`http`).\n\n```ts\ntrigger: { sse: {\n path?: string, // URL path; supports :params (e.g. \"/sse/chat/:sessionId\")\n events?: string[], // default [\"*\"]\n channels?: string[],\n maxConnections?: number, // default 10000\n heartbeatInterval?: number, // default 30000 ms\n retryInterval?: number, // default 3000 ms (browser reconnect hint)\n // also: concurrencyKey, concurrencyLimit\n}}\n```\n\n```ts\nimport { workflow } from \"@blokjs/helper\";\n\nexport default workflow({\n name: \"Clock Stream\", version: \"1.0.0\",\n trigger: { sse: { path: \"/sse/clock\", heartbeatInterval: 15000 } },\n steps: [\n { id: \"sub\", use: \"@blokjs/sse-subscribe\", inputs: { channels: [\"clock\"] } },\n { id: \"stream\", use: \"@blokjs/sse-stream\", inputs: { source: \"js/ctx.state.sub\" } },\n ],\n});\n```\n\nA sibling HTTP workflow publishes via `@blokjs/sse-publish`; both share the in-process bus. Cross-process needs a Redis pub/sub backplane.\n\n### 2.6 WEBSOCKET \u2014 `trigger: { websocket: {...} }`\n\n**Purpose:** Bidirectional WS connections; one workflow run per inbound message/lifecycle event. Mounts on the shared HTTP port via Hono `upgradeWebSocket`.\n\n**USE WHEN:** two-way realtime \u2014 chat rooms, live cursors, RPC-over-WS, server-pushed updates the client also writes to. **DON'T USE FOR:** one-way push (\u2192`sse` is cheaper); request/response (\u2192`http`).\n\n```ts\ntrigger: { websocket: {\n path?: string, // URL path; supports :params (e.g. \"/ws/room/:roomId\")\n events?: string[], // default [\"*\"]; supported: \"open\"|\"message\"|\"close\"|\"error\"\n rooms?: string[],\n maxConnections?: number, // default 10000\n heartbeatInterval?: number, // default 30000 ms\n messageRateLimit?: number, // default 100 msgs/sec/client\n // also: concurrencyKey, concurrencyLimit\n}}\n```\n\n```ts\nimport { workflow } from \"@blokjs/helper\";\n\nexport default workflow({\n name: \"WS Echo\", version: \"1.0.0\",\n trigger: { websocket: { path: \"/ws/echo\", events: [\"message\", \"open\", \"close\"] } },\n steps: [\n { id: \"reply\", use: \"@blokjs/ws-reply\",\n inputs: { message: \"js/({ echo: ctx.request.body, at: Date.now() })\" } },\n ],\n});\n```\n\nHelpers: `@blokjs/ws-reply` (this connection), `@blokjs/ws-broadcast` (fan-out), `@blokjs/ws-close`. Cross-process broadcast needs `BLOK_WS_BACKPLANE=redis` + `BLOK_WS_BACKPLANE_REDIS_URL`.\n\n### 2.7 WEBHOOK \u2014 `trigger: { webhook: {...} }`\n\n**Purpose:** Receive signed provider POSTs, verify the HMAC signature, apply replay protection, then dispatch. Mounts on the shared HTTP port.\n\n**USE WHEN:** receiving Stripe / GitHub / Slack / Shopify / Svix callbacks, or any HMAC-signed partner webhook via custom `signature`. **DON'T USE FOR:** unsigned inbound requests (\u2192`http`).\n\n```ts\ntrigger: { webhook: {\n provider?: \"github\"|\"stripe\"|\"slack\"|\"shopify\"|\"svix\", // pick this OR signature, not both\n path?: string, // defaults to /webhooks/<provider>\n secretEnv?: string, // env var name holding the shared secret (never inline the secret)\n events?: string[], // allowlist; out-of-scope \u2192 200 {status:\"ignored\"}\n tolerance?: number, // seconds, default 300 \u2014 clock-skew window\n idempotencyKey?: string, // e.g. \"js/ctx.request.body.id\" \u2014 replay protection\n namespace?: string, // prefix for polymorphic subworkflow dispatch\n middleware?: string[],\n signature?: { // custom HMAC for non-built-in providers\n scheme?: \"hmac-sha256\"|\"hmac-sha1\"|\"hmac-sha512\", // default sha256\n header: string, format?: string, // format default \"{hex}\"; \"{hex}\"/\"{base64}\"\n secretEnv: string, tolerance?: number, timestampHeader?: string,\n },\n}}\n```\n\n```ts\nimport { workflow } from \"@blokjs/helper\";\n\nexport default workflow({\n name: \"Stripe Webhook\", version: \"1.0.0\",\n trigger: { webhook: {\n provider: \"stripe\", namespace: \"stripe\",\n secretEnv: \"STRIPE_WEBHOOK_SECRET\", idempotencyKey: \"js/ctx.request.body.id\",\n }},\n steps: [\n { id: \"dispatch\", subworkflow: \"js/ctx.request.body.type\", // \"invoice.paid\" \u2192 \"stripe.invoice.paid\"\n inputs: { stripeEvent: \"js/ctx.request.body\" } },\n ],\n});\n```\n\nBad signature \u2192 401 with a structured `reason`; duplicate \u2192 200 `{status:\"duplicate\"}`; a workflow throw still returns 200 (senders shouldn't retry). `ctx.request.rawBody` carries the bytes the HMAC was computed against.\n\n### 2.8 MCP \u2014 `trigger: { mcp: {...} }`\n\n**Purpose:** Expose a workflow as an MCP **tool** (default) or **resource** to AI/LLM clients (Cursor, Claude Code). The tool's `inputSchema` is auto-generated from the workflow's Zod `input`. Mounts on the shared HTTP port.\n\n**USE WHEN:** giving an LLM/agent a callable tool or readable resource backed by a workflow. **DON'T USE FOR:** plain HTTP APIs for non-MCP clients (\u2192`http`).\n\n```ts\ntrigger: { mcp: {\n path?: string, // default \"/mcp\"\n serverName?: string, // default \"blok-mcp\"; workflows sharing path+serverName aggregate\n serverVersion?: string, // default \"1.0.0\"\n transports?: (\"sse\"|\"streamable-http\")[], // default both\n tool?: { name?: string, description?: string },\n resource?: { uri: string, name?: string, description?: string, mimeType?: string }, // expose as resource\n middleware?: string[],\n}}\n```\n\n**Requires a workflow-level `input:` Zod schema** \u2014 that becomes the tool's `inputSchema`:\n\n```ts\nimport { workflow, $ } from \"@blokjs/helper\";\nimport { z } from \"zod\";\n\nexport default workflow({\n name: \"search_code\", version: \"1.0.0\",\n input: z.object({ query: z.string(), limit: z.number().optional() }), // \u2192 tool inputSchema\n trigger: { mcp: { path: \"/mcp\", serverName: \"my-platform\",\n tool: { description: \"Full-text search the indexed code\" } } },\n steps: [ { id: \"search\", use: \"@my/search\", inputs: { query: $.req.body.query } } ],\n});\n```\n\nServes over SSE (`GET <path>/sse` + `POST <path>/messages`) and/or Streamable-HTTP (`<path>`).\n\n**Connecting a client** \u2014 the server mounts on the HTTP port (default 4000). Give an MCP client the URL `http://localhost:4000/mcp` (Streamable-HTTP, recommended) or `http://localhost:4000/mcp/sse` (legacy SSE):\n- **Claude Code:** `claude mcp add --transport http blok http://localhost:4000/mcp`\n- **Cursor** (`.cursor/mcp.json`): `{ \"mcpServers\": { \"blok\": { \"url\": \"http://localhost:4000/mcp\" } } }`\n- **Quick test:** `npx @modelcontextprotocol/inspector` \u2192 connect to `http://localhost:4000/mcp`\n\n`tools/call` arguments arrive as `ctx.request.body`; the final step's `ctx.response.data` is returned. Identity via the `x-user-context` header is injection-only, NOT authorization \u2014 scope access yourself.\n\n### 2.9 GRPC \u2014 `trigger: { grpc: {...} }`\n\n**Purpose:** Expose a workflow as a gRPC service method handler \u2014 typed, contract-based RPC. Dedicated process bound to a gRPC port.\n\n**USE WHEN:** high-throughput typed RPC between services with a proto contract; cross-language internal calls. **DON'T USE FOR:** browser-facing or REST APIs (\u2192`http`); async work (\u2192`worker`).\n\n> Caveat: gRPC config is **not Zod-validated** at construction. Author against the documented surface:\n\n```ts\ntrigger: { grpc: {\n service: string, // matches `service Foo {}` in the proto\n method: string, // matches `rpc Bar(...) returns (...)`\n proto: string, // path to the .proto file, relative to the workflow\n port?: number, // default 50051; all grpc workflows share one port (env GRPC_PORT)\n middleware?: string[],\n}}\n```\n\n```ts\nimport { workflow } from \"@blokjs/helper\";\n\nexport default workflow({\n name: \"GetUser\", version: \"1.0.0\",\n trigger: { grpc: { service: \"UserService\", method: \"GetUser\", proto: \"users.proto\" } },\n steps: [\n { id: \"lookup\", use: \"@blokjs/api-call\",\n inputs: { url: \"js/`https://internal/users/${ctx.request.body.userId}`\", method: \"GET\" } },\n ],\n});\n```\n\nThe request decodes into `ctx.request.body`; the final step output becomes the gRPC reply. Streaming RPCs use `@blokjs/grpc-stream`.\n\n> **`trigger.queue` is DEAD** \u2014 it has a schema but no runtime and throws at workflow construction. Use `worker`. **`manual`** has no listener (invoked programmatically only \u2014 tests / sub-workflows); not for normal authoring.\n\n---\n\n## 3. AUTHORING WORKFLOWS (v2 DSL)\n\nImport from `@blokjs/helper`: `{ workflow, $, branch, switchOn, forEach, loop, tryCatch }`. The default export is `workflow({...})` \u2014 a single object literal, no chaining, no separate `nodes{}` map.\n\n```ts\nimport { workflow, $ } from \"@blokjs/helper\";\n\nexport default workflow({\n name: \"Process Order\", // >= 3 chars\n version: \"1.0.0\", // semver x.x.x (>= 5 chars)\n trigger: { http: { method: \"POST\", path: \"/orders\" } }, // path optional \u2192 derived from file path\n steps: [\n { id: \"validate\", use: \"order-validator\", inputs: { order: $.req.body } },\n { id: \"save\", use: \"order-store\", inputs: { data: $.state.validate } },\n ],\n});\n```\n\nA regular step is `{ id, use, inputs }`. `id` is required and unique workflow-wide. `use` is the node reference. `type` is inferred from `use` (in-process `module` by default; `runtime.*` must be set explicitly).\n\n### The four context reads\n\n| Read | Resolves to | Scope |\n|---|---|---|\n| `$.state.<id>` | A prior step's stored output | Whole workflow (cross-step) |\n| `$.prev` | Immediately previous step's output | Adjacent only \u2014 overwritten every step |\n| `$.req` | Request envelope (body/headers/params/query/method/url) | Whole run |\n| `$.error` | Captured error inside a `tryCatch.catch` block | `catch` arm only \u2014 `undefined` elsewhere |\n\n`$.error` exposes `.message`, `.name`, `.stack`, `.code` (upstream HTTP status), and `.stepId`. The `$` proxy compiles to `\"js/ctx.<path>\"` strings at definition time \u2014 in JSON workflows write those strings by hand (`\"$.state.fetch\"` or `\"js/ctx.state.fetch\"`). Legacy aliases still resolve: `$.request`=`$.req`, `$.response`=`$.prev`, `$.vars`=`$.state` \u2014 prefer the canonical four.\n\n### Persistence knobs (per-step, declarative)\n\n| Knob | Effect |\n|---|---|\n| *(none)* | Store at `ctx.state[id]` (the 95% case) |\n| `as: \"name\"` | Store at `ctx.state[name]` instead of `ctx.state[id]` |\n| `spread: true` | Shallow-merge `result.data`'s top-level keys into `ctx.state` (multi-output nodes). Mutually exclusive with `as` |\n| `ephemeral: true` | Skip storage \u2014 only `$.prev` carries it to the next step (logging, audit) |\n\n**Every step's output auto-persists to `ctx.state[id]` \u2014 but ONLY on success.** A step that throws writes nothing, so `ctx.state[<id>] === undefined` is a truthful \"did this step succeed?\" check inside a `tryCatch.catch` arm.\n\n### Control-flow primitives\n\n**`branch({when, then, else})`** \u2014 `when` is a JS-expression *string* (the `$` proxy can't intercept `===`):\n\n```ts\nbranch({ id: \"route\",\n when: '$.req.method === \"POST\"',\n then: [{ id: \"create\", use: \"...\", inputs: {...} }],\n else: [{ id: \"read\", use: \"...\", inputs: {...} }] })\n```\n\n**`switchOn({id, on, cases, default?})`** \u2014 N-way branch, first match wins. `when` may be a scalar (`on === when`) or an array (`array.includes(on)`):\n\n```ts\nswitchOn({ id: \"route-by-event\", on: $.req.headers[\"x-github-event\"],\n cases: [\n { when: \"push\", do: [{ id: \"h1\", subworkflow: \"handle-push\" }] },\n { when: [\"pull_request\", \"pr_review\"], do: [{ id: \"h2\", subworkflow: \"handle-pr\" }] },\n ],\n default: [{ id: \"log\", use: \"@blokjs/log\", inputs: { message: \"unknown\" } }] })\n```\n\n**`forEach({id, in, as, do, mode?, concurrency?})`** \u2014 iterate a collection. Each iteration sets `ctx.state[as]` = item and `ctx.state[<as>Index]` = i; the loop's own slot `$.state[<id>]` is the array of each iteration's last-step output. `mode: \"parallel\"` runs with bounded `concurrency` (default 10):\n\n```ts\nforEach({ id: \"process-items\", in: $.req.body.items, as: \"item\",\n mode: \"parallel\", concurrency: 5,\n do: [{ id: \"reserve\", use: \"inventory-reserve\", inputs: { sku: $.state.item.sku } }] })\n```\n\n**`loop({id, while, do, maxIterations?})`** \u2014 while-loop, hard cap default 1000.\n**`tryCatch({id, try, catch, finally?})`** \u2014 `catch` sees `$.error`; errors in `catch` propagate (don't re-trigger `catch`); `finally` runs unconditionally.\n**`{ id, wait: { for: \"3d\" } | { until: <date> } }`** \u2014 durable pause; cannot combine with `idempotencyKey` or `retry`.\n\n### Caching, retry, sub-workflows (per-step)\n\n```ts\n{ id: \"fetch\", use: \"@blokjs/api-call\", inputs: { url: \"...\" },\n idempotencyKey: $.req.body.requestId, // cache by (workflow, step.id, key); default TTL 24h\n retry: { maxAttempts: 3, minTimeoutInMs: 500, maxTimeoutInMs: 10000, factor: 2 },\n maxDuration: \"30s\" } // per-attempt timeout; final-attempt timeout \u2192 run \"timedOut\"\n```\n\nA cache hit replays the cached result through the same `ephemeral`/`spread`/`as` rules and skips the node entirely. Override TTL with `idempotencyKeyTTL: <ms>` (0 = disabled). Default `maxAttempts: 1` = no retry.\n\n**Sub-workflow as a step:**\n\n```ts\n{ id: \"send-receipt\", subworkflow: \"send-receipt-email\",\n inputs: { user: $.state.user }, // becomes child's ctx.request.body (read via $.req.body)\n wait: true } // default: parent blocks, child response lands at state[id]\n```\n\n`wait: false` = fire-and-forget, returns `{runId, workflowName, scheduledAt}`. `subworkflow:` also accepts a `$.<path>`/`js/...` expression for polymorphic dispatch \u2014 pair with `allowList: [...]` whenever it depends on caller data. Recursion capped at 10 (`BLOK_MAX_SUBWORKFLOW_DEPTH`).\n\n### JSON workflows\n\nJSON mirrors the TS DSL one-for-one. Reference earlier outputs as `\"$.state.<id>\"` strings; use `\"ANY\"` for the wildcard method; a branch is one step with `branch: { when, then, else }`. JSON workflows live under `src/workflows/json/` (scanned recursively).\n\n---\n\n## 4. TRIGGER-LEVEL OPTIONS (across kinds)\n\nThese live on the **trigger config**, never on a step. They gate workflow entry.\n\n**Per-key concurrency gating** \u2014 `concurrencyKey` (+ optional `concurrencyLimit` default 1, `onLimit: \"throw\"|\"queue\"`):\n\n```ts\ntrigger: { http: { method: \"POST\", path: \"/render\",\n concurrencyKey: $.req.body.tenantId, concurrencyLimit: 5, onLimit: \"queue\" } }\n```\n\n`concurrencyLimit`/`onLimit`/`concurrencyLeaseMs` all require `concurrencyKey`. Denial \u2192 HTTP 429 + `Retry-After` (or 202 with `onLimit: \"queue\"`).\n\n**Scheduling** \u2014 `delay`, `ttl`, `debounce`. Durations are a number (ms) or a unit string (`\"500ms\"`,`\"30s\"`,`\"5m\"`,`\"2h\"`,`\"1d\"`):\n\n```ts\ntrigger: { http: { method: \"POST\", path: \"/welcome\", delay: \"1h\", ttl: \"2h\" } }\ntrigger: { http: { method: \"POST\", path: \"/save/:docId\",\n debounce: { key: $.req.params.docId, mode: \"trailing\", delay: \"500ms\", maxDelay: \"5s\" } } }\n```\n\nFor HTTP, `ttl` requires `delay`. Debounce modes: `trailing` (default \u2014 fire after silence) / `leading` (fire first, suppress follow-ups).\n\n**Middleware** \u2014 two forms:\n\n1. *Trigger-level chain* \u2014 ordered middleware-workflow names, run before the body on the same ctx:\n ```ts\n trigger: { http: { method: \"GET\", middleware: [\"auth-check\", \"request-id\"] } }\n ```\n2. *Defining a middleware workflow* \u2014 `workflow({ middleware: true })`. `trigger` becomes optional; it gets no public route and is referenced by `name`:\n ```ts\n export default workflow({ name: \"auth-check\", version: \"1.0.0\", middleware: true,\n steps: [ /* sets ctx.state.identity; may stop:true to short-circuit */ ] });\n ```\n\nProcess-global middleware: `WorkflowRegistry.getInstance().setGlobalMiddleware([...])` or `BLOK_GLOBAL_MIDDLEWARE=a,b`.\n\n---\n\n## 5. AUTHORING NODES\n\n### 5.1 defineNode (TypeScript, in-process)\n\nAlways `export default defineNode(...)`. Never class-based `BlokService`. Zod input/output are mandatory.\n\n```ts\nimport { defineNode } from \"@blokjs/core\";\nimport { z } from \"zod\";\n\nexport default defineNode({\n name: \"fetch-user\",\n description: \"Fetches a user by ID\",\n input: z.object({ userId: z.string().uuid() }), // validated BEFORE execute\n output: z.object({ user: z.object({ id: z.string(), name: z.string() }) }), // validated AFTER\n async execute(ctx, input) {\n const user = await fetchUser(input.userId); // input is type-safe\n return { user }; // MUST match the output schema\n },\n});\n```\n\n- **Errors:** input failure \u2192 `GlobalError` code **400**; a plain `Error` thrown in `execute` \u2192 code **500**; a `GlobalError` you throw is preserved verbatim (custom codes like 401 survive).\n- **Never write `ctx.state` from a node** \u2014 return your output and let the runner persist it. For a genuine side-channel value, use `ctx.publish(name, value)`.\n- No `any` types \u2014 use `z.unknown()` and narrow.\n- `flow: true` nodes return `NodeBase[]`; `contentType: \"text/html\"` sets the response Content-Type.\n\nTypeScript nodes live in `src/nodes/` and are referenced by `use: \"<name>\"` (no `type` needed \u2014 `module` is the default).\n\n### 5.2 Nodes in other runtimes (gRPC sidecars)\n\nThe 7 non-TS runtimes run as long-lived gRPC sidecar processes; the TypeScript runner is the client. A step routes to a sidecar with **`type: \"runtime.<lang>\"`** and `use:` = the registered node name. The step's resolved `inputs` arrive as the node's config / typed input (NOT `ctx.request.body` \u2014 that holds the original trigger payload). The node's return value lands in `ctx.state[<step-id>]`.\n\n**Runtime nodes live in `runtimes/<lang>/nodes/`** and require that runtime to be scaffolded. Add a runtime with `blokctl runtime add <lang>` (or `blokctl create <project> --runtimes go,python3,...` at create time). Scaffold a node with `blokctl create node <name> --runtime <lang>`. Across all runtimes:\n\n- The runner speaks **gRPC only** (the legacy HTTP `/execute` path was removed in v0.5).\n- gRPC dispatch port = legacy HTTP port + 1000. **Dispatch ports:** go `10001`, rust `10002`, java `10003`, csharp `10004`, php `10005`, ruby `10006`, python3 `10007`. (Readiness/health HTTP ports are the legacy `9001`\u2013`9007`; the CLI readiness check is a **TCP connect to the gRPC port**, not `GET /health`.)\n- `blokctl dev` sets `BLOK_TRANSPORT=grpc` + `GRPC_PORT` for each sidecar. Most SDKs default to HTTP transport if you launch them by hand \u2014 always let `blokctl dev` (or the env) set gRPC, or the runner can't reach the node.\n- Generated proto stubs ship with each SDK \u2014 you do **not** regenerate them to author a node.\n- Each SDK has a **typed** contract (the equivalent of `defineNode` \u2014 validated input, typed output, reflected JSON Schema) and a lower-level untyped contract. **Prefer the typed contract.** Bad input auto-fails with `NODE_INPUT_VALIDATION` / HTTP 400 before your code runs.\n- gRPC message cap defaults to 16 MiB (`BLOK_GRPC_MAX_MESSAGE_BYTES`).\n- Don't edit `.blok/runtimes/` \u2014 those are generated copies.\n\nThe workflow step is identical regardless of runtime \u2014 only `type` changes:\n\n```ts\n{ id: \"sum\", use: \"add-numbers\", type: \"runtime.<lang>\", inputs: { a: $.req.body.a, b: $.req.body.b } }\n```\n\n#### Authoring a node in go\n\n`runtimes/go/nodes/addnumbers.go` \u2014 typed via `blok.DefineNode`:\n\n```go\npackage nodes\n\nimport blok \"github.com/nickincloud/blok-go\"\n\ntype AddNumbersInput struct {\n A int `json:\"a\"`\n B int `json:\"b\"`\n}\ntype AddNumbersOutput struct {\n Sum int `json:\"sum\"`\n}\n\nconst AddNumbersNodeName = \"add-numbers\"\n\nvar AddNumbersNode = blok.DefineNode(AddNumbersNodeName, \"Adds two integers\",\n func(_ *blok.Context, in AddNumbersInput) (AddNumbersOutput, error) {\n return AddNumbersOutput{Sum: in.A + in.B}, nil\n })\n```\n\nRegister in `runtimes/go/cmd/server/main.go`:\n\n```go\nfunc main() {\n registry := blok.NewNodeRegistry()\n registry.Register(nodes.AddNumbersNodeName, nodes.AddNumbersNode)\n registry.Use(blok.RecoveryMiddleware(), blok.LoggingMiddleware(blok.NewLogger(blok.LogLevelInfo)))\n if err := blok.ListenAndServe(registry); err != nil { log.Fatalf(\"Server error: %v\", err) }\n}\n```\n\nWorkflow step: `{ id: \"sum\", use: \"add-numbers\", type: \"runtime.go\", inputs: { a: $.req.body.a, b: $.req.body.b } }`. Errors: return a non-nil `error`, or use `blok.NewValidationError` / `blok.NewError(category)...Build()` for structured `BlokError`. Toolchain: Go 1.24+, `go mod download`, `go run ./cmd/server`.\n\n#### Authoring a node in rust\n\n`runtimes/rust/nodes/add-numbers/src/main.rs` \u2014 typed via the `TypedNode` trait:\n\n```rust\nuse async_trait::async_trait;\nuse blok::{BlokError, Context, NodeRegistry, TypedNode};\nuse schemars::JsonSchema;\nuse serde::{Deserialize, Serialize};\n\n#[derive(Deserialize, JsonSchema)]\nstruct AddInput { a: f64, b: f64 }\n\n#[derive(Serialize, JsonSchema)]\nstruct AddOutput { sum: f64 }\n\nstruct AddNumbers;\n\n#[async_trait]\nimpl TypedNode for AddNumbers {\n type Input = AddInput;\n type Output = AddOutput;\n fn name(&self) -> &str { \"add-numbers\" }\n fn description(&self) -> &str { \"Adds two numbers\" }\n async fn run(&self, _ctx: &mut Context, input: AddInput) -> Result<AddOutput, BlokError> {\n Ok(AddOutput { sum: input.a + input.b })\n }\n}\n\n#[tokio::main]\nasync fn main() {\n let mut registry = NodeRegistry::new(\"1.0.0\");\n registry.register_typed(AddNumbers); // typed nodes register via register_typed\n blok::server::serve(registry, 9002).await.unwrap();\n}\n```\n\nWorkflow step: `type: \"runtime.rust\"`. `Input`/`Output` must derive `serde` + `schemars::JsonSchema`. Errors: `BlokError::validation()/.dependency()/...build()`. Toolchain: `cargo build --release` / `cargo run`; gRPC is feature-gated \u2014 build with the `grpc` feature (or `--features full`) so the runner can dispatch.\n\n#### Authoring a node in java\n\n`runtimes/java/src/main/java/com/blok/blok/nodes/AddNumbersNode.java` \u2014 typed via `TypedNode<I, O>`:\n\n```java\npackage com.blok.blok.nodes;\n\nimport com.blok.blok.node.TypedNode;\nimport com.blok.blok.types.Context;\n\npublic final class AddNumbersNode extends TypedNode<AddNumbersNode.Input, AddNumbersNode.Output> {\n public record Input(int a, int b) {}\n public record Output(int sum) {}\n\n @Override public String name() { return \"add-numbers\"; }\n @Override public String description() { return \"Adds two integers\"; }\n @Override protected Class<Input> inputClass() { return Input.class; }\n @Override protected Class<?> outputClass() { return Output.class; }\n\n @Override protected Output run(Context ctx, Input input) {\n return new Output(input.a() + input.b());\n }\n}\n```\n\nRegister in `runtimes/java/src/main/java/com/blok/blok/Main.java`:\n\n```java\nNodeRegistry registry = new NodeRegistry();\nregistry.register(\"add-numbers\", new com.blok.blok.nodes.AddNumbersNode());\nregistry.use(new RecoveryMiddleware());\nregistry.use(new LoggingMiddleware(logger));\n```\n\nWorkflow step: `type: \"runtime.java\"`. Errors: `throw BlokError.validation().code(...).message(...).build();`. Primitive record components (`int`, `boolean`) are required in the reflected schema; boxed types are optional. Toolchain: JDK 17+ and Maven, `mvn package -q -DskipTests`, `java -jar target/blok-java-1.0.0.jar`.\n\n#### Authoring a node in csharp\n\n`runtimes/csharp/Nodes/AddNumbersNode.cs` \u2014 typed via `TypedNode<TInput, TOutput>`:\n\n```csharp\nusing System.ComponentModel.DataAnnotations;\nusing Blok.Core.Node;\nusing Blok.Core.Types;\n\nnamespace Blok.Runtime.Nodes;\n\npublic sealed record AddNumbersInput([property: Required] double A, [property: Required] double B);\npublic sealed record AddNumbersOutput(double Sum);\n\npublic sealed class AddNumbersNode : TypedNode<AddNumbersInput, AddNumbersOutput>\n{\n public override string Name => \"add-numbers\";\n public override string Description => \"Adds two numbers\";\n public override Task<AddNumbersOutput> RunAsync(Context ctx, AddNumbersInput input)\n => Task.FromResult(new AddNumbersOutput(input.A + input.B));\n}\n```\n\nRegister in `runtimes/csharp/Program.cs`:\n\n```csharp\nvar config = ServerConfig.FromEnv();\nvar registry = new NodeRegistry(config.Version);\nregistry.Register(\"add-numbers\", new AddNumbersNode());\nawait RuntimeServer.Run(registry, config);\n```\n\nWorkflow step: `type: \"runtime.csharp\"`. The wire is **camelCase** (`{ \"a\": 2, \"b\": 3 }` maps to `A`/`B`). Errors: `throw BlokError`. Toolchain: .NET 8.0+, `dotnet restore`, `dotnet run`.\n\n#### Authoring a node in php\n\n`runtimes/php/nodes/add-numbers/src/Nodes/AddNumbersNode.php` \u2014 typed via `TypedNode` (or plain `NodeHandler`):\n\n```php\n<?php\ndeclare(strict_types=1);\nnamespace Blok\\Nodes;\n\nuse Blok\\Blok\\Node\\TypedNode;\nuse Blok\\Blok\\Types\\Context;\n\nfinal class AddNumbersInput\n{\n public function __construct(public int $a, public int $b) {}\n}\n\nfinal class AddNumbersNode extends TypedNode\n{\n public function name(): string { return 'add-numbers'; }\n public function description(): string { return 'Adds two integers'; }\n protected function inputClass(): string { return AddNumbersInput::class; }\n\n protected function run(Context $ctx, object $input): mixed\n {\n /** @var AddNumbersInput $input */\n return ['sum' => $input->a + $input->b];\n }\n}\n```\n\nRegister in `runtimes/php/bin/serve.php`:\n\n```php\n$config = ServerConfig::fromEnv();\n$registry = new NodeRegistry($config->version);\n$registry->register('add-numbers', new AddNumbersNode());\n// ... wire $registry into BlokNodeRuntimeService + RoadRunner GrpcServer and $server->serve();\n```\n\nWorkflow step: `type: \"runtime.php\"`. The gRPC server is RoadRunner (`rr serve -c .rr.yaml`), which `blokctl dev` runs. Imports are `Blok\\Blok\\Node\\NodeHandler` and `Blok\\Blok\\Types\\Context`. Toolchain: PHP 8.2+, Composer, RoadRunner; `composer install`.\n\n#### Authoring a node in ruby\n\n`runtimes/ruby/nodes/add_numbers_node.rb` \u2014 typed via `Blok::Node::TypedNode`:\n\n```ruby\n# frozen_string_literal: true\nrequire \"blok\"\n\nclass AddNumbersNode < Blok::Node::TypedNode\n node_name \"add-numbers\"\n description \"Adds two numbers and returns their sum\"\n\n input do\n field :a, :number, required: true\n field :b, :number, required: true\n end\n output { field :sum, :number }\n\n def run(_ctx, input)\n { \"sum\" => input[:a] + input[:b] } # string-keyed Hash is idiomatic\n end\nend\n```\n\nRegister in `runtimes/ruby/bin/serve.rb`:\n\n```ruby\nrequire_relative \"../nodes/add_numbers_node\"\n\nconfig = Blok::Config::ServerConfig.from_env\nregistry = Blok::Node::NodeRegistry.new(config.version)\nregistry.register(\"add-numbers\", AddNumbersNode.new) # name MUST equal node_name\nregistry.use(Blok::Middleware::RecoveryMiddleware.new)\n# serve.rb routes to start_grpc under BLOK_TRANSPORT=grpc\n```\n\nWorkflow step: `type: \"runtime.ruby\"`. Field types: `:string, :integer, :number, :boolean, :array, :object`. Errors: `raise Blok::Errors::BlokError.validation(...)`. Toolchain: Ruby 3.2+, Bundler; `bundle install`.\n\n#### Authoring a node in python3\n\n`runtimes/python3/nodes/add_numbers/node.py` \u2014 typed via the `@node` decorator (Pydantic):\n\n```python\nfrom __future__ import annotations\nfrom pydantic import BaseModel, Field\nfrom blok import node, Context\n\n\nclass AddNumbersInput(BaseModel):\n a: float\n b: float = Field(0)\n\n\nclass AddNumbersOutput(BaseModel):\n sum: float\n\n\n@node(\"add-numbers\", \"Adds two numbers and returns their sum\")\ndef add_numbers(ctx: Context, input: AddNumbersInput) -> AddNumbersOutput:\n return AddNumbersOutput(sum=input.a + input.b)\n```\n\nRegistration is **manual** \u2014 importing the module runs the `@node` decorator; then flush with `register_decorated`. In `runtimes/python3/nodes/__init__.py`:\n\n```python\nfrom blok import register_decorated\nfrom . import add_numbers # noqa: F401 (runs the @node decorator)\n\ndef register_project_nodes(registry):\n return register_decorated(registry)\n```\n\n\u2026and call `register_project_nodes(registry)` from the boot path (after the SDK's `register_all(registry)`). Workflow step: `type: \"runtime.python3\"`; `use:` must match the **string in `@node(\"name\", ...)`**, not the function name. Errors: `raise BlokError.validation(...)` (or `.dependency`, `.not_found`, \u2026). Toolchain: Python 3, `pip3 install -r requirements.txt`; `@node` requires `pydantic`.\n\n> The legacy `BlokService` / `async def handle()` / `from core.blok import BlokService` Python shape **does not exist** in this SDK \u2014 ignore any example that uses it. Use `@node` (or the `NodeHandler` ABC).\n\n---\n\n## 6. RUNNING LOCALLY / INFRA\n\n```bash\nblokctl dev # full dev server: spawns selected runtimes + the runner\nblokctl create node <name> --runtime <lang> # scaffold a node (ts default; pass --runtime for sidecars)\nblokctl runtime add <lang> # add a non-TS runtime to an existing project\nblokctl trace # open Blok Studio (run traces at /__blok)\n```\n\nThe `http`/`sse`/`websocket`/`webhook`/`mcp` triggers need no external infra \u2014 they share the HTTP server. The cross-process triggers (`worker`, `pubsub`) need a broker.\n\n**For worker/pubsub, start the broker stack** with the dev compose (Redis + NATS + Postgres/Adminer):\n\n```bash\ncd infra/development && docker compose up -d nats # or: redis redis-commander\n```\n\nThe default worker adapter is `in-memory` (zero infra) \u2014 only start a broker when you set a real provider (`nats`, `redis`, `bullmq`, \u2026). Monitoring UIs from the dev compose: Adminer `:8080`, Redis Commander `:8081`, NATS monitor `:8222`. The compose declares an external `shared-network` \u2014 if the first run fails, run `docker network create shared-network`.\n\n**For Kafka / RabbitMQ / SQS / GCP-Pub/Sub emulators**, use `infra/testing/docker-compose.yml` instead \u2014 those brokers are wired there on non-standard ports with emulators, matching the provider env blocks the scaffold writes.\n\n---\n\n## 7. FOOTGUN LIST (read before authoring)\n\n1. **Never reuse a step `id`** \u2014 anywhere, including across mutually-exclusive `switch`/`branch`/`tryCatch` arms. All ids share one flat per-workflow config map; duplicates collide (last definition wins) and the matched arm silently runs with the *other* arm's inputs. If two arms must write the same downstream key, give them distinct ids and use `as: \"shared\"`.\n2. **Don't prefix `@blokjs/expr`'s `expression` input with `js/`** \u2014 that input is itself mapper-resolved, so `js/...` double-evaluates. Write plain JS: `expression: \"ctx.state.x.y\"`.\n3. **`set_var` was removed in v0.5** \u2014 the runner throws at load time if present. Drop `set_var: true` (default-store handles it); replace `set_var: false` with `ephemeral: true`.\n4. **Use `\"ANY\"`, not `\"*\"`, for the wildcard HTTP method** \u2014 `\"*\"` is accepted but warns and is auto-normalized.\n5. **`trigger.queue` is rejected at construction** \u2014 it has no runtime and would silently never run. Use `trigger.worker` (`{ worker: { queue: \"<name>\" } }`).\n6. **Workflow envelope minimums:** `name` >= 3 chars, `version` >= 5 chars (semver), `steps` must be non-empty, and a `trigger` is required unless `middleware: true`.\n7. **Every v2 step schema is `.strict()`** \u2014 a misspelled or unknown field throws at load time, not silently dropped. A trigger-only field placed on a step (`concurrencyKey`, `delay`, `ttl`, `debounce`, `concurrencyLimit`) gets a targeted error pointing you to the trigger config.\n8. **`as` and `spread` are mutually exclusive** \u2014 pick one.\n9. **`$.prev` is volatile** (only the previous step). For any non-adjacent read use `$.state.<id>`. Reading `$.state.<id>` for a step that set `ephemeral: true` returns `undefined`.\n10. **Sub-workflow `idempotencyKey` with `wait: true` caches the WHOLE child result** \u2014 a cache hit means the child (and its side effects: emails, charges) never runs. Headline pattern AND primary footgun.\n\nPlus the cross-runtime rules: **the wrong input source** (typed sidecar nodes read their step `inputs`, NOT `ctx.request.body`), **registration is explicit** for every runtime (a file in `runtimes/<lang>/nodes/` does nothing until you register it by name), and **`type: \"runtime.<lang>\"` is required** on the step or it defaults to the in-process TS path and fails with `Node type X not found`.\n\n**Production env knob worth naming:** `BLOK_MAPPER_MODE=strict` \u2014 fail-fast on `js/...` input resolution errors instead of silently passing the literal string through. Strongly recommended for production.\n\n---\n\n## 8. TESTING\n\nUse the `@blokjs/runner` testing utilities with Vitest.\n\n**Unit-test a node** with `NodeTestHarness`:\n\n```ts\nimport { NodeTestHarness } from \"@blokjs/runner\";\nimport myNode from \"../src/nodes/my-node\";\n\nconst harness = new NodeTestHarness(myNode);\nconst result = await harness.execute({ userId: \"abc-123\" });\nharness.assertSuccess(result);\nharness.assertOutput(result, { user: { id: \"abc-123\" } });\n```\n\n**Integration-test a workflow** with `WorkflowTestRunner`:\n\n```ts\nimport { WorkflowTestRunner } from \"@blokjs/runner\";\n\nconst runner = new WorkflowTestRunner({ verbose: true, mockAllNodes: true });\nrunner.registerNode(\"validate\", ValidateNode);\nrunner.mockNode(\"external-api\", async (input) => ({ result: \"mocked\" }));\nrunner.loadWorkflow(myWorkflowDefinition);\nconst result = await runner.execute({ input: \"data\" });\nexpect(result.success).toBe(true);\n```\n\n---\n\n## Do / Do NOT\n\n**Do:**\n- Read `.blok/config.json` and existing `src/workflows/` to learn which triggers + runtimes this project uses; author for those.\n- Start every workflow from the **trigger decision table** in \u00A71, not from HTTP.\n- Use `workflow({ name, version, trigger, steps })` from `@blokjs/helper`.\n- Use the typed node contract in every runtime (`defineNode` / `DefineNode` / `TypedNode` / `@node`).\n- Reference cross-step outputs with `$.state.<id>`; use `as:`/`spread:`/`ephemeral:` to shape persistence.\n- Set `type: \"runtime.<lang>\"` on every sidecar step and register the node by name.\n\n**Do NOT:**\n- Default to the HTTP trigger because it's familiar \u2014 pick by intent.\n- Emit `trigger.queue` \u2014 it throws; use `worker`.\n- Use `\"*\"` for the wildcard method (use `\"ANY\"`), or `set_var` (removed in v0.5).\n- Write class-based `BlokService` nodes, or the stale Python `BlokService`/`async def handle()` shape.\n- Write to `ctx.state` inside a node \u2014 return your output (or `ctx.publish(...)` for a side-channel value).\n- Reuse a step `id`, combine `as` + `spread`, or use `any` types.\n- Read a typed sidecar node's data from `ctx.request.body` \u2014 read the step `inputs` / typed input.\n- Edit files under `.blok/runtimes/` \u2014 they are generated.\n";
26
+ declare const agents_md = "\n# AGENTS.md \u2014 Blok Framework AI Context\n\nBlok is a **multi-trigger, multi-runtime workflow framework**. A workflow is a declarative list of steps; each step runs a node; the runner resolves data between steps and persists state. Two facts shape everything you author here:\n\n- **HTTP is ONE of 9 triggers, NOT the default.** Every workflow declares exactly one trigger. Picking `http` reflexively is the most common mistake \u2014 start with the decision table below.\n- **Nodes can be written in 8 runtimes.** TypeScript runs in-process; the other 7 (`go`, `rust`, `java`, `csharp`, `php`, `ruby`, `python3`) run as gRPC sidecar processes. A step routes to a sidecar via `type: \"runtime.<lang>\"`.\n\nThe 9 trigger types: `http`, `worker`, `cron`, `pubsub`, `sse`, `websocket`, `webhook`, `mcp`, `grpc`.\nThe 8 runtimes: `typescript` (in-process), `go`, `rust`, `java`, `csharp`, `php`, `ruby`, `python3`.\n\nThe canonical TypeScript form is the **typed-handle DSL** from `@blokjs/core`: `workflow(name, { version, trigger }, (entry) => { ... })`, where each `step()` returns a typed handle you reference directly \u2014 **no `$`, no `js/`, no raw `ctx` strings.** The object-style `workflow({ name, version, trigger, steps: [...] })` from `@blokjs/helper` and JSON workflows are equivalent (all three compile to the same IR) and remain fully supported. The same shape works for all 9 triggers \u2014 only the `trigger:` block changes.\n\n---\n\n## 0. THE TYPED-HANDLE DSL (`@blokjs/core`) \u2014 preferred for TypeScript\n\n```ts\nimport { workflow, step, branch, forEach, switchOn, tryCatch, http, tpl, gt } from \"@blokjs/core\";\n\nexport default workflow(\"order-intake\", { version: \"1.0.0\", trigger: http.post(\"/orders\") }, (req) => {\n // step(id, node, inputs) \u2192 a TYPED handle shaped like the node's output.\n const order = step(\"validate\", validateOrder, { qty: req.body.qty });\n\n // Reference a handle field anywhere downstream \u2014 it records a ref the runner resolves.\n step(\"summary\", summarize, { line: tpl`order of ${order.qty} item(s)` });\n\n // Control flow: a comparator (gt/eq/lt/...) builds the condition; arms are callbacks.\n branch(\"lane\", gt(order.qty, 10), {\n then: () => { step(\"bulk\", routeOrder, { lane: \"bulk\" }); },\n else: () => { step(\"standard\", routeOrder, { lane: \"standard\" }); },\n });\n});\n```\n\n**Trigger config:** `http.{get,post,put,delete,patch,any}(path?, opts?)` for HTTP (omit `path` for file-based routing). For any other trigger pass the raw block as `trigger:` \u2014 e.g. `{ worker: { queue: \"jobs\" } }`, `{ cron: { schedule: \"0 2 * * *\" } }`, `{ webhook: { source: \"stripe\" } }`.\n\n**The entry handle** (the callback's argument) is the trigger payload \u2014 typed and conventionally named per trigger: `http`\u2192`req`, `webhook`\u2192`event`, `cron`\u2192`tick`, `worker`\u2192`job`, `pubsub`\u2192`msg`, `grpc`\u2192`rpc` (others get a loose handle). Read `req.body`, `req.params.id`, `req.query.q`, `req.headers[\"x-\u2026\"]`.\n\n**Handles & persistence:**\n- `const h = step(\"id\", node, inputs)` \u2014 every step auto-persists to `ctx.state[\"id\"]` on success; `h` / `h.field` is how you reference it later (never `$.state.id`).\n- 4th arg `opts`: `{ as: \"name\" }` roots the handle at `state[\"name\"]`; `{ spread: true }` flattens `result.data` into state (mutually exclusive with `as`); `{ ephemeral: true }` skips persistence (handle then unreadable downstream); also `idempotencyKey`, `retry`, `maxDuration`, `type: \"runtime.<lang>\"`.\n- `tpl`\u2026${h.field}\u2026`` builds a string with embedded handle refs.\n- Comparators (typed): `eq, ne, gt, gte, lt, lte, not`. A bare boolean handle is a truthiness check.\n- `defineNode` is re-exported from `@blokjs/core` \u2014 author nodes exactly as before; just RETURN output (never write `ctx.state`/`ctx.vars`).\n\n**Control-flow primitives** (all from `@blokjs/core`):\n- `branch(id, cond, { then: () => {\u2026}, else?: () => {\u2026} })` \u2014 `cond` is a comparator or a boolean handle.\n- `forEach(iterable, (item, index) => { step(\u2026) }, { as?, mode?, concurrency? })` \u2014 `iterable` is a handle; `item`/`index` are per-iteration handles; `mode: \"parallel\"` bounds with `concurrency` (default 10).\n- `switchOn(discriminant, { cases: [{ when: \"push\", do: () => {\u2026} }], default?: () => {\u2026} }, { id })` \u2014 `when` labels are STATIC literals (a handle never matches).\n- `tryCatch(id, { try: () => {\u2026}, catch: (error) => { step(\"log\", logger, { msg: error.message }); }, finally?: () => {\u2026} })` \u2014 `error` is a typed handle (`.message`/`.name`/`.stack`/`.code`/`.stepId`).\n\n**The four footguns** (full detail: `docs/d/primitives/handles-and-footguns.mdx`):\n1. **Arm-scoped handles don't escape their arm.** A handle minted inside a `branch`/`switchOn`/`tryCatch`/`forEach` arm is unreadable outside it. Return a value from the control-flow step, or have both arms write one `as:` key.\n2. **Ephemeral handles are unreadable.** Reading an `{ ephemeral: true }` step's handle resolves to `undefined`.\n3. **Never reuse a step `id`** \u2014 including across mutually-exclusive arms. Ids are a flat per-workflow map; the runner throws at load time. Use `as:` when two arms must write the same downstream key.\n4. **Never name a `forEach` `as:`/`asIndex` after an existing step id** (or another loop's `as`) \u2014 they share `ctx.state`; the runner throws at load time.\n\n---\n\n## 1. CHOOSING A TRIGGER (do this first, every time)\n\n**Before writing `trigger: { http: ... }`, read this table and pick by intent.**\n\n| Intent / what you're building | Trigger | Why NOT http |\n|---|---|---|\n| Respond to an HTTP/REST request; JSON API; HTML page; file download | **`http`** | \u2014 |\n| Process a background / queued / async job; offload slow work | **`worker`** | http blocks the caller; jobs need a queue + retries + DLQ |\n| Run on a schedule / recurring time-based job (nightly, hourly, cron) | **`cron`** | http only fires on a request; nothing calls it on a timer |\n| React to messages on a cloud topic/subscription (cross-service events) | **`pubsub`** | http isn't subscribed to a broker; events would be dropped |\n| Stream / push live updates one-way to a browser (tokens, progress, feed) | **`sse`** | a plain http response is one-shot; it can't keep pushing |\n| Bidirectional realtime (chat rooms, live cursors, client\u2194server messages) | **`websocket`** | http is half-duplex request/response, no server push back-channel |\n| Receive a signed provider webhook (Stripe / GitHub / Slack / Shopify / Svix / custom HMAC) | **`webhook`** | http won't verify the HMAC signature or do replay protection |\n| Expose a workflow as a tool/resource to an AI/LLM client (Cursor, Claude) | **`mcp`** | http isn't MCP; the client can't discover or call it as a tool |\n| High-throughput typed RPC between services with a proto contract | **`grpc`** | http/REST overhead is too high; no typed contract |\n\n**Tie-breakers:**\n- **One-way stream \u2192 `sse`; two-way \u2192 `websocket`.** SSE is cheaper and simpler; reach for `websocket` only when the client must send messages back over the same connection.\n- **In-process pub/sub (single Node process, HTTP+SSE chains) \u2192 the `sse` bus, NOT `pubsub`.** `pubsub` is the multi-process / multi-cloud sibling backed by an external broker.\n- **Queue consumer \u2192 `worker`, never `queue`.** `trigger.queue` is **DEAD** \u2014 it has a schema but no runtime and throws at workflow construction time. Always use `worker` (`{ worker: { queue: \"<name>\" } }`).\n\n### Read `.blok/config.json` first\n\nThe project records which triggers and runtimes were actually scaffolded in **`.blok/config.json`**. **Author for those \u2014 do not assume HTTP.** If the project was scaffolded with the worker trigger and the Go runtime, the user almost certainly wants a worker workflow and/or a Go node, not an HTTP endpoint. When in doubt, read that file and match the existing workflows under `src/workflows/`.\n\n### Same-port vs cross-process families\n\n- **Same-port family** \u2014 `http`, `sse`, `websocket`, `webhook`, `mcp` all mount on the **same Hono HTTP server / port** (default 4000) and share an in-process event bus.\n- **Cross-process family** \u2014 `worker`, `cron`, `pubsub`, `grpc` each run in their **own Node process** and coordinate via external brokers / their own ports.\n\nRegardless of kind, every trigger populates `ctx.request.{body,headers,params,query,method}`, so the workflow body is structurally identical across triggers \u2014 only the `trigger:` block differs.\n\n---\n\n## 2. THE 9 TRIGGERS\n\nEach trigger below: one-line purpose, USE-WHEN / DON'T, config shape, and a canonical typed-handle `workflow(name, { trigger }, (entry) => { ... })` example.\n\n### 2.1 HTTP \u2014 `trigger: { http: {...} }`\n\n**Purpose:** Turn a workflow into an inbound HTTP/REST endpoint. Owns the listening server (default port 4000) that sse/websocket/webhook/mcp mount onto.\n\n**USE WHEN:** synchronous request\u2192response; JSON APIs; HTML UI (`accept: \"text/html\"`); file downloads. **DON'T USE FOR:** background jobs (\u2192`worker`), scheduled work (\u2192`cron`), broker events (\u2192`pubsub`), live push (\u2192`sse`/`websocket`), signed callbacks (\u2192`webhook`).\n\n```ts\ntrigger: { http: {\n method: \"GET\"|\"POST\"|\"PUT\"|\"DELETE\"|\"PATCH\"|\"HEAD\"|\"OPTIONS\"|\"ANY\", // required; use \"ANY\" not \"*\"\n path?: string, // optional; omit \u2192 derived from file path\n accept?: string, // default \"application/json\"; \"text/html\" for UI\n headers?: Record<string,string>, // required-headers gate; missing \u2192 400 before any step\n middleware?: string[],\n // shared concurrency/scheduling: concurrencyKey, concurrencyLimit, onLimit, delay, ttl, debounce\n}}\n```\n\n```ts\nimport { http, node, step, tpl, workflow } from \"@blokjs/core\";\n\nexport default workflow(\"Get User\", { version: \"1.0.0\", trigger: http.get(\"/users/:id\") }, (req) => {\n const user = step(\"lookup\", node(\"@blokjs/api-call\"), { url: tpl`https://internal/users/${req.params.id}` });\n step(\"respond\", node(\"@blokjs/respond\"), { body: user }, { ephemeral: true });\n});\n```\n\n### 2.2 WORKER \u2014 `trigger: { worker: {...} }`\n\n**Purpose:** Consume background jobs from a queue, one workflow run per delivery. Runs in its own Node process. **This is the trigger to use whenever you'd reach for a queue \u2014 `queue` is dead.**\n\n**USE WHEN:** offloading slow/async work; queue consumers; fan-out job processing. **DON'T USE FOR:** synchronous responses (\u2192`http`); time schedules (\u2192`cron`); cloud fan-out topics (\u2192`pubsub`).\n\n```ts\ntrigger: { worker: {\n queue: string, // required \u2014 queue/topic/stream name\n provider?: \"in-memory\"|\"nats\"|\"bullmq\"|\"kafka\"|\"rabbitmq\"|\"sqs\"|\"redis\"|\"pg-boss\", // default in-memory\n concurrency?: number, // default 1 \u2014 concurrent jobs per process\n timeout?: number, // ms \u2014 per-attempt hard timeout\n retries?: number, // default 3 \u2014 then DLQ\n priority?: number, consumerGroup?: string, ack?: boolean,\n deadLetterQueue?: string, fromBeginning?: boolean,\n // shared concurrency/scheduling: concurrencyKey, concurrencyLimit, onLimit, delay, ttl, debounce, middleware\n}}\n```\n\n```ts\nimport { node, step, workflow } from \"@blokjs/core\";\n\nexport default workflow(\"Process Background Job\", { version: \"1.0.0\", trigger: { worker: { queue: \"background-jobs\" } } }, (job) => {\n step(\"process-job\", node(\"@blokjs/api-call\"), { url: \"https://example.com/process\", method: \"POST\", body: job.body });\n});\n```\n\n**Worker context mapping:** `ctx.request.body` \u2192 job payload; `ctx.request.params.{queue,jobId,attempt}` \u2192 job metadata; `ctx.vars._worker_job` \u2192 full job record. Producers enqueue with `@blokjs/worker-publish`. Non-`in-memory` providers need their client as a peer dep (`nats`, `bullmq`+`ioredis`, `ioredis`, `@aws-sdk/client-sqs`, `kafkajs`, `amqplib`, `pg-boss`).\n\n### 2.3 CRON \u2014 `trigger: { cron: {...} }`\n\n**Purpose:** Run a workflow on a time schedule (standard cron expression). Dedicated process.\n\n**USE WHEN:** recurring/scheduled work \u2014 nightly cleanup, hourly polls, daily digests, periodic syncs. **DON'T USE FOR:** anything triggered by an external event or request.\n\n```ts\ntrigger: { cron: {\n schedule: string, // required \u2014 \"m h dom mon dow\", e.g. \"0 2 * * *\"\n timezone?: string, // default \"UTC\" \u2014 IANA tz e.g. \"America/New_York\"\n overlap?: boolean, // default false \u2014 allow overlapping executions\n // also: concurrencyKey, concurrencyLimit, middleware\n}}\n```\n\n```ts\nimport { node, step, workflow } from \"@blokjs/core\";\n\nexport default workflow(\"Daily Cleanup\", { version: \"1.0.0\", trigger: { cron: { schedule: \"0 2 * * *\", timezone: \"America/New_York\" } } }, () => {\n step(\"purge-stale\", node(\"@blokjs/api-call\"), { url: \"https://api.example.com/cleanup\", method: \"POST\" });\n});\n```\n\n`ctx.request.body` is `{}`; fire metadata is on `ctx.request.params.{schedule,firedAt}`. To serialize overlapping runs use `concurrencyKey: \"self\"`, `concurrencyLimit: 1`.\n\n### 2.4 PUBSUB \u2014 `trigger: { pubsub: {...} }`\n\n**Purpose:** Consume messages from a cloud/broker pub-sub topic, one run per delivery. Dedicated process. Fan-out (1:N) by default; competing-consumer (1-of-N) when `consumerGroup` is set.\n\n**USE WHEN:** cross-service / multi-process event handling over a real broker (GCP Pub/Sub, AWS SNS+SQS, Azure Service Bus, NATS, Redis Streams, Kafka). **DON'T USE FOR:** in-process pub/sub for HTTP+SSE chains (\u2192`sse` bus); plain job queues with competing consumers + retries (\u2192`worker`).\n\n```ts\ntrigger: { pubsub: {\n provider?: \"nats\"|\"redis-streams\"|\"kafka\"|\"gcp\"|\"aws\"|\"azure\", // default BLOK_PUBSUB_ADAPTER\n topic: string, // required \u2014 topic/subject/stream (wildcards ok: \"orders.*.created\")\n subscription?: string, // required for gcp/aws/azure; derived from consumerGroup otherwise\n consumerGroup?: string, // set \u2192 competing-consumer; unset \u2192 fan-out\n durable?: boolean, startFrom?: \"earliest\"|\"latest\"|{seq:number}|{timestamp:number},\n ack?: boolean, // default true\n maxMessages?: number, // default 10\n ackDeadline?: number, // default 30 (s)\n deadLetterTopic?: string, filter?: string,\n}}\n```\n\n```ts\nimport { node, step, workflow } from \"@blokjs/core\";\n\nexport default workflow(\"On Order Placed\", { version: \"1.0.0\", trigger: { pubsub: { provider: \"gcp\", topic: \"orders.placed\", subscription: \"fulfillment-svc\" } } }, (msg) => {\n step(\"fulfill\", node(\"@blokjs/api-call\"),\n { url: \"https://fulfillment.internal/api/orders\", method: \"POST\", body: msg.body },\n { idempotencyKey: msg.params.messageId }); // dedup redeliveries\n});\n```\n\n`messageId` on `ctx.request.params` is the natural `idempotencyKey`. Provider env vars \u2014 GCP: `GOOGLE_APPLICATION_CREDENTIALS`+`PUBSUB_PROJECT_ID`; AWS: standard credential chain (`topic`=SNS ARN, `subscription`=SQS URL); Azure: `AZURE_SERVICEBUS_CONNECTION_STRING`.\n\n### 2.5 SSE \u2014 `trigger: { sse: {...} }`\n\n**Purpose:** One-way server\u2192browser streaming via `EventSource`. Mounts on the shared HTTP port; pumps in-process bus events to connected clients.\n\n**USE WHEN:** pushing live updates one-way \u2014 token streaming from an LLM, progress feeds, notification streams. **DON'T USE FOR:** client\u2192server messages (\u2192`websocket`); one-shot JSON responses (\u2192`http`).\n\n```ts\ntrigger: { sse: {\n path?: string, // URL path; supports :params (e.g. \"/sse/chat/:sessionId\")\n events?: string[], // default [\"*\"]\n channels?: string[],\n maxConnections?: number, // default 10000\n heartbeatInterval?: number, // default 30000 ms\n retryInterval?: number, // default 3000 ms (browser reconnect hint)\n // also: concurrencyKey, concurrencyLimit\n}}\n```\n\n```ts\nimport { node, step, workflow } from \"@blokjs/core\";\n\nexport default workflow(\"Clock Stream\", { version: \"1.0.0\", trigger: { sse: { path: \"/sse/clock\", heartbeatInterval: 15000 } } }, () => {\n const sub = step(\"sub\", node(\"@blokjs/sse-subscribe\"), { channels: [\"clock\"] });\n step(\"stream\", node(\"@blokjs/sse-stream\"), { source: sub });\n});\n```\n\nA sibling HTTP workflow publishes via `@blokjs/sse-publish`; both share the in-process bus. Cross-process needs a Redis pub/sub backplane.\n\n### 2.6 WEBSOCKET \u2014 `trigger: { websocket: {...} }`\n\n**Purpose:** Bidirectional WS connections; one workflow run per inbound message/lifecycle event. Mounts on the shared HTTP port via Hono `upgradeWebSocket`.\n\n**USE WHEN:** two-way realtime \u2014 chat rooms, live cursors, RPC-over-WS, server-pushed updates the client also writes to. **DON'T USE FOR:** one-way push (\u2192`sse` is cheaper); request/response (\u2192`http`).\n\n```ts\ntrigger: { websocket: {\n path?: string, // URL path; supports :params (e.g. \"/ws/room/:roomId\")\n events?: string[], // default [\"*\"]; supported: \"open\"|\"message\"|\"close\"|\"error\"\n rooms?: string[],\n maxConnections?: number, // default 10000\n heartbeatInterval?: number, // default 30000 ms\n messageRateLimit?: number, // default 100 msgs/sec/client\n // also: concurrencyKey, concurrencyLimit\n}}\n```\n\n```ts\nimport { js, node, step, workflow } from \"@blokjs/core\";\n\nexport default workflow(\"WS Echo\", { version: \"1.0.0\", trigger: { websocket: { path: \"/ws/echo\", events: [\"message\", \"open\", \"close\"] } } }, (conn) => {\n // `js` is the escape hatch for a non-structural expression (Date.now()).\n step(\"reply\", node(\"@blokjs/ws-reply\"), { message: js`({ echo: ${conn.body}, at: Date.now() })` });\n});\n```\n\nHelpers: `@blokjs/ws-reply` (this connection), `@blokjs/ws-broadcast` (fan-out), `@blokjs/ws-close`. Cross-process broadcast needs `BLOK_WS_BACKPLANE=redis` + `BLOK_WS_BACKPLANE_REDIS_URL`.\n\n### 2.7 WEBHOOK \u2014 `trigger: { webhook: {...} }`\n\n**Purpose:** Receive signed provider POSTs, verify the HMAC signature, apply replay protection, then dispatch. Mounts on the shared HTTP port.\n\n**USE WHEN:** receiving Stripe / GitHub / Slack / Shopify / Svix callbacks, or any HMAC-signed partner webhook via custom `signature`. **DON'T USE FOR:** unsigned inbound requests (\u2192`http`).\n\n```ts\ntrigger: { webhook: {\n provider?: \"github\"|\"stripe\"|\"slack\"|\"shopify\"|\"svix\", // pick this OR signature, not both\n path?: string, // defaults to /webhooks/<provider>\n secretEnv?: string, // env var name holding the shared secret (never inline the secret)\n events?: string[], // allowlist; out-of-scope \u2192 200 {status:\"ignored\"}\n tolerance?: number, // seconds, default 300 \u2014 clock-skew window\n idempotencyKey?: string, // e.g. \"js/ctx.request.body.id\" \u2014 replay protection\n namespace?: string, // prefix for polymorphic subworkflow dispatch\n middleware?: string[],\n signature?: { // custom HMAC for non-built-in providers\n scheme?: \"hmac-sha256\"|\"hmac-sha1\"|\"hmac-sha512\", // default sha256\n header: string, format?: string, // format default \"{hex}\"; \"{hex}\"/\"{base64}\"\n secretEnv: string, tolerance?: number, timestampHeader?: string,\n },\n}}\n```\n\n```ts\nimport { subworkflow, workflow } from \"@blokjs/core\";\n\nexport default workflow(\"Stripe Webhook\", { version: \"1.0.0\", trigger: { webhook: {\n provider: \"stripe\", namespace: \"stripe\",\n secretEnv: \"STRIPE_WEBHOOK_SECRET\", idempotencyKey: \"js/ctx.request.body.id\",\n} } }, (event) => {\n // polymorphic dispatch: \"invoice.paid\" \u2192 \"stripe.invoice.paid\" (namespace prefix from the trigger)\n subworkflow(\"dispatch\", event.body.type, { stripeEvent: event.body });\n});\n```\n\nBad signature \u2192 401 with a structured `reason`; duplicate \u2192 200 `{status:\"duplicate\"}`; a workflow throw still returns 200 (senders shouldn't retry). `ctx.request.rawBody` carries the bytes the HMAC was computed against.\n\n### 2.8 MCP \u2014 `trigger: { mcp: {...} }`\n\n**Purpose:** Expose a workflow as an MCP **tool** (default) or **resource** to AI/LLM clients (Cursor, Claude Code). The tool's `inputSchema` is auto-generated from the workflow's Zod `input`. Mounts on the shared HTTP port.\n\n**USE WHEN:** giving an LLM/agent a callable tool or readable resource backed by a workflow. **DON'T USE FOR:** plain HTTP APIs for non-MCP clients (\u2192`http`).\n\n```ts\ntrigger: { mcp: {\n path?: string, // default \"/mcp\"\n serverName?: string, // default \"blok-mcp\"; workflows sharing path+serverName aggregate\n serverVersion?: string, // default \"1.0.0\"\n transports?: (\"sse\"|\"streamable-http\")[], // default both\n tool?: { name?: string, description?: string },\n resource?: { uri: string, name?: string, description?: string, mimeType?: string }, // expose as resource\n middleware?: string[],\n}}\n```\n\n**Requires a workflow-level `input:` Zod schema** \u2014 that becomes the tool's `inputSchema`:\n\n```ts\nimport { node, step, workflow } from \"@blokjs/core\";\nimport { z } from \"zod\";\n\nexport default workflow(\"search_code\", {\n version: \"1.0.0\",\n input: z.object({ query: z.string(), limit: z.number().optional() }), // \u2192 tool inputSchema\n trigger: { mcp: { path: \"/mcp\", serverName: \"my-platform\",\n tool: { description: \"Full-text search the indexed code\" } } },\n}, (call) => {\n step(\"search\", node(\"@my/search\"), { query: call.body.query });\n});\n```\n\nServes over SSE (`GET <path>/sse` + `POST <path>/messages`) and/or Streamable-HTTP (`<path>`).\n\n**Connecting a client** \u2014 the server mounts on the HTTP port (default 4000). Give an MCP client the URL `http://localhost:4000/mcp` (Streamable-HTTP, recommended) or `http://localhost:4000/mcp/sse` (legacy SSE):\n- **Claude Code:** `claude mcp add --transport http blok http://localhost:4000/mcp`\n- **Cursor** (`.cursor/mcp.json`): `{ \"mcpServers\": { \"blok\": { \"url\": \"http://localhost:4000/mcp\" } } }`\n- **Quick test:** `npx @modelcontextprotocol/inspector` \u2192 connect to `http://localhost:4000/mcp`\n\n`tools/call` arguments arrive as `ctx.request.body`; the final step's `ctx.response.data` is returned. Identity via the `x-user-context` header is injection-only, NOT authorization \u2014 scope access yourself.\n\n### 2.9 GRPC \u2014 `trigger: { grpc: {...} }`\n\n**Purpose:** Expose a workflow as a gRPC service method handler \u2014 typed, contract-based RPC. Dedicated process bound to a gRPC port.\n\n**USE WHEN:** high-throughput typed RPC between services with a proto contract; cross-language internal calls. **DON'T USE FOR:** browser-facing or REST APIs (\u2192`http`); async work (\u2192`worker`).\n\n> Caveat: gRPC config is **not Zod-validated** at construction. Author against the documented surface:\n\n```ts\ntrigger: { grpc: {\n service: string, // matches `service Foo {}` in the proto\n method: string, // matches `rpc Bar(...) returns (...)`\n proto: string, // path to the .proto file, relative to the workflow\n port?: number, // default 50051; all grpc workflows share one port (env GRPC_PORT)\n middleware?: string[],\n}}\n```\n\n```ts\nimport { node, step, tpl, workflow } from \"@blokjs/core\";\n\nexport default workflow(\"GetUser\", { version: \"1.0.0\", trigger: { grpc: { service: \"UserService\", method: \"GetUser\", proto: \"users.proto\" } } }, (rpc) => {\n step(\"lookup\", node(\"@blokjs/api-call\"), { url: tpl`https://internal/users/${rpc.body.userId}`, method: \"GET\" });\n});\n```\n\nThe request decodes into `ctx.request.body`; the final step output becomes the gRPC reply. (Server-streaming RPCs are not yet supported by the inbound trigger.)\n\n> **`trigger.queue` is DEAD** \u2014 it has a schema but no runtime and throws at workflow construction. Use `worker`. **`manual`** has no listener (invoked programmatically only \u2014 tests / sub-workflows); not for normal authoring.\n\n---\n\n## 3. AUTHORING WORKFLOWS \u2014 object-style & JSON (legacy; see \u00A70 for the canonical typed-handle DSL)\n\n\u00A70 covers the canonical typed-handle DSL (`workflow(name, { trigger }, (entry) => { ... })`). This section documents the **equivalent object-style form** (`@blokjs/helper`, one `workflow({...})` object literal with `$` reads) and JSON \u2014 both still fully supported for migrating and maintaining existing workflows; all three compile to the same IR. The persistence knobs and control-flow semantics below apply to every form.\n\n```ts\nimport { workflow, $ } from \"@blokjs/helper\";\n\nexport default workflow({\n name: \"Process Order\", // >= 3 chars\n version: \"1.0.0\", // semver x.x.x (>= 5 chars)\n trigger: { http: { method: \"POST\", path: \"/orders\" } }, // path optional \u2192 derived from file path\n steps: [\n { id: \"validate\", use: \"order-validator\", inputs: { order: $.req.body } },\n { id: \"save\", use: \"order-store\", inputs: { data: $.state.validate } },\n ],\n});\n```\n\nA regular step is `{ id, use, inputs }`. `id` is required and unique workflow-wide. `use` is the node reference. `type` is inferred from `use` (in-process `module` by default; `runtime.*` must be set explicitly).\n\n### The four context reads\n\n| Read | Resolves to | Scope |\n|---|---|---|\n| `$.state.<id>` | A prior step's stored output | Whole workflow (cross-step) |\n| `$.prev` | Immediately previous step's output | Adjacent only \u2014 overwritten every step |\n| `$.req` | Request envelope (body/headers/params/query/method/url) | Whole run |\n| `$.error` | Captured error inside a `tryCatch.catch` block | `catch` arm only \u2014 `undefined` elsewhere |\n\n`$.error` exposes `.message`, `.name`, `.stack`, `.code` (upstream HTTP status), and `.stepId`. The `$` proxy compiles to `\"js/ctx.<path>\"` strings at definition time \u2014 in JSON workflows write those strings by hand (`\"$.state.fetch\"` or `\"js/ctx.state.fetch\"`). Legacy aliases still resolve: `$.request`=`$.req`, `$.response`=`$.prev`, `$.vars`=`$.state` \u2014 prefer the canonical four.\n\n### Persistence knobs (per-step, declarative)\n\n| Knob | Effect |\n|---|---|\n| *(none)* | Store at `ctx.state[id]` (the 95% case) |\n| `as: \"name\"` | Store at `ctx.state[name]` instead of `ctx.state[id]` |\n| `spread: true` | Shallow-merge `result.data`'s top-level keys into `ctx.state` (multi-output nodes). Mutually exclusive with `as` |\n| `ephemeral: true` | Skip storage \u2014 only `$.prev` carries it to the next step (logging, audit) |\n\n**Every step's output auto-persists to `ctx.state[id]` \u2014 but ONLY on success.** A step that throws writes nothing, so `ctx.state[<id>] === undefined` is a truthful \"did this step succeed?\" check inside a `tryCatch.catch` arm.\n\n### Control-flow primitives\n\n**`branch({when, then, else})`** \u2014 `when` is a JS-expression *string* (the `$` proxy can't intercept `===`):\n\n```ts\nbranch({ id: \"route\",\n when: '$.req.method === \"POST\"',\n then: [{ id: \"create\", use: \"...\", inputs: {...} }],\n else: [{ id: \"read\", use: \"...\", inputs: {...} }] })\n```\n\n**`switchOn({id, on, cases, default?})`** \u2014 N-way branch, first match wins. `when` may be a scalar (`on === when`) or an array (`array.includes(on)`):\n\n```ts\nswitchOn({ id: \"route-by-event\", on: $.req.headers[\"x-github-event\"],\n cases: [\n { when: \"push\", do: [{ id: \"h1\", subworkflow: \"handle-push\" }] },\n { when: [\"pull_request\", \"pr_review\"], do: [{ id: \"h2\", subworkflow: \"handle-pr\" }] },\n ],\n default: [{ id: \"log\", use: \"@blokjs/log\", inputs: { message: \"unknown\" } }] })\n```\n\n**`forEach({id, in, as, do, mode?, concurrency?})`** \u2014 iterate a collection. Each iteration sets `ctx.state[as]` = item and `ctx.state[<as>Index]` = i; the loop's own slot `$.state[<id>]` is the array of each iteration's last-step output. `mode: \"parallel\"` runs with bounded `concurrency` (default 10):\n\n```ts\nforEach({ id: \"process-items\", in: $.req.body.items, as: \"item\",\n mode: \"parallel\", concurrency: 5,\n do: [{ id: \"reserve\", use: \"inventory-reserve\", inputs: { sku: $.state.item.sku } }] })\n```\n\n**`loop({id, while, do, maxIterations?})`** \u2014 while-loop, hard cap default 1000.\n**`tryCatch({id, try, catch, finally?})`** \u2014 `catch` sees `$.error`; errors in `catch` propagate (don't re-trigger `catch`); `finally` runs unconditionally.\n**`{ id, wait: { for: \"3d\" } | { until: <date> } }`** \u2014 durable pause; cannot combine with `idempotencyKey` or `retry`.\n\n### Caching, retry, sub-workflows (per-step)\n\n```ts\n{ id: \"fetch\", use: \"@blokjs/api-call\", inputs: { url: \"...\" },\n idempotencyKey: $.req.body.requestId, // cache by (workflow, step.id, key); default TTL 24h\n retry: { maxAttempts: 3, minTimeoutInMs: 500, maxTimeoutInMs: 10000, factor: 2 },\n maxDuration: \"30s\" } // per-attempt timeout; final-attempt timeout \u2192 run \"timedOut\"\n```\n\nA cache hit replays the cached result through the same `ephemeral`/`spread`/`as` rules and skips the node entirely. Override TTL with `idempotencyKeyTTL: <ms>` (0 = disabled). Default `maxAttempts: 1` = no retry.\n\n**Sub-workflow as a step:**\n\n```ts\n{ id: \"send-receipt\", subworkflow: \"send-receipt-email\",\n inputs: { user: $.state.user }, // becomes child's ctx.request.body (read via $.req.body)\n wait: true } // default: parent blocks, child response lands at state[id]\n```\n\n`wait: false` = fire-and-forget, returns `{runId, workflowName, scheduledAt}`. `subworkflow:` also accepts a `$.<path>`/`js/...` expression for polymorphic dispatch \u2014 pair with `allowList: [...]` whenever it depends on caller data. Recursion capped at 10 (`BLOK_MAX_SUBWORKFLOW_DEPTH`).\n\n### JSON workflows\n\nJSON mirrors the TS DSL one-for-one. Reference earlier outputs as `\"$.state.<id>\"` strings; use `\"ANY\"` for the wildcard method; a branch is one step with `branch: { when, then, else }`. JSON workflows live under `src/workflows/json/` (scanned recursively).\n\n---\n\n## 4. TRIGGER-LEVEL OPTIONS (across kinds)\n\nThese live on the **trigger config**, never on a step. They gate workflow entry.\n\n**Per-key concurrency gating** \u2014 `concurrencyKey` (+ optional `concurrencyLimit` default 1, `onLimit: \"throw\"|\"queue\"`):\n\n```ts\ntrigger: { http: { method: \"POST\", path: \"/render\",\n concurrencyKey: $.req.body.tenantId, concurrencyLimit: 5, onLimit: \"queue\" } }\n```\n\n`concurrencyLimit`/`onLimit`/`concurrencyLeaseMs` all require `concurrencyKey`. Denial \u2192 HTTP 429 + `Retry-After` (or 202 with `onLimit: \"queue\"`).\n\n**Scheduling** \u2014 `delay`, `ttl`, `debounce`. Durations are a number (ms) or a unit string (`\"500ms\"`,`\"30s\"`,`\"5m\"`,`\"2h\"`,`\"1d\"`):\n\n```ts\ntrigger: { http: { method: \"POST\", path: \"/welcome\", delay: \"1h\", ttl: \"2h\" } }\ntrigger: { http: { method: \"POST\", path: \"/save/:docId\",\n debounce: { key: $.req.params.docId, mode: \"trailing\", delay: \"500ms\", maxDelay: \"5s\" } } }\n```\n\nFor HTTP, `ttl` requires `delay`. Debounce modes: `trailing` (default \u2014 fire after silence) / `leading` (fire first, suppress follow-ups).\n\n**Middleware** \u2014 two forms:\n\n1. *Trigger-level chain* \u2014 ordered middleware-workflow names, run before the body on the same ctx:\n ```ts\n trigger: { http: { method: \"GET\", middleware: [\"auth-check\", \"request-id\"] } }\n ```\n2. *Defining a middleware workflow* \u2014 `workflow(name, { middleware: true }, cb)`. `trigger` becomes optional; it gets no public route and is referenced by `name`:\n ```ts\n import { node, step, workflow } from \"@blokjs/core\";\n\n export default workflow(\"auth-check\", { version: \"1.0.0\", middleware: true }, () => {\n // populate ctx.state.identity; a step may return stop:true to short-circuit the chain\n step(\"identity\", node(\"@my/verify-auth\"), {});\n });\n ```\n\nProcess-global middleware: `WorkflowRegistry.getInstance().setGlobalMiddleware([...])` or `BLOK_GLOBAL_MIDDLEWARE=a,b`.\n\n---\n\n## 5. AUTHORING NODES\n\n### 5.1 defineNode (TypeScript, in-process)\n\nAlways `export default defineNode(...)`. Never class-based `BlokService`. Zod input/output are mandatory.\n\n```ts\nimport { defineNode } from \"@blokjs/core\";\nimport { z } from \"zod\";\n\nexport default defineNode({\n name: \"fetch-user\",\n description: \"Fetches a user by ID\",\n input: z.object({ userId: z.string().uuid() }), // validated BEFORE execute\n output: z.object({ user: z.object({ id: z.string(), name: z.string() }) }), // validated AFTER\n async execute(ctx, input) {\n const user = await fetchUser(input.userId); // input is type-safe\n return { user }; // MUST match the output schema\n },\n});\n```\n\n- **Errors:** input failure \u2192 `GlobalError` code **400**; a plain `Error` thrown in `execute` \u2192 code **500**; a `GlobalError` you throw is preserved verbatim (custom codes like 401 survive).\n- **Never write `ctx.state` from a node** \u2014 return your output and let the runner persist it. For a genuine side-channel value, use `ctx.publish(name, value)`.\n- No `any` types \u2014 use `z.unknown()` and narrow.\n- `flow: true` nodes return `NodeBase[]`; `contentType: \"text/html\"` sets the response Content-Type.\n\nTypeScript nodes live in `src/nodes/` and are referenced by `use: \"<name>\"` (no `type` needed \u2014 `module` is the default).\n\n### 5.2 Nodes in other runtimes (gRPC sidecars)\n\nThe 7 non-TS runtimes run as long-lived gRPC sidecar processes; the TypeScript runner is the client. A step routes to a sidecar with **`type: \"runtime.<lang>\"`** and `use:` = the registered node name. The step's resolved `inputs` arrive as the node's config / typed input (NOT `ctx.request.body` \u2014 that holds the original trigger payload). The node's return value lands in `ctx.state[<step-id>]`.\n\n**Runtime nodes live in `runtimes/<lang>/nodes/`** and require that runtime to be scaffolded. Add a runtime with `blokctl runtime add <lang>` (or `blokctl create <project> --runtimes go,python3,...` at create time). Scaffold a node with `blokctl create node <name> --runtime <lang>`. Across all runtimes:\n\n- The runner speaks **gRPC only** (the legacy HTTP `/execute` path was removed in v0.5).\n- gRPC dispatch port = legacy HTTP port + 1000. **Dispatch ports:** go `10001`, rust `10002`, java `10003`, csharp `10004`, php `10005`, ruby `10006`, python3 `10007`. (Readiness/health HTTP ports are the legacy `9001`\u2013`9007`; the CLI readiness check is a **TCP connect to the gRPC port**, not `GET /health`.)\n- `blokctl dev` sets `BLOK_TRANSPORT=grpc` + `GRPC_PORT` for each sidecar. Most SDKs default to HTTP transport if you launch them by hand \u2014 always let `blokctl dev` (or the env) set gRPC, or the runner can't reach the node.\n- Generated proto stubs ship with each SDK \u2014 you do **not** regenerate them to author a node.\n- Each SDK has a **typed** contract (the equivalent of `defineNode` \u2014 validated input, typed output, reflected JSON Schema) and a lower-level untyped contract. **Prefer the typed contract.** Bad input auto-fails with `NODE_INPUT_VALIDATION` / HTTP 400 before your code runs.\n- gRPC message cap defaults to 16 MiB (`BLOK_GRPC_MAX_MESSAGE_BYTES`).\n- Don't edit `.blok/runtimes/` \u2014 those are generated copies.\n\nThe workflow step is identical regardless of runtime \u2014 only `type` changes:\n\n```ts\n{ id: \"sum\", use: \"add-numbers\", type: \"runtime.<lang>\", inputs: { a: $.req.body.a, b: $.req.body.b } }\n```\n\n#### Authoring a node in go\n\n`runtimes/go/nodes/addnumbers.go` \u2014 typed via `blok.DefineNode`:\n\n```go\npackage nodes\n\nimport blok \"github.com/nickincloud/blok-go\"\n\ntype AddNumbersInput struct {\n A int `json:\"a\"`\n B int `json:\"b\"`\n}\ntype AddNumbersOutput struct {\n Sum int `json:\"sum\"`\n}\n\nconst AddNumbersNodeName = \"add-numbers\"\n\nvar AddNumbersNode = blok.DefineNode(AddNumbersNodeName, \"Adds two integers\",\n func(_ *blok.Context, in AddNumbersInput) (AddNumbersOutput, error) {\n return AddNumbersOutput{Sum: in.A + in.B}, nil\n })\n```\n\nRegister in `runtimes/go/cmd/server/main.go`:\n\n```go\nfunc main() {\n registry := blok.NewNodeRegistry()\n registry.Register(nodes.AddNumbersNodeName, nodes.AddNumbersNode)\n registry.Use(blok.RecoveryMiddleware(), blok.LoggingMiddleware(blok.NewLogger(blok.LogLevelInfo)))\n if err := blok.ListenAndServe(registry); err != nil { log.Fatalf(\"Server error: %v\", err) }\n}\n```\n\nWorkflow step: `{ id: \"sum\", use: \"add-numbers\", type: \"runtime.go\", inputs: { a: $.req.body.a, b: $.req.body.b } }`. Errors: return a non-nil `error`, or use `blok.NewValidationError` / `blok.NewError(category)...Build()` for structured `BlokError`. Toolchain: Go 1.24+, `go mod download`, `go run ./cmd/server`.\n\n#### Authoring a node in rust\n\n`runtimes/rust/nodes/add-numbers/src/main.rs` \u2014 typed via the `TypedNode` trait:\n\n```rust\nuse async_trait::async_trait;\nuse blok::{BlokError, Context, NodeRegistry, TypedNode};\nuse schemars::JsonSchema;\nuse serde::{Deserialize, Serialize};\n\n#[derive(Deserialize, JsonSchema)]\nstruct AddInput { a: f64, b: f64 }\n\n#[derive(Serialize, JsonSchema)]\nstruct AddOutput { sum: f64 }\n\nstruct AddNumbers;\n\n#[async_trait]\nimpl TypedNode for AddNumbers {\n type Input = AddInput;\n type Output = AddOutput;\n fn name(&self) -> &str { \"add-numbers\" }\n fn description(&self) -> &str { \"Adds two numbers\" }\n async fn run(&self, _ctx: &mut Context, input: AddInput) -> Result<AddOutput, BlokError> {\n Ok(AddOutput { sum: input.a + input.b })\n }\n}\n\n#[tokio::main]\nasync fn main() {\n let mut registry = NodeRegistry::new(\"1.0.0\");\n registry.register_typed(AddNumbers); // typed nodes register via register_typed\n blok::server::serve(registry, 9002).await.unwrap();\n}\n```\n\nWorkflow step: `type: \"runtime.rust\"`. `Input`/`Output` must derive `serde` + `schemars::JsonSchema`. Errors: `BlokError::validation()/.dependency()/...build()`. Toolchain: `cargo build --release` / `cargo run`; gRPC is feature-gated \u2014 build with the `grpc` feature (or `--features full`) so the runner can dispatch.\n\n#### Authoring a node in java\n\n`runtimes/java/src/main/java/com/blok/blok/nodes/AddNumbersNode.java` \u2014 typed via `TypedNode<I, O>`:\n\n```java\npackage com.blok.blok.nodes;\n\nimport com.blok.blok.node.TypedNode;\nimport com.blok.blok.types.Context;\n\npublic final class AddNumbersNode extends TypedNode<AddNumbersNode.Input, AddNumbersNode.Output> {\n public record Input(int a, int b) {}\n public record Output(int sum) {}\n\n @Override public String name() { return \"add-numbers\"; }\n @Override public String description() { return \"Adds two integers\"; }\n @Override protected Class<Input> inputClass() { return Input.class; }\n @Override protected Class<?> outputClass() { return Output.class; }\n\n @Override protected Output run(Context ctx, Input input) {\n return new Output(input.a() + input.b());\n }\n}\n```\n\nRegister in `runtimes/java/src/main/java/com/blok/blok/Main.java`:\n\n```java\nNodeRegistry registry = new NodeRegistry();\nregistry.register(\"add-numbers\", new com.blok.blok.nodes.AddNumbersNode());\nregistry.use(new RecoveryMiddleware());\nregistry.use(new LoggingMiddleware(logger));\n```\n\nWorkflow step: `type: \"runtime.java\"`. Errors: `throw BlokError.validation().code(...).message(...).build();`. Primitive record components (`int`, `boolean`) are required in the reflected schema; boxed types are optional. Toolchain: JDK 17+ and Maven, `mvn package -q -DskipTests`, `java -jar target/blok-java-1.0.0.jar`.\n\n#### Authoring a node in csharp\n\n`runtimes/csharp/Nodes/AddNumbersNode.cs` \u2014 typed via `TypedNode<TInput, TOutput>`:\n\n```csharp\nusing System.ComponentModel.DataAnnotations;\nusing Blok.Core.Node;\nusing Blok.Core.Types;\n\nnamespace Blok.Runtime.Nodes;\n\npublic sealed record AddNumbersInput([property: Required] double A, [property: Required] double B);\npublic sealed record AddNumbersOutput(double Sum);\n\npublic sealed class AddNumbersNode : TypedNode<AddNumbersInput, AddNumbersOutput>\n{\n public override string Name => \"add-numbers\";\n public override string Description => \"Adds two numbers\";\n public override Task<AddNumbersOutput> RunAsync(Context ctx, AddNumbersInput input)\n => Task.FromResult(new AddNumbersOutput(input.A + input.B));\n}\n```\n\nRegister in `runtimes/csharp/Program.cs`:\n\n```csharp\nvar config = ServerConfig.FromEnv();\nvar registry = new NodeRegistry(config.Version);\nregistry.Register(\"add-numbers\", new AddNumbersNode());\nawait RuntimeServer.Run(registry, config);\n```\n\nWorkflow step: `type: \"runtime.csharp\"`. The wire is **camelCase** (`{ \"a\": 2, \"b\": 3 }` maps to `A`/`B`). Errors: `throw BlokError`. Toolchain: .NET 8.0+, `dotnet restore`, `dotnet run`.\n\n#### Authoring a node in php\n\n`runtimes/php/nodes/add-numbers/src/Nodes/AddNumbersNode.php` \u2014 typed via `TypedNode` (or plain `NodeHandler`):\n\n```php\n<?php\ndeclare(strict_types=1);\nnamespace Blok\\Nodes;\n\nuse Blok\\Blok\\Node\\TypedNode;\nuse Blok\\Blok\\Types\\Context;\n\nfinal class AddNumbersInput\n{\n public function __construct(public int $a, public int $b) {}\n}\n\nfinal class AddNumbersNode extends TypedNode\n{\n public function name(): string { return 'add-numbers'; }\n public function description(): string { return 'Adds two integers'; }\n protected function inputClass(): string { return AddNumbersInput::class; }\n\n protected function run(Context $ctx, object $input): mixed\n {\n /** @var AddNumbersInput $input */\n return ['sum' => $input->a + $input->b];\n }\n}\n```\n\nRegister in `runtimes/php/bin/serve.php`:\n\n```php\n$config = ServerConfig::fromEnv();\n$registry = new NodeRegistry($config->version);\n$registry->register('add-numbers', new AddNumbersNode());\n// ... wire $registry into BlokNodeRuntimeService + RoadRunner GrpcServer and $server->serve();\n```\n\nWorkflow step: `type: \"runtime.php\"`. The gRPC server is RoadRunner (`rr serve -c .rr.yaml`), which `blokctl dev` runs. Imports are `Blok\\Blok\\Node\\NodeHandler` and `Blok\\Blok\\Types\\Context`. Toolchain: PHP 8.2+, Composer, RoadRunner; `composer install`.\n\n#### Authoring a node in ruby\n\n`runtimes/ruby/nodes/add_numbers_node.rb` \u2014 typed via `Blok::Node::TypedNode`:\n\n```ruby\n# frozen_string_literal: true\nrequire \"blok\"\n\nclass AddNumbersNode < Blok::Node::TypedNode\n node_name \"add-numbers\"\n description \"Adds two numbers and returns their sum\"\n\n input do\n field :a, :number, required: true\n field :b, :number, required: true\n end\n output { field :sum, :number }\n\n def run(_ctx, input)\n { \"sum\" => input[:a] + input[:b] } # string-keyed Hash is idiomatic\n end\nend\n```\n\nRegister in `runtimes/ruby/bin/serve.rb`:\n\n```ruby\nrequire_relative \"../nodes/add_numbers_node\"\n\nconfig = Blok::Config::ServerConfig.from_env\nregistry = Blok::Node::NodeRegistry.new(config.version)\nregistry.register(\"add-numbers\", AddNumbersNode.new) # name MUST equal node_name\nregistry.use(Blok::Middleware::RecoveryMiddleware.new)\n# serve.rb routes to start_grpc under BLOK_TRANSPORT=grpc\n```\n\nWorkflow step: `type: \"runtime.ruby\"`. Field types: `:string, :integer, :number, :boolean, :array, :object`. Errors: `raise Blok::Errors::BlokError.validation(...)`. Toolchain: Ruby 3.2+, Bundler; `bundle install`.\n\n#### Authoring a node in python3\n\n`runtimes/python3/nodes/add_numbers/node.py` \u2014 typed via the `@node` decorator (Pydantic):\n\n```python\nfrom __future__ import annotations\nfrom pydantic import BaseModel, Field\nfrom blok import node, Context\n\n\nclass AddNumbersInput(BaseModel):\n a: float\n b: float = Field(0)\n\n\nclass AddNumbersOutput(BaseModel):\n sum: float\n\n\n@node(\"add-numbers\", \"Adds two numbers and returns their sum\")\ndef add_numbers(ctx: Context, input: AddNumbersInput) -> AddNumbersOutput:\n return AddNumbersOutput(sum=input.a + input.b)\n```\n\nRegistration is **manual** \u2014 importing the module runs the `@node` decorator; then flush with `register_decorated`. In `runtimes/python3/nodes/__init__.py`:\n\n```python\nfrom blok import register_decorated\nfrom . import add_numbers # noqa: F401 (runs the @node decorator)\n\ndef register_project_nodes(registry):\n return register_decorated(registry)\n```\n\n\u2026and call `register_project_nodes(registry)` from the boot path (after the SDK's `register_all(registry)`). Workflow step: `type: \"runtime.python3\"`; `use:` must match the **string in `@node(\"name\", ...)`**, not the function name. Errors: `raise BlokError.validation(...)` (or `.dependency`, `.not_found`, \u2026). Toolchain: Python 3, `pip3 install -r requirements.txt`; `@node` requires `pydantic`.\n\n> The legacy `BlokService` / `async def handle()` / `from core.blok import BlokService` Python shape **does not exist** in this SDK \u2014 ignore any example that uses it. Use `@node` (or the `NodeHandler` ABC).\n\n---\n\n## 6. RUNNING LOCALLY / INFRA\n\n```bash\nblokctl dev # full dev server: spawns selected runtimes + the runner\nblokctl create node <name> --runtime <lang> # scaffold a node (ts default; pass --runtime for sidecars)\nblokctl runtime add <lang> # add a non-TS runtime to an existing project\nblokctl trace # open Blok Studio (run traces at /__blok)\n```\n\nThe `http`/`sse`/`websocket`/`webhook`/`mcp` triggers need no external infra \u2014 they share the HTTP server. The cross-process triggers (`worker`, `pubsub`) need a broker.\n\n**For worker/pubsub, start the broker stack** with the dev compose (Redis + NATS + Postgres/Adminer):\n\n```bash\ncd infra/development && docker compose up -d nats # or: redis redis-commander\n```\n\nThe default worker adapter is `in-memory` (zero infra) \u2014 only start a broker when you set a real provider (`nats`, `redis`, `bullmq`, \u2026). Monitoring UIs from the dev compose: Adminer `:8080`, Redis Commander `:8081`, NATS monitor `:8222`. The compose declares an external `shared-network` \u2014 if the first run fails, run `docker network create shared-network`.\n\n**For Kafka / RabbitMQ / SQS / GCP-Pub/Sub emulators**, use `infra/testing/docker-compose.yml` instead \u2014 those brokers are wired there on non-standard ports with emulators, matching the provider env blocks the scaffold writes.\n\n---\n\n## 7. FOOTGUN LIST (read before authoring)\n\n1. **Never reuse a step `id`** \u2014 anywhere, including across mutually-exclusive `switch`/`branch`/`tryCatch` arms. All ids share one flat per-workflow config map; duplicates collide (last definition wins) and the matched arm silently runs with the *other* arm's inputs. If two arms must write the same downstream key, give them distinct ids and use `as: \"shared\"`.\n2. **Don't prefix `@blokjs/expr`'s `expression` input with `js/`** \u2014 that input is itself mapper-resolved, so `js/...` double-evaluates. Write plain JS: `expression: \"ctx.state.x.y\"`.\n3. **`set_var` was removed in v0.5** \u2014 the runner throws at load time if present. Drop `set_var: true` (default-store handles it); replace `set_var: false` with `ephemeral: true`.\n4. **Use `\"ANY\"`, not `\"*\"`, for the wildcard HTTP method** \u2014 `\"*\"` is accepted but warns and is auto-normalized.\n5. **`trigger.queue` is rejected at construction** \u2014 it has no runtime and would silently never run. Use `trigger.worker` (`{ worker: { queue: \"<name>\" } }`).\n6. **Workflow envelope minimums:** `name` >= 3 chars, `version` >= 5 chars (semver), `steps` must be non-empty, and a `trigger` is required unless `middleware: true`.\n7. **Every v2 step schema is `.strict()`** \u2014 a misspelled or unknown field throws at load time, not silently dropped. A trigger-only field placed on a step (`concurrencyKey`, `delay`, `ttl`, `debounce`, `concurrencyLimit`) gets a targeted error pointing you to the trigger config.\n8. **`as` and `spread` are mutually exclusive** \u2014 pick one.\n9. **`$.prev` is volatile** (only the previous step). For any non-adjacent read use `$.state.<id>`. Reading `$.state.<id>` for a step that set `ephemeral: true` returns `undefined`.\n10. **Sub-workflow `idempotencyKey` with `wait: true` caches the WHOLE child result** \u2014 a cache hit means the child (and its side effects: emails, charges) never runs. Headline pattern AND primary footgun.\n\nPlus the cross-runtime rules: **the wrong input source** (typed sidecar nodes read their step `inputs`, NOT `ctx.request.body`), **registration is explicit** for every runtime (a file in `runtimes/<lang>/nodes/` does nothing until you register it by name), and **`type: \"runtime.<lang>\"` is required** on the step or it defaults to the in-process TS path and fails with `Node type X not found`.\n\n**Production env knob worth naming:** `BLOK_MAPPER_MODE=strict` \u2014 fail-fast on `js/...` input resolution errors instead of silently passing the literal string through. Strongly recommended for production.\n\n---\n\n## 8. TESTING\n\nUse the `@blokjs/core/testing` utilities with Vitest (same package you author against).\n\n**Unit-test a node** with `NodeTestHarness`:\n\n```ts\nimport { NodeTestHarness } from \"@blokjs/core/testing\";\nimport myNode from \"../src/nodes/my-node\";\n\nconst harness = new NodeTestHarness(myNode);\nconst result = await harness.execute({ userId: \"abc-123\" });\nharness.assertSuccess(result);\nharness.assertOutput(result, { user: { id: \"abc-123\" } });\n```\n\n**Integration-test a workflow** with `WorkflowTestRunner`:\n\n```ts\nimport { WorkflowTestRunner } from \"@blokjs/core/testing\";\n\nconst runner = new WorkflowTestRunner({ verbose: true, mockAllNodes: true });\nrunner.registerNode(\"validate\", ValidateNode);\nrunner.mockNode(\"external-api\", async (input) => ({ result: \"mocked\" }));\nrunner.loadWorkflow(myWorkflowDefinition);\nconst result = await runner.execute({ input: \"data\" });\nexpect(result.success).toBe(true);\n```\n\n---\n\n## Do / Do NOT\n\n**Do:**\n- Read `.blok/config.json` and existing `src/workflows/` to learn which triggers + runtimes this project uses; author for those.\n- Start every workflow from the **trigger decision table** in \u00A71, not from HTTP.\n- Author with the typed-handle DSL \u2014 `workflow(name, { version, trigger }, (entry) => { step(\"id\", node(\"@pkg\"), inputs) })` from `@blokjs/core` (\u00A70). Object-style `workflow({...})` and JSON stay valid for legacy/migration.\n- Use the typed node contract in every runtime (`defineNode` / `DefineNode` / `TypedNode` / `@node`).\n- Reference cross-step outputs via the handle `step()` returns (not `$.state.<id>`); use the 4th-arg `as:`/`spread:`/`ephemeral:` knobs to shape persistence.\n- Set `type: \"runtime.<lang>\"` on every sidecar step and register the node by name.\n\n**Do NOT:**\n- Default to the HTTP trigger because it's familiar \u2014 pick by intent.\n- Emit `trigger.queue` \u2014 it throws; use `worker`.\n- Use `\"*\"` for the wildcard method (use `\"ANY\"`), or `set_var` (removed in v0.5).\n- Write class-based `BlokService` nodes, or the stale Python `BlokService`/`async def handle()` shape.\n- Write to `ctx.state` inside a node \u2014 return your output (or `ctx.publish(...)` for a side-channel value).\n- Reuse a step `id`, combine `as` + `spread`, or use `any` types.\n- Read a typed sidecar node's data from `ctx.request.body` \u2014 read the step `inputs` / typed input.\n- Edit files under `.blok/runtimes/` \u2014 they are generated.\n";
27
27
  declare const claude_md = "\n# Blok \u2014 Claude Code Quick Reference\n\nThis is the **terse operational quick-reference**. For full architecture, every trigger's complete config + examples, and the per-runtime node templates, **read `AGENTS.md`** in this project root.\n\n## Quick Commands\n\n```bash\nblokctl dev # Full dev server (spawns trigger runtimes + runner)\nblokctl create workflow <name> # Scaffold a workflow\nblokctl create node <name> # Scaffold a TS node\nblokctl create node <name> --runtime go # Scaffold a node in another runtime (go|rust|java|csharp|php|ruby|python3)\nblokctl trace # Open Blok Studio (or visit /__blok on the running trigger)\n```\n\n---\n\n## 1. Pick the right trigger FIRST (do NOT default to HTTP)\n\nBlok has **9 trigger kinds**. HTTP is **one of nine**, not the default \u2014 it is correct only for synchronous request/response. Every workflow declares exactly **one** trigger.\n\n**Before writing any workflow:** read **`.blok/config.json`** to see which triggers and runtimes this project actually scaffolded, and author for those. If the project is a `worker`/`cron`/`pubsub` project, do not write an HTTP workflow. Match the installed triggers.\n\n### Trigger Decision Table \u2014 choose by intent\n\n| What you're building | Trigger |\n|---|---|\n| Respond to an HTTP/REST request; JSON API; HTML page; file download | **`http`** |\n| Process a background / queued / async job; offload slow work | **`worker`** |\n| Run on a schedule / recurring time-based job (nightly, hourly) | **`cron`** |\n| React to messages on a cloud topic/subscription (cross-service events) | **`pubsub`** |\n| Stream live updates one-way to a browser (tokens, progress, feed) | **`sse`** |\n| Bidirectional realtime (chat, live cursors, client\u2194server messages) | **`websocket`** |\n| Receive a signed provider webhook (Stripe / GitHub / Slack / Shopify / Svix) | **`webhook`** |\n| Expose a workflow as a tool/resource to an AI/LLM client (Cursor, Claude) | **`mcp`** |\n| High-throughput typed RPC between services with a `.proto` contract | **`grpc`** |\n\nTie-breakers: one-way stream \u2192 `sse`; two-way \u2192 `websocket`. In-process pub/sub (single Node process, HTTP+SSE) \u2192 `sse` bus, not `pubsub`. Queue consumer \u2192 **`worker`** (the `queue` kind is dead \u2014 it throws at construction; never emit `trigger: { queue: ... }`).\n\n`http`, `sse`, `websocket`, `webhook`, `mcp` share one Hono port. `worker`, `cron`, `pubsub`, `grpc` run in their own processes. Regardless of kind, the body reads `ctx.request.{body,headers,params,query,method}` identically \u2014 only the `trigger:` block changes. See `AGENTS.md` for each kind's full config + a runnable example.\n\n---\n\n## 2. Context & State (v2)\n\n**Every step's output auto-persists to `ctx.state[id]` \u2014 on success only.** A step that errors writes nothing, so `ctx.state[<id>] === undefined` is a truthful \"did it succeed?\" check inside a `tryCatch.catch` arm.\n\n**The four reads** (the `$` proxy compiles to `\"js/ctx.<path>\"` strings; in JSON write those strings by hand):\n\n| Read | Resolves to | Scope |\n|---|---|---|\n| `$.state.<id>` | A prior step's stored output | Whole workflow (cross-step) |\n| `$.prev` | Immediately previous step's output | Adjacent only \u2014 overwritten every step |\n| `$.req` | Request envelope (body/headers/params/query/method) | Whole run |\n| `$.error` | Captured error (`.message`/`.code`/`.stepId`) | `tryCatch.catch` arm only |\n\n**Persistence knobs (per-step):**\n\n| Knob | Effect |\n|---|---|\n| *(none)* | Store at `ctx.state[id]` (the 95% case) |\n| `as: \"name\"` | Store at `ctx.state[name]` instead. Mutually exclusive with `spread` |\n| `spread: true` | Shallow-merge `result.data`'s keys into `ctx.state` (multi-output nodes) |\n| `ephemeral: true` | Skip storage; only `$.prev` carries it to the next step (logging/audit) |\n\nPer-step reliability lives on the step: `idempotencyKey` (cache by `(workflow, step.id, key)`, default 24h TTL), `retry: { maxAttempts, minTimeoutInMs?, factor? }`, `maxDuration: \"30s\"`. Cross-key gating + scheduling (`concurrencyKey`, `onLimit`, `delay`, `ttl`, `debounce`, `middleware`) go on the **trigger block**, never on a step.\n\n---\n\n## 3. Generating Nodes\n\nAlways `export default defineNode(...)` (TS) \u2014 never class-based `BlokService`. Zod input/output are mandatory. Never write `ctx.state` from a node \u2014 return your output and let the runner persist it (use `ctx.publish(name, value)` for a true side-channel). No `any` types \u2014 use `z.unknown()`.\n\n```typescript\nimport { defineNode } from \"@blokjs/core\";\nimport { z } from \"zod\";\n\nexport default defineNode({\n name: \"fetch-user\",\n description: \"Fetches a user by ID\",\n input: z.object({ userId: z.string().uuid() }), // validated BEFORE execute \u2192 400 on fail\n output: z.object({ user: z.object({ id: z.string(), name: z.string() }) }), // validated AFTER \u2192 500 on fail\n async execute(ctx, input) {\n const user = await fetchUser(input.userId); // input is type-safe\n return { user }; // MUST match the output schema\n },\n});\n```\n\n### Nodes in other runtimes\n\nA non-TS node runs in a per-language sidecar and is referenced from a step with `type: \"runtime.<lang>\"` + `use: \"<node name>\"`. Scaffold one with `blokctl create node <name> --runtime <lang>`. **Full, copy-pasteable per-runtime node templates are in `AGENTS.md`.** Nodes live under `runtimes/<lang>/nodes/`.\n\n| Runtime | Step `type` | gRPC port |\n|---|---|---|\n| Go | `runtime.go` | 10001 |\n| Rust | `runtime.rust` | 10002 |\n| Java | `runtime.java` | 10003 |\n| C# | `runtime.csharp` | 10004 |\n| PHP | `runtime.php` | 10005 |\n| Ruby | `runtime.ruby` | 10006 |\n| Python3 | `runtime.python3` | 10007 |\n\n**Inline cross-runtime example (Python3 \u2014 `@node` is the Python `defineNode`):**\n\n```python\n# runtimes/python3/nodes/add_numbers/node.py\nfrom pydantic import BaseModel, Field\nfrom blok import node, Context\n\nclass AddNumbersInput(BaseModel):\n a: float\n b: float = Field(0)\n\nclass AddNumbersOutput(BaseModel):\n sum: float\n\n@node(\"add-numbers\", \"Adds two numbers and returns their sum\")\ndef add_numbers(ctx: Context, input: AddNumbersInput) -> AddNumbersOutput:\n return AddNumbersOutput(sum=input.a + input.b)\n```\n\nRegistration is **manual** in non-TS runtimes \u2014 importing the module runs the decorator; wire it into the boot path (see `AGENTS.md`). The `use:` value must match the registered node **name** string, not the function name.\n\n---\n\n## 4. Generating Workflows\n\n**Preferred (TypeScript): the typed-handle DSL from `@blokjs/core`.** `workflow(name, { version, trigger }, (entry) => { ... })` \u2014 each `step(id, node, inputs)` returns a typed handle you reference directly; no `$`, no `js/`, no raw `ctx` strings. `http.{get,post,\u2026}(path?)` builds the trigger; other triggers pass the raw block (`{ worker: { queue } }`, `{ cron: { schedule } }`, \u2026). Entry name per trigger: `http`\u2192`req`, `worker`\u2192`job`, `cron`\u2192`tick`, `webhook`\u2192`event`, `pubsub`\u2192`msg`, `grpc`\u2192`rpc`.\n\n```typescript\nimport { workflow, step, branch, gt, http } from \"@blokjs/core\";\n\nexport default workflow(\"Process Order\", { version: \"1.0.0\", trigger: http.post(\"/orders\") }, (req) => {\n const order = step(\"validate\", orderValidator, { order: req.body }); // typed handle\n step(\"save\", orderStore, { data: order }); // reference it directly\n branch(\"big\", gt(order.total, 100), { then: () => { step(\"vip\", flagVip, { id: order.id }); } });\n});\n```\n\nControl flow (all from `@blokjs/core`): `branch(id, cond, {then,else?})`, `forEach(iterable, (item,i)=>{}, {as?,mode?})`, `switchOn(disc, {cases:[{when,do}],default?}, {id})`, `tryCatch(id, {try,catch:(err)=>{},finally?})`; comparators `eq/ne/gt/gte/lt/lte/not`; `tpl`\u2026${h.x}\u2026`` for strings. The **four footguns**: arm-scoped handles don't escape their arm; ephemeral handles read `undefined`; never reuse a step `id`; never name a `forEach` `as:` after an existing id. See `docs/d/primitives/handles-and-footguns.mdx`.\n\n**Equivalent object-style form** (`@blokjs/helper`, also valid; JSON mirrors it): one object literal, `steps: [...]`, reference outputs with `$.state.<id>` / `$.req.body`.\n\n```typescript\nimport { workflow, $ } from \"@blokjs/helper\";\n\nexport default workflow({\n name: \"Process Order\",\n version: \"1.0.0\",\n trigger: { http: { method: \"POST\", path: \"/orders\" } }, // path optional \u2192 derived from file path\n steps: [\n { id: \"validate\", use: \"order-validator\", inputs: { order: $.req.body } },\n { id: \"save\", use: \"order-store\", inputs: { data: $.state.validate } },\n ],\n});\n```\n\n**Swap the `trigger:` block for any other kind** (body stays the same). Full configs + examples in `AGENTS.md`:\n\n```typescript\ntrigger: { worker: { queue: \"background-jobs\" } } // background jobs\ntrigger: { cron: { schedule: \"0 2 * * *\", timezone: \"America/New_York\" } } // recurring\ntrigger: { pubsub: { provider: \"gcp\", topic: \"orders.placed\", subscription: \"fulfillment-svc\" } }\ntrigger: { sse: { path: \"/sse/clock\", heartbeatInterval: 15000 } } // one-way stream\ntrigger: { websocket: { path: \"/ws/echo\", events: [\"message\", \"open\", \"close\"] } }\ntrigger: { webhook: { provider: \"stripe\", secretEnv: \"STRIPE_WEBHOOK_SECRET\", idempotencyKey: \"js/ctx.request.body.id\" } }\ntrigger: { mcp: { path: \"/mcp\", tool: { description: \"...\" } } } // needs a workflow-level input: z.object({...})\ntrigger: { grpc: { service: \"UserService\", method: \"GetUser\", proto: \"users.proto\" } }\n```\n\n**Branch:**\n\n```typescript\nimport { workflow, branch, $ } from \"@blokjs/helper\";\nbranch({ id: \"route\",\n when: '$.req.method === \"POST\"', // when is a JS-expression STRING ($ can't intercept ===)\n then: [{ id: \"create\", use: \"...\", inputs: {...} }],\n else: [{ id: \"read\", use: \"...\", inputs: {...} }] })\n```\n\n**Worker/pubsub/broker projects** need local infra. The scaffold ships an `infra/development` docker-compose with the broker stack \u2014 `cd infra/development && docker compose up -d` to start NATS/Redis (run `docker network create shared-network` once if prompted).\n\n---\n\n## 5. Common Errors\n\n| Error | Cause | Fix |\n|---|---|---|\n| `Trigger kind 'queue' has no runtime` | Used `trigger: { queue: ... }` | Use `trigger: { worker: { queue: \"<name>\" } }` |\n| `Validation failed: name must be at least 3 characters` | Workflow `name` < 3 chars / `version` < 5 chars | Lengthen name; use full semver `x.x.x` |\n| `Unrecognized key(s) in object: \"...\"` | Misspelled / unknown field \u2014 every v2 step schema is `.strict()` | Fix the spelling; trigger-only fields (`concurrencyKey`, `delay`, `ttl`, `debounce`) belong on the trigger, not a step |\n| `ctx.state['X'] is undefined` | Step X has `ephemeral: true`, or `$.state.<id>` references a typo'd id | Remove `ephemeral`, or fix the id reference |\n| `as and spread are mutually exclusive` | Step set both | Pick one |\n| `branch step is missing 'when'` | No condition string | Set `when: \"...\"` |\n| `step \"...\" uses set_var` | Legacy field (removed v0.5) | Drop `set_var: true`; replace `set_var: false` with `ephemeral: true` |\n| `node '<name>' not found in registry` (non-TS) | Node not imported/registered in the sidecar boot path | Import the module + register it; `use:` must match the registered node name |\n| `Node type X not found` | Missing runtime resolver / wrong `type` | Check `type: \"runtime.<lang>\"` and that the runtime is scaffolded |\n| `[blok][mapper] Failed to resolve ...` | A `js/...` input expression threw | Fix the expression; set `BLOK_MAPPER_MODE=strict` to fail-fast in prod |\n\n---\n\n## 6. Do NOT\n\n- Do NOT default to the HTTP trigger \u2014 read `.blok/config.json` and pick the trigger by intent (Section 1).\n- Do NOT use `trigger: { queue: ... }` \u2014 it has no runtime and throws. Use `worker`.\n- Do NOT reuse a step `id` anywhere \u2014 including across `switch`/`branch`/`tryCatch` arms (all ids share one flat map; duplicates collide silently). Use `as:` if two arms must write the same downstream key.\n- Do NOT write to `ctx.state` inside a node's `execute()` \u2014 return your output; use `ctx.publish(name, value)` for a side-channel.\n- Do NOT assume `$.prev` (or `ctx.response.data`) survives more than one step \u2014 use `$.state.<id>` for cross-step reads.\n- Do NOT prefix `@blokjs/expr`'s `expression` input with `js/` \u2014 it double-evaluates. Write plain JS: `expression: \"ctx.state.x.y\"`.\n- Do NOT use `set_var` \u2014 removed in v0.5, throws at load.\n- Do NOT use `\"*\"` for the wildcard HTTP method \u2014 use `\"ANY\"`.\n- Do NOT generate class-based `BlokService` nodes or use `any` types \u2014 always `defineNode()` (TS) / `@node` (Python) with Zod/Pydantic schemas.\n- Do NOT use ESLint/Prettier \u2014 this project uses Biome. Do NOT edit auto-generated files in `.blok/runtimes/`.\n";
28
28
  declare const function_first_node_file = "import { defineNode } from \"@blokjs/core\";\nimport { z } from \"zod\";\n\n/**\n * A function-first node that demonstrates the modern defineNode pattern.\n * This node is type-safe, validated, and requires 60% less boilerplate.\n */\nexport default defineNode({\n\tname: \"{{NODE_NAME}}\",\n\tdescription: \"A function-first node with Zod validation\",\n\n\t// Input schema using Zod - automatically validated\n\tinput: z.object({\n\t\tmessage: z.string().optional().default(\"Hello World\"),\n\t}),\n\n\t// Output schema using Zod - automatically validated\n\toutput: z.object({\n\t\tmessage: z.string(),\n\t\ttimestamp: z.string(),\n\t}),\n\n\t// Execute function - type-safe with inferred types from Zod schemas\n\tasync execute(ctx, input) {\n\t\t// Your business logic here\n\t\t// - ctx.request: Access HTTP request data\n\t\t// - ctx.logger: Log messages\n\t\t// - ctx.env: Access environment variables\n\t\t//\n\t\t// Just RETURN your output \u2014 the runner auto-persists it to\n\t\t// ctx.state[\"{{NODE_NAME}}\"], readable from any later step's handle.\n\t\t// Do NOT write to ctx.state / ctx.vars inside a node.\n\n\t\t// Return type-safe output (validated automatically)\n\t\treturn {\n\t\t\tmessage: `Processed: ${input.message}`,\n\t\t\ttimestamp: new Date().toISOString(),\n\t\t};\n\t},\n});\n";
29
29
  export { node_file, package_dependencies, package_dev_dependencies, python3_file, examples_url, workflow_template, supervisord_nodejs, supervisord_python, go_node_file, java_node_file, rust_node_file, csharp_node_file, php_node_file, ruby_node_file, function_first_node_file, agents_md, claude_md, };