blokctl 0.7.0 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands/create/project.js +7 -2
- package/dist/commands/create/utils/Examples.d.ts +3 -3
- package/dist/commands/create/utils/Examples.js +68 -9
- package/dist/commands/generate/WorkflowGenerator.js +4 -4
- package/dist/commands/generate/WorkflowGenerator.test.js +2 -2
- package/dist/commands/generate/prompts/create-fn-node.system.js +10 -17
- package/dist/commands/generate/prompts/create-workflow.system.js +114 -375
- package/dist/commands/generate/validators/WorkflowValidator.js +80 -5
- package/dist/commands/generate/validators/WorkflowValidator.test.js +66 -0
- package/package.json +2 -2
|
@@ -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 = "
|
|
25
|
+
const GITHUB_REPO_RELEASE_TAG = "v1.0.0";
|
|
26
26
|
const RUNTIME_HELLO_EXAMPLES = {
|
|
27
27
|
go: "runtime-go-hello.ts",
|
|
28
28
|
rust: "runtime-rust-hello.ts",
|
|
@@ -549,8 +549,9 @@ export async function createProject(opts, version, currentPath = false, localRep
|
|
|
549
549
|
"@blokjs/trigger-webhook": "triggers/webhook",
|
|
550
550
|
"@blokjs/trigger-websocket": "triggers/websocket",
|
|
551
551
|
"@blokjs/trigger-worker": "triggers/worker",
|
|
552
|
+
"@blokjs/core": "core/core",
|
|
552
553
|
};
|
|
553
|
-
const BLOKJS_DEP_RANGE = "^0.
|
|
554
|
+
const BLOKJS_DEP_RANGE = "^1.0.0";
|
|
554
555
|
for (const depGroup of ["dependencies", "devDependencies", "peerDependencies"]) {
|
|
555
556
|
const deps = packageJsonContent[depGroup];
|
|
556
557
|
if (!deps)
|
|
@@ -606,6 +607,10 @@ export async function createProject(opts, version, currentPath = false, localRep
|
|
|
606
607
|
...packageJsonContent.devDependencies,
|
|
607
608
|
blokctl: blokctlRef,
|
|
608
609
|
};
|
|
610
|
+
packageJsonContent.dependencies = {
|
|
611
|
+
...packageJsonContent.dependencies,
|
|
612
|
+
"@blokjs/core": localRepoPath ? `file:${path.resolve(repoSource, "core/core")}` : BLOKJS_DEP_RANGE,
|
|
613
|
+
};
|
|
609
614
|
packageJsonContent.scripts = Object.fromEntries(Object.entries(packageJsonContent.scripts).filter(([s]) => s !== "test" && s !== "test:dev"));
|
|
610
615
|
packageJsonContent.devDependencies = Object.fromEntries(Object.entries(packageJsonContent.devDependencies).filter(([d]) => d !== "vitest" && !d.startsWith("@vitest/")));
|
|
611
616
|
const providerDeps = getProviderDependencies(selectedTriggers, pubsubProvider, queueProvider, explicitQueueProvider);
|
|
@@ -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 workflow form is `workflow({ name, version, trigger, steps })` from `@blokjs/helper`. The same shape works for all 9 triggers \u2014 only the `trigger:` block changes.\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/runner\";\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";
|
|
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/runner\";\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\nCanonical form: `workflow({ name, version, trigger, steps })` from `@blokjs/helper` \u2014 one object literal, no chained builder, no separate `nodes{}` map. `name` \u2265 3 chars, `version` \u2265 5 chars (semver). Reference earlier outputs with `$.state.<id>` / `$.req.body`. Use `branch`, `switchOn`, `forEach`, `loop`, `tryCatch` (all from `@blokjs/helper`) for control flow.\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
|
-
declare const function_first_node_file = "import { defineNode } from \"@blokjs/
|
|
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";
|
|
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
|
+
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, };
|
|
@@ -349,7 +349,52 @@ Blok is a **multi-trigger, multi-runtime workflow framework**. A workflow is a d
|
|
|
349
349
|
The 9 trigger types: \`http\`, \`worker\`, \`cron\`, \`pubsub\`, \`sse\`, \`websocket\`, \`webhook\`, \`mcp\`, \`grpc\`.
|
|
350
350
|
The 8 runtimes: \`typescript\` (in-process), \`go\`, \`rust\`, \`java\`, \`csharp\`, \`php\`, \`ruby\`, \`python3\`.
|
|
351
351
|
|
|
352
|
-
The canonical
|
|
352
|
+
The 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 — **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 — only the \`trigger:\` block changes.
|
|
353
|
+
|
|
354
|
+
---
|
|
355
|
+
|
|
356
|
+
## 0. THE TYPED-HANDLE DSL (\`@blokjs/core\`) — preferred for TypeScript
|
|
357
|
+
|
|
358
|
+
\`\`\`ts
|
|
359
|
+
import { workflow, step, branch, forEach, switchOn, tryCatch, http, tpl, gt } from "@blokjs/core";
|
|
360
|
+
|
|
361
|
+
export default workflow("order-intake", { version: "1.0.0", trigger: http.post("/orders") }, (req) => {
|
|
362
|
+
// step(id, node, inputs) → a TYPED handle shaped like the node's output.
|
|
363
|
+
const order = step("validate", validateOrder, { qty: req.body.qty });
|
|
364
|
+
|
|
365
|
+
// Reference a handle field anywhere downstream — it records a ref the runner resolves.
|
|
366
|
+
step("summary", summarize, { line: tpl\`order of \${order.qty} item(s)\` });
|
|
367
|
+
|
|
368
|
+
// Control flow: a comparator (gt/eq/lt/...) builds the condition; arms are callbacks.
|
|
369
|
+
branch("lane", gt(order.qty, 10), {
|
|
370
|
+
then: () => { step("bulk", routeOrder, { lane: "bulk" }); },
|
|
371
|
+
else: () => { step("standard", routeOrder, { lane: "standard" }); },
|
|
372
|
+
});
|
|
373
|
+
});
|
|
374
|
+
\`\`\`
|
|
375
|
+
|
|
376
|
+
**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:\` — e.g. \`{ worker: { queue: "jobs" } }\`, \`{ cron: { schedule: "0 2 * * *" } }\`, \`{ webhook: { source: "stripe" } }\`.
|
|
377
|
+
|
|
378
|
+
**The entry handle** (the callback's argument) is the trigger payload — typed and conventionally named per trigger: \`http\`→\`req\`, \`webhook\`→\`event\`, \`cron\`→\`tick\`, \`worker\`→\`job\`, \`pubsub\`→\`msg\`, \`grpc\`→\`rpc\` (others get a loose handle). Read \`req.body\`, \`req.params.id\`, \`req.query.q\`, \`req.headers["x-…"]\`.
|
|
379
|
+
|
|
380
|
+
**Handles & persistence:**
|
|
381
|
+
- \`const h = step("id", node, inputs)\` — every step auto-persists to \`ctx.state["id"]\` on success; \`h\` / \`h.field\` is how you reference it later (never \`$.state.id\`).
|
|
382
|
+
- 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>"\`.
|
|
383
|
+
- \`tpl\`…\${h.field}…\`\` builds a string with embedded handle refs.
|
|
384
|
+
- Comparators (typed): \`eq, ne, gt, gte, lt, lte, not\`. A bare boolean handle is a truthiness check.
|
|
385
|
+
- \`defineNode\` is re-exported from \`@blokjs/core\` — author nodes exactly as before; just RETURN output (never write \`ctx.state\`/\`ctx.vars\`).
|
|
386
|
+
|
|
387
|
+
**Control-flow primitives** (all from \`@blokjs/core\`):
|
|
388
|
+
- \`branch(id, cond, { then: () => {…}, else?: () => {…} })\` — \`cond\` is a comparator or a boolean handle.
|
|
389
|
+
- \`forEach(iterable, (item, index) => { step(…) }, { as?, mode?, concurrency? })\` — \`iterable\` is a handle; \`item\`/\`index\` are per-iteration handles; \`mode: "parallel"\` bounds with \`concurrency\` (default 10).
|
|
390
|
+
- \`switchOn(discriminant, { cases: [{ when: "push", do: () => {…} }], default?: () => {…} }, { id })\` — \`when\` labels are STATIC literals (a handle never matches).
|
|
391
|
+
- \`tryCatch(id, { try: () => {…}, catch: (error) => { step("log", logger, { msg: error.message }); }, finally?: () => {…} })\` — \`error\` is a typed handle (\`.message\`/\`.name\`/\`.stack\`/\`.code\`/\`.stepId\`).
|
|
392
|
+
|
|
393
|
+
**The four footguns** (full detail: \`docs/d/primitives/handles-and-footguns.mdx\`):
|
|
394
|
+
1. **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.
|
|
395
|
+
2. **Ephemeral handles are unreadable.** Reading an \`{ ephemeral: true }\` step's handle resolves to \`undefined\`.
|
|
396
|
+
3. **Never reuse a step \`id\`** — 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.
|
|
397
|
+
4. **Never name a \`forEach\` \`as:\`/\`asIndex\` after an existing step id** (or another loop's \`as\`) — they share \`ctx.state\`; the runner throws at load time.
|
|
353
398
|
|
|
354
399
|
---
|
|
355
400
|
|
|
@@ -858,7 +903,7 @@ Process-global middleware: \`WorkflowRegistry.getInstance().setGlobalMiddleware(
|
|
|
858
903
|
Always \`export default defineNode(...)\`. Never class-based \`BlokService\`. Zod input/output are mandatory.
|
|
859
904
|
|
|
860
905
|
\`\`\`ts
|
|
861
|
-
import { defineNode } from "@blokjs/
|
|
906
|
+
import { defineNode } from "@blokjs/core";
|
|
862
907
|
import { z } from "zod";
|
|
863
908
|
|
|
864
909
|
export default defineNode({
|
|
@@ -1334,7 +1379,7 @@ Per-step reliability lives on the step: \`idempotencyKey\` (cache by \`(workflow
|
|
|
1334
1379
|
Always \`export default defineNode(...)\` (TS) — never class-based \`BlokService\`. Zod input/output are mandatory. Never write \`ctx.state\` from a node — return your output and let the runner persist it (use \`ctx.publish(name, value)\` for a true side-channel). No \`any\` types — use \`z.unknown()\`.
|
|
1335
1380
|
|
|
1336
1381
|
\`\`\`typescript
|
|
1337
|
-
import { defineNode } from "@blokjs/
|
|
1382
|
+
import { defineNode } from "@blokjs/core";
|
|
1338
1383
|
import { z } from "zod";
|
|
1339
1384
|
|
|
1340
1385
|
export default defineNode({
|
|
@@ -1388,7 +1433,21 @@ Registration is **manual** in non-TS runtimes — importing the module runs the
|
|
|
1388
1433
|
|
|
1389
1434
|
## 4. Generating Workflows
|
|
1390
1435
|
|
|
1391
|
-
|
|
1436
|
+
**Preferred (TypeScript): the typed-handle DSL from \`@blokjs/core\`.** \`workflow(name, { version, trigger }, (entry) => { ... })\` — each \`step(id, node, inputs)\` returns a typed handle you reference directly; no \`$\`, no \`js/\`, no raw \`ctx\` strings. \`http.{get,post,…}(path?)\` builds the trigger; other triggers pass the raw block (\`{ worker: { queue } }\`, \`{ cron: { schedule } }\`, …). Entry name per trigger: \`http\`→\`req\`, \`worker\`→\`job\`, \`cron\`→\`tick\`, \`webhook\`→\`event\`, \`pubsub\`→\`msg\`, \`grpc\`→\`rpc\`.
|
|
1437
|
+
|
|
1438
|
+
\`\`\`typescript
|
|
1439
|
+
import { workflow, step, branch, gt, http } from "@blokjs/core";
|
|
1440
|
+
|
|
1441
|
+
export default workflow("Process Order", { version: "1.0.0", trigger: http.post("/orders") }, (req) => {
|
|
1442
|
+
const order = step("validate", orderValidator, { order: req.body }); // typed handle
|
|
1443
|
+
step("save", orderStore, { data: order }); // reference it directly
|
|
1444
|
+
branch("big", gt(order.total, 100), { then: () => { step("vip", flagVip, { id: order.id }); } });
|
|
1445
|
+
});
|
|
1446
|
+
\`\`\`
|
|
1447
|
+
|
|
1448
|
+
Control 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\`…\${h.x}…\`\` 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\`.
|
|
1449
|
+
|
|
1450
|
+
**Equivalent object-style form** (\`@blokjs/helper\`, also valid; JSON mirrors it): one object literal, \`steps: [...]\`, reference outputs with \`$.state.<id>\` / \`$.req.body\`.
|
|
1392
1451
|
|
|
1393
1452
|
\`\`\`typescript
|
|
1394
1453
|
import { workflow, $ } from "@blokjs/helper";
|
|
@@ -1461,7 +1520,7 @@ branch({ id: "route",
|
|
|
1461
1520
|
- Do NOT generate class-based \`BlokService\` nodes or use \`any\` types — always \`defineNode()\` (TS) / \`@node\` (Python) with Zod/Pydantic schemas.
|
|
1462
1521
|
- Do NOT use ESLint/Prettier — this project uses Biome. Do NOT edit auto-generated files in \`.blok/runtimes/\`.
|
|
1463
1522
|
`;
|
|
1464
|
-
const function_first_node_file = `import { defineNode } from "@blokjs/
|
|
1523
|
+
const function_first_node_file = `import { defineNode } from "@blokjs/core";
|
|
1465
1524
|
import { z } from "zod";
|
|
1466
1525
|
|
|
1467
1526
|
/**
|
|
@@ -1486,13 +1545,13 @@ export default defineNode({
|
|
|
1486
1545
|
// Execute function - type-safe with inferred types from Zod schemas
|
|
1487
1546
|
async execute(ctx, input) {
|
|
1488
1547
|
// Your business logic here
|
|
1489
|
-
// - ctx.vars: Access workflow variables
|
|
1490
1548
|
// - ctx.request: Access HTTP request data
|
|
1491
1549
|
// - ctx.logger: Log messages
|
|
1492
1550
|
// - ctx.env: Access environment variables
|
|
1493
|
-
|
|
1494
|
-
//
|
|
1495
|
-
ctx.
|
|
1551
|
+
//
|
|
1552
|
+
// Just RETURN your output — the runner auto-persists it to
|
|
1553
|
+
// ctx.state["{{NODE_NAME}}"], readable from any later step's handle.
|
|
1554
|
+
// Do NOT write to ctx.state / ctx.vars inside a node.
|
|
1496
1555
|
|
|
1497
1556
|
// Return type-safe output (validated automatically)
|
|
1498
1557
|
return {
|
|
@@ -101,11 +101,11 @@ export default class WorkflowGenerator {
|
|
|
101
101
|
"```",
|
|
102
102
|
"",
|
|
103
103
|
"Please fix these errors and regenerate the workflow JSON. Common fixes:",
|
|
104
|
-
"- Ensure every step
|
|
104
|
+
"- Ensure every step has a unique `id` and a `use` (or a `branch`) — inputs live inline; there is NO `nodes` map",
|
|
105
105
|
"- Ensure the trigger has exactly one trigger type with valid configuration",
|
|
106
|
-
"- Ensure
|
|
107
|
-
"-
|
|
108
|
-
"-
|
|
106
|
+
"- Ensure branch `when` is a raw `ctx.*` expression (never `js/` or `$.`)",
|
|
107
|
+
"- Reference earlier outputs with `$.state.<id>` and request data with `$.req.*`",
|
|
108
|
+
"- Use a single step with `branch: { when, then, else }` for conditionals (not a `conditions` array)",
|
|
109
109
|
].join("\n");
|
|
110
110
|
return feedback;
|
|
111
111
|
}
|
|
@@ -70,8 +70,8 @@ describe("WorkflowGenerator", () => {
|
|
|
70
70
|
});
|
|
71
71
|
it("should include common fix suggestions", () => {
|
|
72
72
|
const result = createFeedback("Test", "{}", ["Missing node entry"]);
|
|
73
|
-
expect(result).toContain("
|
|
74
|
-
expect(result).toContain("else
|
|
73
|
+
expect(result).toContain("unique `id`");
|
|
74
|
+
expect(result).toContain("branch: { when, then, else }");
|
|
75
75
|
});
|
|
76
76
|
});
|
|
77
77
|
});
|