blokctl 0.6.19 → 0.6.20

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.
@@ -74,6 +74,7 @@ Examples:
74
74
  7- Webhook router: POST /webhooks/{stripe,github,linear} with signed bodies — set the matching *_WEBHOOK_SECRET env vars (needs --triggers webhook)
75
75
  8- LLM agent w/ tool calls: open http://localhost:4000/agent — model picks between get_weather and calculate tools (needs OPENROUTER_API_KEY + Redis)
76
76
  9- Worker fan-out: POST /fanout/jobs with body '{items:[...], tenantId?:"..."}' to enqueue N worker jobs (needs --triggers worker; BLOK_WORKER_ADAPTER=in-memory works single-process)
77
+ 10- Trigger references (NOT http): workflows/json/{cron-heartbeat,pubsub-on-order,websocket-echo}.json demonstrate the cron, pubsub, and websocket triggers — read AGENTS.md "Choosing a trigger" to pick the right one by intent instead of defaulting to HTTP.
77
78
 
78
79
  For more documentation, visit src/nodes/examples/README.md. The first three examples require a PostgreSQL database to function.
79
80
  `;
@@ -611,470 +612,1128 @@ HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \\
611
612
 
612
613
  CMD ["bundle", "exec", "puma", "-b", "tcp://0.0.0.0:8080"]
613
614
  `;
614
- const agents_md = `# Blok Project
615
+ const agents_md = `
616
+ # AGENTS.md — Blok Framework AI Context
615
617
 
616
- Blok is a TypeScript-first workflow orchestration framework. It executes declarative workflows (JSON or TypeScript DSL) composed of steps (nodes) that run across 8 language runtimes: NodeJS, Python3, Go, Rust, Java, C#, PHP, and Ruby.
618
+ Blok 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:
617
619
 
618
- ## Project Structure
620
+ - **HTTP is ONE of 9 triggers, NOT the default.** Every workflow declares exactly one trigger. Picking \`http\` reflexively is the most common mistake — start with the decision table below.
621
+ - **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>"\`.
619
622
 
623
+ The 9 trigger types: \`http\`, \`worker\`, \`cron\`, \`pubsub\`, \`sse\`, \`websocket\`, \`webhook\`, \`mcp\`, \`grpc\`.
624
+ The 8 runtimes: \`typescript\` (in-process), \`go\`, \`rust\`, \`java\`, \`csharp\`, \`php\`, \`ruby\`, \`python3\`.
625
+
626
+ The canonical workflow form is \`workflow({ name, version, trigger, steps })\` from \`@blokjs/helper\`. The same shape works for all 9 triggers — only the \`trigger:\` block changes.
627
+
628
+ ---
629
+
630
+ ## 1. CHOOSING A TRIGGER (do this first, every time)
631
+
632
+ **Before writing \`trigger: { http: ... }\`, read this table and pick by intent.**
633
+
634
+ | Intent / what you're building | Trigger | Why NOT http |
635
+ |---|---|---|
636
+ | Respond to an HTTP/REST request; JSON API; HTML page; file download | **\`http\`** | — |
637
+ | Process a background / queued / async job; offload slow work | **\`worker\`** | http blocks the caller; jobs need a queue + retries + DLQ |
638
+ | Run on a schedule / recurring time-based job (nightly, hourly, cron) | **\`cron\`** | http only fires on a request; nothing calls it on a timer |
639
+ | React to messages on a cloud topic/subscription (cross-service events) | **\`pubsub\`** | http isn't subscribed to a broker; events would be dropped |
640
+ | 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 |
641
+ | Bidirectional realtime (chat rooms, live cursors, client↔server messages) | **\`websocket\`** | http is half-duplex request/response, no server push back-channel |
642
+ | Receive a signed provider webhook (Stripe / GitHub / Slack / Shopify / Svix / custom HMAC) | **\`webhook\`** | http won't verify the HMAC signature or do replay protection |
643
+ | 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 |
644
+ | High-throughput typed RPC between services with a proto contract | **\`grpc\`** | http/REST overhead is too high; no typed contract |
645
+
646
+ **Tie-breakers:**
647
+ - **One-way stream → \`sse\`; two-way → \`websocket\`.** SSE is cheaper and simpler; reach for \`websocket\` only when the client must send messages back over the same connection.
648
+ - **In-process pub/sub (single Node process, HTTP+SSE chains) → the \`sse\` bus, NOT \`pubsub\`.** \`pubsub\` is the multi-process / multi-cloud sibling backed by an external broker.
649
+ - **Queue consumer → \`worker\`, never \`queue\`.** \`trigger.queue\` is **DEAD** — it has a schema but no runtime and throws at workflow construction time. Always use \`worker\` (\`{ worker: { queue: "<name>" } }\`).
650
+
651
+ ### Read \`.blok/config.json\` first
652
+
653
+ The project records which triggers and runtimes were actually scaffolded in **\`.blok/config.json\`**. **Author for those — 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/\`.
654
+
655
+ ### Same-port vs cross-process families
656
+
657
+ - **Same-port family** — \`http\`, \`sse\`, \`websocket\`, \`webhook\`, \`mcp\` all mount on the **same Hono HTTP server / port** (default 4000) and share an in-process event bus.
658
+ - **Cross-process family** — \`worker\`, \`cron\`, \`pubsub\`, \`grpc\` each run in their **own Node process** and coordinate via external brokers / their own ports.
659
+
660
+ Regardless of kind, every trigger populates \`ctx.request.{body,headers,params,query,method}\`, so the workflow body is structurally identical across triggers — only the \`trigger:\` block differs.
661
+
662
+ ---
663
+
664
+ ## 2. THE 9 TRIGGERS
665
+
666
+ Each trigger below: one-line purpose, USE-WHEN / DON'T, config shape, and a canonical \`workflow({...})\` example.
667
+
668
+ ### 2.1 HTTP — \`trigger: { http: {...} }\`
669
+
670
+ **Purpose:** Turn a workflow into an inbound HTTP/REST endpoint. Owns the listening server (default port 4000) that sse/websocket/webhook/mcp mount onto.
671
+
672
+ **USE WHEN:** synchronous request→response; JSON APIs; HTML UI (\`accept: "text/html"\`); file downloads. **DON'T USE FOR:** background jobs (→\`worker\`), scheduled work (→\`cron\`), broker events (→\`pubsub\`), live push (→\`sse\`/\`websocket\`), signed callbacks (→\`webhook\`).
673
+
674
+ \`\`\`ts
675
+ trigger: { http: {
676
+ method: "GET"|"POST"|"PUT"|"DELETE"|"PATCH"|"HEAD"|"OPTIONS"|"ANY", // required; use "ANY" not "*"
677
+ path?: string, // optional; omit → derived from file path
678
+ accept?: string, // default "application/json"; "text/html" for UI
679
+ headers?: Record<string,string>, // required-headers gate; missing → 400 before any step
680
+ middleware?: string[],
681
+ // shared concurrency/scheduling: concurrencyKey, concurrencyLimit, onLimit, delay, ttl, debounce
682
+ }}
683
+ \`\`\`
684
+
685
+ \`\`\`ts
686
+ import { workflow, $ } from "@blokjs/helper";
687
+
688
+ export default workflow({
689
+ name: "Get User", version: "1.0.0",
690
+ trigger: { http: { method: "GET", path: "/users/:id" } },
691
+ steps: [
692
+ { id: "lookup", use: "@blokjs/api-call",
693
+ inputs: { url: "js/\`https://internal/users/\${ctx.request.params.id}\`" } },
694
+ { id: "respond", use: "@blokjs/respond", inputs: { body: $.state.lookup }, ephemeral: true },
695
+ ],
696
+ });
620
697
  \`\`\`
621
- ├── src/
622
- │ └── nodes/ # TypeScript node implementations
623
- ├── runtimes/ # Non-NodeJS runtime nodes (Go, Python3, etc.)
624
- │ └── {lang}/nodes/ # Language-specific node implementations
625
- ├── workflows/
626
- │ ├── json/ # Workflow definitions (JSON)
627
- │ ├── yaml/ # Workflow definitions (YAML)
628
- │ └── toml/ # Workflow definitions (TOML)
629
- ├── .blok/
630
- │ ├── config.json # Runtime configuration (ports, start commands)
631
- │ └── runtimes/ # Auto-generated runtime scaffolds
632
- ├── .env.local # Environment variables (ports, paths)
633
- └── supervisord.conf # Process management config
698
+
699
+ ### 2.2 WORKER \`trigger: { worker: {...} }\`
700
+
701
+ **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 — \`queue\` is dead.**
702
+
703
+ **USE WHEN:** offloading slow/async work; queue consumers; fan-out job processing. **DON'T USE FOR:** synchronous responses (→\`http\`); time schedules (→\`cron\`); cloud fan-out topics (→\`pubsub\`).
704
+
705
+ \`\`\`ts
706
+ trigger: { worker: {
707
+ queue: string, // required queue/topic/stream name
708
+ provider?: "in-memory"|"nats"|"bullmq"|"kafka"|"rabbitmq"|"sqs"|"redis"|"pg-boss", // default in-memory
709
+ concurrency?: number, // default 1 concurrent jobs per process
710
+ timeout?: number, // ms per-attempt hard timeout
711
+ retries?: number, // default 3 — then DLQ
712
+ priority?: number, consumerGroup?: string, ack?: boolean,
713
+ deadLetterQueue?: string, fromBeginning?: boolean,
714
+ // shared concurrency/scheduling: concurrencyKey, concurrencyLimit, onLimit, delay, ttl, debounce, middleware
715
+ }}
634
716
  \`\`\`
635
717
 
636
- ## Commands
718
+ \`\`\`ts
719
+ import { workflow } from "@blokjs/helper";
637
720
 
638
- \`\`\`bash
639
- npm run dev # Start dev server (or blokctl dev for multi-runtime)
640
- npm run build # Build project
641
- npm test # Run tests
642
- blokctl create node <name> # Scaffold a new node
643
- blokctl create workflow <n># Scaffold a new workflow
644
- blokctl trace # Open Blok Studio (trace visualization)
645
- blokctl studio # Alias for blokctl trace
721
+ export default workflow({
722
+ name: "Process Background Job", version: "1.0.0",
723
+ trigger: { worker: { queue: "background-jobs" } },
724
+ steps: [
725
+ { id: "process-job", use: "@blokjs/api-call", type: "module",
726
+ inputs: { url: "https://example.com/process", method: "POST", body: "js/ctx.request.body" } },
727
+ ],
728
+ });
646
729
  \`\`\`
647
730
 
648
- ## Context Critical Data Flow
731
+ **Worker context mapping:** \`ctx.request.body\` job payload; \`ctx.request.params.{queue,jobId,attempt}\` → job metadata; \`ctx.vars._worker_job\` → 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\`).
649
732
 
650
- The Context type is the central execution state passed through every step.
733
+ ### 2.3 CRON \`trigger: { cron: {...} }\`
651
734
 
652
- \`\`\`typescript
653
- type Context = {
654
- id: string; // Unique request ID
655
- request: RequestContext; // Incoming request (body, headers, params, query)
656
- response: ResponseContext; // Current step output — OVERWRITTEN every step
657
- vars?: VarsContext; // Persistent variables — PERSISTS across workflow
658
- config: ConfigContext; // Node config (inputs resolved by Mapper)
659
- env?: EnvContext; // process.env access
660
- logger: LoggerContext;
661
- error: ErrorContext;
662
- };
735
+ **Purpose:** Run a workflow on a time schedule (standard cron expression). Dedicated process.
736
+
737
+ **USE WHEN:** recurring/scheduled work — nightly cleanup, hourly polls, daily digests, periodic syncs. **DON'T USE FOR:** anything triggered by an external event or request.
738
+
739
+ \`\`\`ts
740
+ trigger: { cron: {
741
+ schedule: string, // required "m h dom mon dow", e.g. "0 2 * * *"
742
+ timezone?: string, // default "UTC" — IANA tz e.g. "America/New_York"
743
+ overlap?: boolean, // default false — allow overlapping executions
744
+ // also: concurrencyKey, concurrencyLimit, middleware
745
+ }}
746
+ \`\`\`
747
+
748
+ \`\`\`ts
749
+ import { workflow } from "@blokjs/helper";
750
+
751
+ export default workflow({
752
+ name: "Daily Cleanup", version: "1.0.0",
753
+ trigger: { cron: { schedule: "0 2 * * *", timezone: "America/New_York" } },
754
+ steps: [
755
+ { id: "purge-stale", use: "@blokjs/api-call",
756
+ inputs: { url: "https://api.example.com/cleanup", method: "POST" } },
757
+ ],
758
+ });
663
759
  \`\`\`
664
760
 
665
- ### The Two Critical Rules
761
+ \`ctx.request.body\` is \`{}\`; fire metadata is on \`ctx.request.params.{schedule,firedAt}\`. To serialize overlapping runs use \`concurrencyKey: "self"\`, \`concurrencyLimit: 1\`.
666
762
 
667
- **Rule 1: \\\`ctx.prev\\\` carries the immediately previous step's output.**
668
- Each step's output replaces \\\`ctx.prev\\\`. Use it for adjacent-step access only.
763
+ ### 2.4 PUBSUB \`trigger: { pubsub: {...} }\`
669
764
 
670
- **Rule 2: \\\`ctx.state[id]\\\` PERSISTS across the entire workflow.**
671
- Every step's output is auto-stored at \\\`ctx.state[<step-id>]\\\` (the v2 default-store rule). Downstream steps reference it via \\\`$.state.<id>\\\` (TS DSL) or \\\`"$.state.<id>"\\\` / \\\`"js/ctx.state.<id>"\\\` (JSON). Opt out per step with \\\`ephemeral: true\\\`.
765
+ **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.
672
766
 
673
- ### Data Flow Example
767
+ **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 (→\`sse\` bus); plain job queues with competing consumers + retries (→\`worker\`).
674
768
 
769
+ \`\`\`ts
770
+ trigger: { pubsub: {
771
+ provider?: "nats"|"redis-streams"|"kafka"|"gcp"|"aws"|"azure", // default BLOK_PUBSUB_ADAPTER
772
+ topic: string, // required — topic/subject/stream (wildcards ok: "orders.*.created")
773
+ subscription?: string, // required for gcp/aws/azure; derived from consumerGroup otherwise
774
+ consumerGroup?: string, // set → competing-consumer; unset → fan-out
775
+ durable?: boolean, startFrom?: "earliest"|"latest"|{seq:number}|{timestamp:number},
776
+ ack?: boolean, // default true
777
+ maxMessages?: number, // default 10
778
+ ackDeadline?: number, // default 30 (s)
779
+ deadLetterTopic?: string, filter?: string,
780
+ }}
675
781
  \`\`\`
676
- Step 1: id "fetch-user"
677
- → ctx.state["fetch-user"] = { id: "123", name: "Alice" }
678
- → ctx.prev = { id: "123", name: "Alice" }
679
782
 
680
- Step 2: id "transform"
681
- ctx.state["transform"] = { result: "done" }
682
- → ctx.prev = { result: "done" } ← Step 1 output GONE from prev
683
- → ctx.state["fetch-user"] still available
783
+ \`\`\`ts
784
+ import { workflow } from "@blokjs/helper";
684
785
 
685
- Step 3: id "output"
686
- Can read ctx.state["fetch-user"].name ← still "Alice"
786
+ export default workflow({
787
+ name: "On Order Placed", version: "1.0.0",
788
+ trigger: { pubsub: { provider: "gcp", topic: "orders.placed", subscription: "fulfillment-svc" } },
789
+ steps: [
790
+ { id: "fulfill", use: "@blokjs/api-call",
791
+ idempotencyKey: "js/ctx.request.params.messageId", // dedup redeliveries
792
+ inputs: { url: "https://fulfillment.internal/api/orders", method: "POST", body: "js/ctx.request.body" } },
793
+ ],
794
+ });
687
795
  \`\`\`
688
796
 
689
- ### Blueprint MapperExpression Resolution
797
+ \`messageId\` on \`ctx.request.params\` is the natural \`idempotencyKey\`. Provider env vars GCP: \`GOOGLE_APPLICATION_CREDENTIALS\`+\`PUBSUB_PROJECT_ID\`; AWS: standard credential chain (\`topic\`=SNS ARN, \`subscription\`=SQS URL); Azure: \`AZURE_SERVICEBUS_CONNECTION_STRING\`.
690
798
 
691
- Node inputs support dynamic expressions resolved BEFORE node execution:
799
+ ### 2.5 SSE \`trigger: { sse: {...} }\`
692
800
 
693
- \`\`\`json
694
- {
695
- "inputs": {
696
- "userId": "js/ctx.request.body.userId",
697
- "chain": "js/ctx.vars['previous-step'].chain",
698
- "previous": "js/ctx.response.data.result"
699
- }
700
- }
801
+ **Purpose:** One-way server→browser streaming via \`EventSource\`. Mounts on the shared HTTP port; pumps in-process bus events to connected clients.
802
+
803
+ **USE WHEN:** pushing live updates one-way — token streaming from an LLM, progress feeds, notification streams. **DON'T USE FOR:** client→server messages (→\`websocket\`); one-shot JSON responses (→\`http\`).
804
+
805
+ \`\`\`ts
806
+ trigger: { sse: {
807
+ path?: string, // URL path; supports :params (e.g. "/sse/chat/:sessionId")
808
+ events?: string[], // default ["*"]
809
+ channels?: string[],
810
+ maxConnections?: number, // default 10000
811
+ heartbeatInterval?: number, // default 30000 ms
812
+ retryInterval?: number, // default 3000 ms (browser reconnect hint)
813
+ // also: concurrencyKey, concurrencyLimit
814
+ }}
815
+ \`\`\`
816
+
817
+ \`\`\`ts
818
+ import { workflow } from "@blokjs/helper";
819
+
820
+ export default workflow({
821
+ name: "Clock Stream", version: "1.0.0",
822
+ trigger: { sse: { path: "/sse/clock", heartbeatInterval: 15000 } },
823
+ steps: [
824
+ { id: "sub", use: "@blokjs/sse-subscribe", inputs: { channels: ["clock"] } },
825
+ { id: "stream", use: "@blokjs/sse-stream", inputs: { source: "js/ctx.state.sub" } },
826
+ ],
827
+ });
828
+ \`\`\`
829
+
830
+ A sibling HTTP workflow publishes via \`@blokjs/sse-publish\`; both share the in-process bus. Cross-process needs a Redis pub/sub backplane.
831
+
832
+ ### 2.6 WEBSOCKET — \`trigger: { websocket: {...} }\`
833
+
834
+ **Purpose:** Bidirectional WS connections; one workflow run per inbound message/lifecycle event. Mounts on the shared HTTP port via Hono \`upgradeWebSocket\`.
835
+
836
+ **USE WHEN:** two-way realtime — chat rooms, live cursors, RPC-over-WS, server-pushed updates the client also writes to. **DON'T USE FOR:** one-way push (→\`sse\` is cheaper); request/response (→\`http\`).
837
+
838
+ \`\`\`ts
839
+ trigger: { websocket: {
840
+ path?: string, // URL path; supports :params (e.g. "/ws/room/:roomId")
841
+ events?: string[], // default ["*"]; supported: "open"|"message"|"close"|"error"
842
+ rooms?: string[],
843
+ maxConnections?: number, // default 10000
844
+ heartbeatInterval?: number, // default 30000 ms
845
+ messageRateLimit?: number, // default 100 msgs/sec/client
846
+ // also: concurrencyKey, concurrencyLimit
847
+ }}
848
+ \`\`\`
849
+
850
+ \`\`\`ts
851
+ import { workflow } from "@blokjs/helper";
852
+
853
+ export default workflow({
854
+ name: "WS Echo", version: "1.0.0",
855
+ trigger: { websocket: { path: "/ws/echo", events: ["message", "open", "close"] } },
856
+ steps: [
857
+ { id: "reply", use: "@blokjs/ws-reply",
858
+ inputs: { message: "js/({ echo: ctx.request.body, at: Date.now() })" } },
859
+ ],
860
+ });
701
861
  \`\`\`
702
862
 
703
- Available in js/ expressions: \\\`ctx\\\` (full context), \\\`data\\\` (ctx.prev.data), \\\`func\\\` (ctx.func), \\\`vars\\\` (alias for ctx.state).
863
+ Helpers: \`@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\`.
864
+
865
+ ### 2.7 WEBHOOK — \`trigger: { webhook: {...} }\`
866
+
867
+ **Purpose:** Receive signed provider POSTs, verify the HMAC signature, apply replay protection, then dispatch. Mounts on the shared HTTP port.
868
+
869
+ **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 (→\`http\`).
870
+
871
+ \`\`\`ts
872
+ trigger: { webhook: {
873
+ provider?: "github"|"stripe"|"slack"|"shopify"|"svix", // pick this OR signature, not both
874
+ path?: string, // defaults to /webhooks/<provider>
875
+ secretEnv?: string, // env var name holding the shared secret (never inline the secret)
876
+ events?: string[], // allowlist; out-of-scope → 200 {status:"ignored"}
877
+ tolerance?: number, // seconds, default 300 — clock-skew window
878
+ idempotencyKey?: string, // e.g. "js/ctx.request.body.id" — replay protection
879
+ namespace?: string, // prefix for polymorphic subworkflow dispatch
880
+ middleware?: string[],
881
+ signature?: { // custom HMAC for non-built-in providers
882
+ scheme?: "hmac-sha256"|"hmac-sha1"|"hmac-sha512", // default sha256
883
+ header: string, format?: string, // format default "{hex}"; "{hex}"/"{base64}"
884
+ secretEnv: string, tolerance?: number, timestampHeader?: string,
885
+ },
886
+ }}
887
+ \`\`\`
888
+
889
+ \`\`\`ts
890
+ import { workflow } from "@blokjs/helper";
891
+
892
+ export default workflow({
893
+ name: "Stripe Webhook", version: "1.0.0",
894
+ trigger: { webhook: {
895
+ provider: "stripe", namespace: "stripe",
896
+ secretEnv: "STRIPE_WEBHOOK_SECRET", idempotencyKey: "js/ctx.request.body.id",
897
+ }},
898
+ steps: [
899
+ { id: "dispatch", subworkflow: "js/ctx.request.body.type", // "invoice.paid" → "stripe.invoice.paid"
900
+ inputs: { stripeEvent: "js/ctx.request.body" } },
901
+ ],
902
+ });
903
+ \`\`\`
904
+
905
+ Bad signature → 401 with a structured \`reason\`; duplicate → 200 \`{status:"duplicate"}\`; a workflow throw still returns 200 (senders shouldn't retry). \`ctx.request.rawBody\` carries the bytes the HMAC was computed against.
906
+
907
+ ### 2.8 MCP — \`trigger: { mcp: {...} }\`
908
+
909
+ **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.
910
+
911
+ **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 (→\`http\`).
912
+
913
+ \`\`\`ts
914
+ trigger: { mcp: {
915
+ path?: string, // default "/mcp"
916
+ serverName?: string, // default "blok-mcp"; workflows sharing path+serverName aggregate
917
+ serverVersion?: string, // default "1.0.0"
918
+ transports?: ("sse"|"streamable-http")[], // default both
919
+ tool?: { name?: string, description?: string },
920
+ resource?: { uri: string, name?: string, description?: string, mimeType?: string }, // expose as resource
921
+ middleware?: string[],
922
+ }}
923
+ \`\`\`
924
+
925
+ **Requires a workflow-level \`input:\` Zod schema** — that becomes the tool's \`inputSchema\`:
926
+
927
+ \`\`\`ts
928
+ import { workflow, $ } from "@blokjs/helper";
929
+ import { z } from "zod";
930
+
931
+ export default workflow({
932
+ name: "search_code", version: "1.0.0",
933
+ input: z.object({ query: z.string(), limit: z.number().optional() }), // → tool inputSchema
934
+ trigger: { mcp: { path: "/mcp", serverName: "my-platform",
935
+ tool: { description: "Full-text search the indexed code" } } },
936
+ steps: [ { id: "search", use: "@my/search", inputs: { query: $.req.body.query } } ],
937
+ });
938
+ \`\`\`
939
+
940
+ Serves over SSE (\`GET <path>/sse\` + \`POST <path>/messages\`) and/or Streamable-HTTP (\`<path>\`).
941
+
942
+ **Connecting a client** — 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):
943
+ - **Claude Code:** \`claude mcp add --transport http blok http://localhost:4000/mcp\`
944
+ - **Cursor** (\`.cursor/mcp.json\`): \`{ "mcpServers": { "blok": { "url": "http://localhost:4000/mcp" } } }\`
945
+ - **Quick test:** \`npx @modelcontextprotocol/inspector\` → connect to \`http://localhost:4000/mcp\`
946
+
947
+ \`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 — scope access yourself.
948
+
949
+ ### 2.9 GRPC — \`trigger: { grpc: {...} }\`
950
+
951
+ **Purpose:** Expose a workflow as a gRPC service method handler — typed, contract-based RPC. Dedicated process bound to a gRPC port.
952
+
953
+ **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 (→\`http\`); async work (→\`worker\`).
954
+
955
+ > Caveat: gRPC config is **not Zod-validated** at construction. Author against the documented surface:
956
+
957
+ \`\`\`ts
958
+ trigger: { grpc: {
959
+ service: string, // matches \`service Foo {}\` in the proto
960
+ method: string, // matches \`rpc Bar(...) returns (...)\`
961
+ proto: string, // path to the .proto file, relative to the workflow
962
+ port?: number, // default 50051; all grpc workflows share one port (env GRPC_PORT)
963
+ middleware?: string[],
964
+ }}
965
+ \`\`\`
966
+
967
+ \`\`\`ts
968
+ import { workflow } from "@blokjs/helper";
969
+
970
+ export default workflow({
971
+ name: "GetUser", version: "1.0.0",
972
+ trigger: { grpc: { service: "UserService", method: "GetUser", proto: "users.proto" } },
973
+ steps: [
974
+ { id: "lookup", use: "@blokjs/api-call",
975
+ inputs: { url: "js/\`https://internal/users/\${ctx.request.body.userId}\`", method: "GET" } },
976
+ ],
977
+ });
978
+ \`\`\`
979
+
980
+ The request decodes into \`ctx.request.body\`; the final step output becomes the gRPC reply. Streaming RPCs use \`@blokjs/grpc-stream\`.
981
+
982
+ > **\`trigger.queue\` is DEAD** — it has a schema but no runtime and throws at workflow construction. Use \`worker\`. **\`manual\`** has no listener (invoked programmatically only — tests / sub-workflows); not for normal authoring.
704
983
 
705
984
  ---
706
985
 
707
- ## Creating Nodes with defineNode
986
+ ## 3. AUTHORING WORKFLOWS (v2 DSL)
708
987
 
709
- Use \\\`defineNode()\\\` for all new nodes. Never use the legacy class-based pattern.
988
+ Import from \`@blokjs/helper\`: \`{ workflow, $, branch, switchOn, forEach, loop, tryCatch }\`. The default export is \`workflow({...})\` a single object literal, no chaining, no separate \`nodes{}\` map.
710
989
 
711
- \`\`\`typescript
990
+ \`\`\`ts
991
+ import { workflow, $ } from "@blokjs/helper";
992
+
993
+ export default workflow({
994
+ name: "Process Order", // >= 3 chars
995
+ version: "1.0.0", // semver x.x.x (>= 5 chars)
996
+ trigger: { http: { method: "POST", path: "/orders" } }, // path optional → derived from file path
997
+ steps: [
998
+ { id: "validate", use: "order-validator", inputs: { order: $.req.body } },
999
+ { id: "save", use: "order-store", inputs: { data: $.state.validate } },
1000
+ ],
1001
+ });
1002
+ \`\`\`
1003
+
1004
+ A 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).
1005
+
1006
+ ### The four context reads
1007
+
1008
+ | Read | Resolves to | Scope |
1009
+ |---|---|---|
1010
+ | \`$.state.<id>\` | A prior step's stored output | Whole workflow (cross-step) |
1011
+ | \`$.prev\` | Immediately previous step's output | Adjacent only — overwritten every step |
1012
+ | \`$.req\` | Request envelope (body/headers/params/query/method/url) | Whole run |
1013
+ | \`$.error\` | Captured error inside a \`tryCatch.catch\` block | \`catch\` arm only — \`undefined\` elsewhere |
1014
+
1015
+ \`$.error\` exposes \`.message\`, \`.name\`, \`.stack\`, \`.code\` (upstream HTTP status), and \`.stepId\`. The \`$\` proxy compiles to \`"js/ctx.<path>"\` strings at definition time — 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\` — prefer the canonical four.
1016
+
1017
+ ### Persistence knobs (per-step, declarative)
1018
+
1019
+ | Knob | Effect |
1020
+ |---|---|
1021
+ | *(none)* | Store at \`ctx.state[id]\` (the 95% case) |
1022
+ | \`as: "name"\` | Store at \`ctx.state[name]\` instead of \`ctx.state[id]\` |
1023
+ | \`spread: true\` | Shallow-merge \`result.data\`'s top-level keys into \`ctx.state\` (multi-output nodes). Mutually exclusive with \`as\` |
1024
+ | \`ephemeral: true\` | Skip storage — only \`$.prev\` carries it to the next step (logging, audit) |
1025
+
1026
+ **Every step's output auto-persists to \`ctx.state[id]\` — 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.
1027
+
1028
+ ### Control-flow primitives
1029
+
1030
+ **\`branch({when, then, else})\`** — \`when\` is a JS-expression *string* (the \`$\` proxy can't intercept \`===\`):
1031
+
1032
+ \`\`\`ts
1033
+ branch({ id: "route",
1034
+ when: '$.req.method === "POST"',
1035
+ then: [{ id: "create", use: "...", inputs: {...} }],
1036
+ else: [{ id: "read", use: "...", inputs: {...} }] })
1037
+ \`\`\`
1038
+
1039
+ **\`switchOn({id, on, cases, default?})\`** — N-way branch, first match wins. \`when\` may be a scalar (\`on === when\`) or an array (\`array.includes(on)\`):
1040
+
1041
+ \`\`\`ts
1042
+ switchOn({ id: "route-by-event", on: $.req.headers["x-github-event"],
1043
+ cases: [
1044
+ { when: "push", do: [{ id: "h1", subworkflow: "handle-push" }] },
1045
+ { when: ["pull_request", "pr_review"], do: [{ id: "h2", subworkflow: "handle-pr" }] },
1046
+ ],
1047
+ default: [{ id: "log", use: "@blokjs/log", inputs: { message: "unknown" } }] })
1048
+ \`\`\`
1049
+
1050
+ **\`forEach({id, in, as, do, mode?, concurrency?})\`** — 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):
1051
+
1052
+ \`\`\`ts
1053
+ forEach({ id: "process-items", in: $.req.body.items, as: "item",
1054
+ mode: "parallel", concurrency: 5,
1055
+ do: [{ id: "reserve", use: "inventory-reserve", inputs: { sku: $.state.item.sku } }] })
1056
+ \`\`\`
1057
+
1058
+ **\`loop({id, while, do, maxIterations?})\`** — while-loop, hard cap default 1000.
1059
+ **\`tryCatch({id, try, catch, finally?})\`** — \`catch\` sees \`$.error\`; errors in \`catch\` propagate (don't re-trigger \`catch\`); \`finally\` runs unconditionally.
1060
+ **\`{ id, wait: { for: "3d" } | { until: <date> } }\`** — durable pause; cannot combine with \`idempotencyKey\` or \`retry\`.
1061
+
1062
+ ### Caching, retry, sub-workflows (per-step)
1063
+
1064
+ \`\`\`ts
1065
+ { id: "fetch", use: "@blokjs/api-call", inputs: { url: "..." },
1066
+ idempotencyKey: $.req.body.requestId, // cache by (workflow, step.id, key); default TTL 24h
1067
+ retry: { maxAttempts: 3, minTimeoutInMs: 500, maxTimeoutInMs: 10000, factor: 2 },
1068
+ maxDuration: "30s" } // per-attempt timeout; final-attempt timeout → run "timedOut"
1069
+ \`\`\`
1070
+
1071
+ A 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.
1072
+
1073
+ **Sub-workflow as a step:**
1074
+
1075
+ \`\`\`ts
1076
+ { id: "send-receipt", subworkflow: "send-receipt-email",
1077
+ inputs: { user: $.state.user }, // becomes child's ctx.request.body (read via $.req.body)
1078
+ wait: true } // default: parent blocks, child response lands at state[id]
1079
+ \`\`\`
1080
+
1081
+ \`wait: false\` = fire-and-forget, returns \`{runId, workflowName, scheduledAt}\`. \`subworkflow:\` also accepts a \`$.<path>\`/\`js/...\` expression for polymorphic dispatch — pair with \`allowList: [...]\` whenever it depends on caller data. Recursion capped at 10 (\`BLOK_MAX_SUBWORKFLOW_DEPTH\`).
1082
+
1083
+ ### JSON workflows
1084
+
1085
+ JSON 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).
1086
+
1087
+ ---
1088
+
1089
+ ## 4. TRIGGER-LEVEL OPTIONS (across kinds)
1090
+
1091
+ These live on the **trigger config**, never on a step. They gate workflow entry.
1092
+
1093
+ **Per-key concurrency gating** — \`concurrencyKey\` (+ optional \`concurrencyLimit\` default 1, \`onLimit: "throw"|"queue"\`):
1094
+
1095
+ \`\`\`ts
1096
+ trigger: { http: { method: "POST", path: "/render",
1097
+ concurrencyKey: $.req.body.tenantId, concurrencyLimit: 5, onLimit: "queue" } }
1098
+ \`\`\`
1099
+
1100
+ \`concurrencyLimit\`/\`onLimit\`/\`concurrencyLeaseMs\` all require \`concurrencyKey\`. Denial → HTTP 429 + \`Retry-After\` (or 202 with \`onLimit: "queue"\`).
1101
+
1102
+ **Scheduling** — \`delay\`, \`ttl\`, \`debounce\`. Durations are a number (ms) or a unit string (\`"500ms"\`,\`"30s"\`,\`"5m"\`,\`"2h"\`,\`"1d"\`):
1103
+
1104
+ \`\`\`ts
1105
+ trigger: { http: { method: "POST", path: "/welcome", delay: "1h", ttl: "2h" } }
1106
+ trigger: { http: { method: "POST", path: "/save/:docId",
1107
+ debounce: { key: $.req.params.docId, mode: "trailing", delay: "500ms", maxDelay: "5s" } } }
1108
+ \`\`\`
1109
+
1110
+ For HTTP, \`ttl\` requires \`delay\`. Debounce modes: \`trailing\` (default — fire after silence) / \`leading\` (fire first, suppress follow-ups).
1111
+
1112
+ **Middleware** — two forms:
1113
+
1114
+ 1. *Trigger-level chain* — ordered middleware-workflow names, run before the body on the same ctx:
1115
+ \`\`\`ts
1116
+ trigger: { http: { method: "GET", middleware: ["auth-check", "request-id"] } }
1117
+ \`\`\`
1118
+ 2. *Defining a middleware workflow* — \`workflow({ middleware: true })\`. \`trigger\` becomes optional; it gets no public route and is referenced by \`name\`:
1119
+ \`\`\`ts
1120
+ export default workflow({ name: "auth-check", version: "1.0.0", middleware: true,
1121
+ steps: [ /* sets ctx.state.identity; may stop:true to short-circuit */ ] });
1122
+ \`\`\`
1123
+
1124
+ Process-global middleware: \`WorkflowRegistry.getInstance().setGlobalMiddleware([...])\` or \`BLOK_GLOBAL_MIDDLEWARE=a,b\`.
1125
+
1126
+ ---
1127
+
1128
+ ## 5. AUTHORING NODES
1129
+
1130
+ ### 5.1 defineNode (TypeScript, in-process)
1131
+
1132
+ Always \`export default defineNode(...)\`. Never class-based \`BlokService\`. Zod input/output are mandatory.
1133
+
1134
+ \`\`\`ts
712
1135
  import { defineNode } from "@blokjs/runner";
713
1136
  import { z } from "zod";
714
1137
 
715
1138
  export default defineNode({
716
1139
  name: "fetch-user",
717
- description: "Fetches user by ID",
718
-
719
- input: z.object({
720
- userId: z.string().uuid(),
721
- }),
722
-
723
- output: z.object({
724
- user: z.object({
725
- id: z.string(),
726
- name: z.string(),
727
- email: z.string().email(),
728
- }),
729
- }),
730
-
1140
+ description: "Fetches a user by ID",
1141
+ input: z.object({ userId: z.string().uuid() }), // validated BEFORE execute
1142
+ output: z.object({ user: z.object({ id: z.string(), name: z.string() }) }), // validated AFTER
731
1143
  async execute(ctx, input) {
732
- const user = await fetchUser(input.userId);
733
- return { user };
1144
+ const user = await fetchUser(input.userId); // input is type-safe
1145
+ return { user }; // MUST match the output schema
734
1146
  },
735
1147
  });
736
1148
  \`\`\`
737
1149
 
738
- ### Key Behaviors
1150
+ - **Errors:** input failure → \`GlobalError\` code **400**; a plain \`Error\` thrown in \`execute\` → code **500**; a \`GlobalError\` you throw is preserved verbatim (custom codes like 401 survive).
1151
+ - **Never write \`ctx.state\` from a node** — return your output and let the runner persist it. For a genuine side-channel value, use \`ctx.publish(name, value)\`.
1152
+ - No \`any\` types — use \`z.unknown()\` and narrow.
1153
+ - \`flow: true\` nodes return \`NodeBase[]\`; \`contentType: "text/html"\` sets the response Content-Type.
739
1154
 
740
- - Zod input/output validation runs automatically
741
- - ZodError is mapped to GlobalError with HTTP 400
742
- - \\\`flow: true\\\` nodes return NodeBase[] for conditional execution
743
- - \\\`contentType\\\` sets response Content-Type (e.g., "text/html")
744
- - Always \\\`export default defineNode(...)\\\`
1155
+ TypeScript nodes live in \`src/nodes/\` and are referenced by \`use: "<name>"\` (no \`type\` needed — \`module\` is the default).
745
1156
 
746
- ---
1157
+ ### 5.2 Nodes in other runtimes (gRPC sidecars)
1158
+
1159
+ The 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\` — that holds the original trigger payload). The node's return value lands in \`ctx.state[<step-id>]\`.
1160
+
1161
+ **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:
1162
+
1163
+ - The runner speaks **gRPC only** (the legacy HTTP \`/execute\` path was removed in v0.5).
1164
+ - 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\`–\`9007\`; the CLI readiness check is a **TCP connect to the gRPC port**, not \`GET /health\`.)
1165
+ - \`blokctl dev\` sets \`BLOK_TRANSPORT=grpc\` + \`GRPC_PORT\` for each sidecar. Most SDKs default to HTTP transport if you launch them by hand — always let \`blokctl dev\` (or the env) set gRPC, or the runner can't reach the node.
1166
+ - Generated proto stubs ship with each SDK — you do **not** regenerate them to author a node.
1167
+ - Each SDK has a **typed** contract (the equivalent of \`defineNode\` — 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.
1168
+ - gRPC message cap defaults to 16 MiB (\`BLOK_GRPC_MAX_MESSAGE_BYTES\`).
1169
+ - Don't edit \`.blok/runtimes/\` — those are generated copies.
1170
+
1171
+ The workflow step is identical regardless of runtime — only \`type\` changes:
1172
+
1173
+ \`\`\`ts
1174
+ { id: "sum", use: "add-numbers", type: "runtime.<lang>", inputs: { a: $.req.body.a, b: $.req.body.b } }
1175
+ \`\`\`
1176
+
1177
+ #### Authoring a node in go
1178
+
1179
+ \`runtimes/go/nodes/addnumbers.go\` — typed via \`blok.DefineNode\`:
1180
+
1181
+ \`\`\`go
1182
+ package nodes
1183
+
1184
+ import blok "github.com/nickincloud/blok-go"
1185
+
1186
+ type AddNumbersInput struct {
1187
+ A int \`json:"a"\`
1188
+ B int \`json:"b"\`
1189
+ }
1190
+ type AddNumbersOutput struct {
1191
+ Sum int \`json:"sum"\`
1192
+ }
1193
+
1194
+ const AddNumbersNodeName = "add-numbers"
1195
+
1196
+ var AddNumbersNode = blok.DefineNode(AddNumbersNodeName, "Adds two integers",
1197
+ func(_ *blok.Context, in AddNumbersInput) (AddNumbersOutput, error) {
1198
+ return AddNumbersOutput{Sum: in.A + in.B}, nil
1199
+ })
1200
+ \`\`\`
1201
+
1202
+ Register in \`runtimes/go/cmd/server/main.go\`:
1203
+
1204
+ \`\`\`go
1205
+ func main() {
1206
+ registry := blok.NewNodeRegistry()
1207
+ registry.Register(nodes.AddNumbersNodeName, nodes.AddNumbersNode)
1208
+ registry.Use(blok.RecoveryMiddleware(), blok.LoggingMiddleware(blok.NewLogger(blok.LogLevelInfo)))
1209
+ if err := blok.ListenAndServe(registry); err != nil { log.Fatalf("Server error: %v", err) }
1210
+ }
1211
+ \`\`\`
1212
+
1213
+ Workflow 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\`.
1214
+
1215
+ #### Authoring a node in rust
1216
+
1217
+ \`runtimes/rust/nodes/add-numbers/src/main.rs\` — typed via the \`TypedNode\` trait:
1218
+
1219
+ \`\`\`rust
1220
+ use async_trait::async_trait;
1221
+ use blok::{BlokError, Context, NodeRegistry, TypedNode};
1222
+ use schemars::JsonSchema;
1223
+ use serde::{Deserialize, Serialize};
1224
+
1225
+ #[derive(Deserialize, JsonSchema)]
1226
+ struct AddInput { a: f64, b: f64 }
1227
+
1228
+ #[derive(Serialize, JsonSchema)]
1229
+ struct AddOutput { sum: f64 }
1230
+
1231
+ struct AddNumbers;
1232
+
1233
+ #[async_trait]
1234
+ impl TypedNode for AddNumbers {
1235
+ type Input = AddInput;
1236
+ type Output = AddOutput;
1237
+ fn name(&self) -> &str { "add-numbers" }
1238
+ fn description(&self) -> &str { "Adds two numbers" }
1239
+ async fn run(&self, _ctx: &mut Context, input: AddInput) -> Result<AddOutput, BlokError> {
1240
+ Ok(AddOutput { sum: input.a + input.b })
1241
+ }
1242
+ }
1243
+
1244
+ #[tokio::main]
1245
+ async fn main() {
1246
+ let mut registry = NodeRegistry::new("1.0.0");
1247
+ registry.register_typed(AddNumbers); // typed nodes register via register_typed
1248
+ blok::server::serve(registry, 9002).await.unwrap();
1249
+ }
1250
+ \`\`\`
1251
+
1252
+ Workflow 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 — build with the \`grpc\` feature (or \`--features full\`) so the runner can dispatch.
1253
+
1254
+ #### Authoring a node in java
1255
+
1256
+ \`runtimes/java/src/main/java/com/blok/blok/nodes/AddNumbersNode.java\` — typed via \`TypedNode<I, O>\`:
1257
+
1258
+ \`\`\`java
1259
+ package com.blok.blok.nodes;
1260
+
1261
+ import com.blok.blok.node.TypedNode;
1262
+ import com.blok.blok.types.Context;
1263
+
1264
+ public final class AddNumbersNode extends TypedNode<AddNumbersNode.Input, AddNumbersNode.Output> {
1265
+ public record Input(int a, int b) {}
1266
+ public record Output(int sum) {}
1267
+
1268
+ @Override public String name() { return "add-numbers"; }
1269
+ @Override public String description() { return "Adds two integers"; }
1270
+ @Override protected Class<Input> inputClass() { return Input.class; }
1271
+ @Override protected Class<?> outputClass() { return Output.class; }
1272
+
1273
+ @Override protected Output run(Context ctx, Input input) {
1274
+ return new Output(input.a() + input.b());
1275
+ }
1276
+ }
1277
+ \`\`\`
1278
+
1279
+ Register in \`runtimes/java/src/main/java/com/blok/blok/Main.java\`:
1280
+
1281
+ \`\`\`java
1282
+ NodeRegistry registry = new NodeRegistry();
1283
+ registry.register("add-numbers", new com.blok.blok.nodes.AddNumbersNode());
1284
+ registry.use(new RecoveryMiddleware());
1285
+ registry.use(new LoggingMiddleware(logger));
1286
+ \`\`\`
1287
+
1288
+ Workflow 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\`.
1289
+
1290
+ #### Authoring a node in csharp
1291
+
1292
+ \`runtimes/csharp/Nodes/AddNumbersNode.cs\` — typed via \`TypedNode<TInput, TOutput>\`:
1293
+
1294
+ \`\`\`csharp
1295
+ using System.ComponentModel.DataAnnotations;
1296
+ using Blok.Core.Node;
1297
+ using Blok.Core.Types;
1298
+
1299
+ namespace Blok.Runtime.Nodes;
747
1300
 
748
- ## Workflow Structure (JSON)
1301
+ public sealed record AddNumbersInput([property: Required] double A, [property: Required] double B);
1302
+ public sealed record AddNumbersOutput(double Sum);
749
1303
 
750
- \`\`\`json
1304
+ public sealed class AddNumbersNode : TypedNode<AddNumbersInput, AddNumbersOutput>
751
1305
  {
752
- "name": "My Workflow",
753
- "version": "1.0.0",
754
- "trigger": {
755
- "http": { "method": "POST", "path": "/api/process", "accept": "application/json" }
756
- },
757
- "steps": [
758
- { "id": "fetch", "use": "@blokjs/api-call", "inputs": { "url": "https://api.example.com", "method": "GET" } },
759
- { "id": "process", "use": "my-node", "inputs": { "data": "$.state.fetch" } },
760
- { "id": "go-step", "use": "chain-test", "type": "runtime.go", "inputs": { "processed": "$.state.process" } }
761
- ]
1306
+ public override string Name => "add-numbers";
1307
+ public override string Description => "Adds two numbers";
1308
+ public override Task<AddNumbersOutput> RunAsync(Context ctx, AddNumbersInput input)
1309
+ => Task.FromResult(new AddNumbersOutput(input.A + input.B));
762
1310
  }
763
1311
  \`\`\`
764
1312
 
765
- ### Workflow Naming
766
-
767
- Every workflow's \\\`name\\\` must be UNIQUE across the project. The
768
- \\\`WorkflowRegistry\\\` rejects duplicate names at boot, so a collision
769
- means only one of the colliding workflows ever registers.
770
-
771
- Prefer a dotted \\\`domain.action\\\` convention for the workflow
772
- \\\`name\\\` — \\\`countries.list\\\`, \\\`users.create\\\`,
773
- \\\`orders.refund\\\`. The typed client (\\\`@blokjs/client\\\`) and
774
- \\\`blokctl gen app-types\\\` nest workflows by their dotted name, so a clean
775
- name surfaces as \\\`blok.countries.list(...)\\\` instead of a quoted
776
- \\\`blok["World Countries"]\\\` accessor. Duplicate names also make
777
- \\\`gen app-types\\\` report a collision and DROP one workflow from the
778
- generated \\\`BlokApp\\\` type.
779
-
780
- The dotted convention applies to the workflow \\\`name\\\` only. Keep the
781
- \\\`Workflows.ts\\\` map KEYS dot-free (e.g. \\\`"refund-order"\\\`, not
782
- \\\`"orders.refund"\\\`) — the worker resolver treats the first dot in a map
783
- key as a file-extension delimiter, so a dotted key fails to resolve at load.
784
-
785
- ### Step Types
786
-
787
- | Type | Description |
788
- |------|-------------|
789
- | \\\`module\\\` | TypeScript node from registered modules |
790
- | \\\`local\\\` | TypeScript node from filesystem (NODES_PATH) |
791
- | \\\`runtime.python3\\\` | Python3 SDK container (port 9007) |
792
- | \\\`runtime.go\\\` | Go SDK container (port 9001) |
793
- | \\\`runtime.rust\\\` | Rust SDK container (port 9002) |
794
- | \\\`runtime.java\\\` | Java SDK container (port 9003) |
795
- | \\\`runtime.csharp\\\` | C# SDK container (port 9004) |
796
- | \\\`runtime.php\\\` | PHP SDK container (port 9005) |
797
- | \\\`runtime.ruby\\\` | Ruby SDK container (port 9006) |
798
-
799
- ### Conditional Workflow (if-else)
800
-
801
- \`\`\`json
1313
+ Register in \`runtimes/csharp/Program.cs\`:
1314
+
1315
+ \`\`\`csharp
1316
+ var config = ServerConfig.FromEnv();
1317
+ var registry = new NodeRegistry(config.Version);
1318
+ registry.Register("add-numbers", new AddNumbersNode());
1319
+ await RuntimeServer.Run(registry, config);
1320
+ \`\`\`
1321
+
1322
+ Workflow 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\`.
1323
+
1324
+ #### Authoring a node in php
1325
+
1326
+ \`runtimes/php/nodes/add-numbers/src/Nodes/AddNumbersNode.php\` typed via \`TypedNode\` (or plain \`NodeHandler\`):
1327
+
1328
+ \`\`\`php
1329
+ <?php
1330
+ declare(strict_types=1);
1331
+ namespace Blok\\Nodes;
1332
+
1333
+ use Blok\\Blok\\Node\\TypedNode;
1334
+ use Blok\\Blok\\Types\\Context;
1335
+
1336
+ final class AddNumbersInput
802
1337
  {
803
- "steps": [
1338
+ public function __construct(public int $a, public int $b) {}
1339
+ }
1340
+
1341
+ final class AddNumbersNode extends TypedNode
1342
+ {
1343
+ public function name(): string { return 'add-numbers'; }
1344
+ public function description(): string { return 'Adds two integers'; }
1345
+ protected function inputClass(): string { return AddNumbersInput::class; }
1346
+
1347
+ protected function run(Context $ctx, object $input): mixed
804
1348
  {
805
- "id": "filter-request",
806
- "branch": {
807
- "when": "ctx.request.query.active === \\\\"true\\\\"",
808
- "then": [{ "id": "active-path", "use": "handle-active", "type": "module" }],
809
- "else": [{ "id": "default-path", "use": "handle-default", "type": "module" }]
810
- }
1349
+ /** @var AddNumbersInput $input */
1350
+ return ['sum' => $input->a + $input->b];
811
1351
  }
812
- ]
813
1352
  }
814
1353
  \`\`\`
815
1354
 
816
- ---
1355
+ Register in \`runtimes/php/bin/serve.php\`:
817
1356
 
818
- ## Trigger Types
1357
+ \`\`\`php
1358
+ $config = ServerConfig::fromEnv();
1359
+ $registry = new NodeRegistry($config->version);
1360
+ $registry->register('add-numbers', new AddNumbersNode());
1361
+ // ... wire $registry into BlokNodeRuntimeService + RoadRunner GrpcServer and $server->serve();
1362
+ \`\`\`
819
1363
 
820
- | Trigger | Example Config |
821
- |---------|---------------|
822
- | \\\`http\\\` | \\\`{ "method": "GET", "path": "/", "accept": "application/json" }\\\` |
823
- | \\\`grpc\\\` | \\\`{ "service": "UserService", "method": "GetUser" }\\\` |
824
- | \\\`cron\\\` | \\\`{ "schedule": "0 * * * *", "timezone": "UTC" }\\\` |
825
- | \\\`pubsub\\\` | \\\`{ "provider": "gcp", "topic": "updates" }\\\` |
826
- | \\\`webhook\\\` | \\\`{ "source": "github", "events": ["push"] }\\\` |
827
- | \\\`websocket\\\` | \\\`{ "events": ["message"], "path": "/ws" }\\\` |
828
- | \\\`sse\\\` | \\\`{ "events": ["update"], "path": "/stream" }\\\` |
829
- | \\\`worker\\\` | \\\`{ "queue": "jobs", "concurrency": 5, "retries": 3 }\\\` |
1364
+ Workflow 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\`.
830
1365
 
831
- ### Worker Trigger
1366
+ #### Authoring a node in ruby
832
1367
 
833
- The worker trigger processes background jobs from a queue with retry logic and concurrency control.
1368
+ \`runtimes/ruby/nodes/add_numbers_node.rb\` typed via \`Blok::Node::TypedNode\`:
834
1369
 
835
- \\\`\\\`\\\`typescript
836
- import { workflow, $ } from "@blokjs/helper";
1370
+ \`\`\`ruby
1371
+ # frozen_string_literal: true
1372
+ require "blok"
1373
+
1374
+ class AddNumbersNode < Blok::Node::TypedNode
1375
+ node_name "add-numbers"
1376
+ description "Adds two numbers and returns their sum"
1377
+
1378
+ input do
1379
+ field :a, :number, required: true
1380
+ field :b, :number, required: true
1381
+ end
1382
+ output { field :sum, :number }
1383
+
1384
+ def run(_ctx, input)
1385
+ { "sum" => input[:a] + input[:b] } # string-keyed Hash is idiomatic
1386
+ end
1387
+ end
1388
+ \`\`\`
1389
+
1390
+ Register in \`runtimes/ruby/bin/serve.rb\`:
1391
+
1392
+ \`\`\`ruby
1393
+ require_relative "../nodes/add_numbers_node"
1394
+
1395
+ config = Blok::Config::ServerConfig.from_env
1396
+ registry = Blok::Node::NodeRegistry.new(config.version)
1397
+ registry.register("add-numbers", AddNumbersNode.new) # name MUST equal node_name
1398
+ registry.use(Blok::Middleware::RecoveryMiddleware.new)
1399
+ # serve.rb routes to start_grpc under BLOK_TRANSPORT=grpc
1400
+ \`\`\`
1401
+
1402
+ Workflow 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\`.
1403
+
1404
+ #### Authoring a node in python3
1405
+
1406
+ \`runtimes/python3/nodes/add_numbers/node.py\` — typed via the \`@node\` decorator (Pydantic):
1407
+
1408
+ \`\`\`python
1409
+ from __future__ import annotations
1410
+ from pydantic import BaseModel, Field
1411
+ from blok import node, Context
1412
+
1413
+
1414
+ class AddNumbersInput(BaseModel):
1415
+ a: float
1416
+ b: float = Field(0)
1417
+
1418
+
1419
+ class AddNumbersOutput(BaseModel):
1420
+ sum: float
837
1421
 
838
- export default workflow({
839
- name: "Process Job",
840
- version: "1.0.0",
841
- trigger: { worker: { queue: "background-jobs", concurrency: 5, retries: 3 } },
842
- steps: [
843
- {
844
- id: "process",
845
- use: "my-processor",
846
- inputs: { payload: $.req.body, jobId: $.req.params.jobId },
847
- },
848
- ],
849
- });
850
- \\\`\\\`\\\`
851
1422
 
852
- Job context: \\\`ctx.request.body\\\` = payload, \\\`ctx.request.params.queue\\\` = queue name, \\\`ctx.request.params.jobId\\\` = job ID, \\\`ctx.request.params.attempt\\\` = attempt count, \\\`ctx.vars._worker_job\\\` = full metadata.
1423
+ @node("add-numbers", "Adds two numbers and returns their sum")
1424
+ def add_numbers(ctx: Context, input: AddNumbersInput) -> AddNumbersOutput:
1425
+ return AddNumbersOutput(sum=input.a + input.b)
1426
+ \`\`\`
1427
+
1428
+ Registration is **manual** — importing the module runs the \`@node\` decorator; then flush with \`register_decorated\`. In \`runtimes/python3/nodes/__init__.py\`:
1429
+
1430
+ \`\`\`python
1431
+ from blok import register_decorated
1432
+ from . import add_numbers # noqa: F401 (runs the @node decorator)
1433
+
1434
+ def register_project_nodes(registry):
1435
+ return register_decorated(registry)
1436
+ \`\`\`
1437
+
1438
+ …and 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\`, …). Toolchain: Python 3, \`pip3 install -r requirements.txt\`; \`@node\` requires \`pydantic\`.
1439
+
1440
+ > The legacy \`BlokService\` / \`async def handle()\` / \`from core.blok import BlokService\` Python shape **does not exist** in this SDK — ignore any example that uses it. Use \`@node\` (or the \`NodeHandler\` ABC).
1441
+
1442
+ ---
1443
+
1444
+ ## 6. RUNNING LOCALLY / INFRA
1445
+
1446
+ \`\`\`bash
1447
+ blokctl dev # full dev server: spawns selected runtimes + the runner
1448
+ blokctl create node <name> --runtime <lang> # scaffold a node (ts default; pass --runtime for sidecars)
1449
+ blokctl runtime add <lang> # add a non-TS runtime to an existing project
1450
+ blokctl trace # open Blok Studio (run traces at /__blok)
1451
+ \`\`\`
1452
+
1453
+ The \`http\`/\`sse\`/\`websocket\`/\`webhook\`/\`mcp\` triggers need no external infra — they share the HTTP server. The cross-process triggers (\`worker\`, \`pubsub\`) need a broker.
853
1454
 
854
- Worker providers: \\\`in-memory\\\` (dev default, zero infra), \\\`nats\\\`, \\\`bullmq\\\`, \\\`rabbitmq\\\`, \\\`sqs\\\`, \\\`kafka\\\`, \\\`redis\\\`, \\\`pg-boss\\\`. Resolved per-workflow via \\\`trigger.worker.provider\\\`, then \\\`BLOK_WORKER_ADAPTER\\\`, then \\\`in-memory\\\`.
1455
+ **For worker/pubsub, start the broker stack** with the dev compose (Redis + NATS + Postgres/Adminer):
855
1456
 
856
- ### NATS JetStream
1457
+ \`\`\`bash
1458
+ cd infra/development && docker compose up -d nats # or: redis redis-commander
1459
+ \`\`\`
857
1460
 
858
- Recommended queue/worker backend. Environment variables:
859
- \\\`\\\`\\\`
860
- NATS_SERVERS=localhost:4222
861
- NATS_STREAM_NAME=blok-queue # or blok-worker for worker trigger
862
- NATS_TOKEN= # optional auth
863
- \\\`\\\`\\\`
1461
+ The default worker adapter is \`in-memory\` (zero infra) — only start a broker when you set a real provider (\`nats\`, \`redis\`, \`bullmq\`, …). Monitoring UIs from the dev compose: Adminer \`:8080\`, Redis Commander \`:8081\`, NATS monitor \`:8222\`. The compose declares an external \`shared-network\` — if the first run fails, run \`docker network create shared-network\`.
864
1462
 
865
- Queue providers: \\\`kafka\\\`, \\\`rabbitmq\\\`, \\\`sqs\\\`, \\\`redis\\\`, \\\`beanstalk\\\`, \\\`nats\\\`
1463
+ **For Kafka / RabbitMQ / SQS / GCP-Pub/Sub emulators**, use \`infra/testing/docker-compose.yml\` instead — those brokers are wired there on non-standard ports with emulators, matching the provider env blocks the scaffold writes.
866
1464
 
867
- ### Standalone Workers (Go, Rust, Python)
1465
+ ---
868
1466
 
869
- Go, Rust, and Python SDKs include standalone NATS workers that connect directly to NATS without the TypeScript runner:
1467
+ ## 7. FOOTGUN LIST (read before authoring)
870
1468
 
871
- \\\`\\\`\\\`
872
- WORKER_CONCURRENCY=1 # Max concurrent jobs
873
- WORKER_MAX_RETRIES=3 # Max delivery attempts
874
- WORKER_QUEUES=queue1,queue2 # Queues to consume
875
- \\\`\\\`\\\`
1469
+ 1. **Never reuse a step \`id\`** — 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"\`.
1470
+ 2. **Don't prefix \`@blokjs/expr\`'s \`expression\` input with \`js/\`** — that input is itself mapper-resolved, so \`js/...\` double-evaluates. Write plain JS: \`expression: "ctx.state.x.y"\`.
1471
+ 3. **\`set_var\` was removed in v0.5** — the runner throws at load time if present. Drop \`set_var: true\` (default-store handles it); replace \`set_var: false\` with \`ephemeral: true\`.
1472
+ 4. **Use \`"ANY"\`, not \`"*"\`, for the wildcard HTTP method** — \`"*"\` is accepted but warns and is auto-normalized.
1473
+ 5. **\`trigger.queue\` is rejected at construction** — it has no runtime and would silently never run. Use \`trigger.worker\` (\`{ worker: { queue: "<name>" } }\`).
1474
+ 6. **Workflow envelope minimums:** \`name\` >= 3 chars, \`version\` >= 5 chars (semver), \`steps\` must be non-empty, and a \`trigger\` is required unless \`middleware: true\`.
1475
+ 7. **Every v2 step schema is \`.strict()\`** — 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.
1476
+ 8. **\`as\` and \`spread\` are mutually exclusive** — pick one.
1477
+ 9. **\`$.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\`.
1478
+ 10. **Sub-workflow \`idempotencyKey\` with \`wait: true\` caches the WHOLE child result** — a cache hit means the child (and its side effects: emails, charges) never runs. Headline pattern AND primary footgun.
1479
+
1480
+ Plus 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\`.
1481
+
1482
+ **Production env knob worth naming:** \`BLOK_MAPPER_MODE=strict\` — fail-fast on \`js/...\` input resolution errors instead of silently passing the literal string through. Strongly recommended for production.
876
1483
 
877
1484
  ---
878
1485
 
879
- ## Testing Utilities
1486
+ ## 8. TESTING
1487
+
1488
+ Use the \`@blokjs/runner\` testing utilities with Vitest.
880
1489
 
881
- \\\`@blokjs/runner\\\` provides testing utilities for nodes and workflows.
1490
+ **Unit-test a node** with \`NodeTestHarness\`:
882
1491
 
883
- ### NodeTestHarness — Unit test a single node:
884
- \\\`\\\`\\\`typescript
1492
+ \`\`\`ts
885
1493
  import { NodeTestHarness } from "@blokjs/runner";
1494
+ import myNode from "../src/nodes/my-node";
1495
+
886
1496
  const harness = new NodeTestHarness(myNode);
887
- const result = await harness.execute({ input: "data" });
1497
+ const result = await harness.execute({ userId: "abc-123" });
888
1498
  harness.assertSuccess(result);
889
- harness.assertOutput(result, { expected: "output" });
890
- \\\`\\\`\\\`
1499
+ harness.assertOutput(result, { user: { id: "abc-123" } });
1500
+ \`\`\`
891
1501
 
892
- ### WorkflowTestRunner — Integration test a workflow:
893
- \\\`\\\`\\\`typescript
1502
+ **Integration-test a workflow** with \`WorkflowTestRunner\`:
1503
+
1504
+ \`\`\`ts
894
1505
  import { WorkflowTestRunner } from "@blokjs/runner";
895
- const runner = new WorkflowTestRunner({ verbose: true });
1506
+
1507
+ const runner = new WorkflowTestRunner({ verbose: true, mockAllNodes: true });
896
1508
  runner.registerNode("validate", ValidateNode);
897
1509
  runner.mockNode("external-api", async (input) => ({ result: "mocked" }));
898
- runner.loadWorkflow(workflowDefinition);
1510
+ runner.loadWorkflow(myWorkflowDefinition);
899
1511
  const result = await runner.execute({ input: "data" });
900
- // result.success, result.output, result.trace, result.nodeResults
901
- \\\`\\\`\\\`
1512
+ expect(result.success).toBe(true);
1513
+ \`\`\`
902
1514
 
903
1515
  ---
904
1516
 
905
- ## Runtime Adapter System
906
-
907
- All non-NodeJS SDKs communicate via HTTP:
908
- - **POST /execute** Execute node with context
909
- - **GET /health** Health check
910
-
911
- Environment variables: \\\`RUNTIME_{LANG}_HOST\\\` / \\\`RUNTIME_{LANG}_PORT\\\`
912
-
913
- Runtime nodes auto-save \\\`result.data\\\` to \\\`ctx.vars[stepName]\\\`.
914
-
915
- ---
1517
+ ## Do / Do NOT
1518
+
1519
+ **Do:**
1520
+ - Read \`.blok/config.json\` and existing \`src/workflows/\` to learn which triggers + runtimes this project uses; author for those.
1521
+ - Start every workflow from the **trigger decision table** in §1, not from HTTP.
1522
+ - Use \`workflow({ name, version, trigger, steps })\` from \`@blokjs/helper\`.
1523
+ - Use the typed node contract in every runtime (\`defineNode\` / \`DefineNode\` / \`TypedNode\` / \`@node\`).
1524
+ - Reference cross-step outputs with \`$.state.<id>\`; use \`as:\`/\`spread:\`/\`ephemeral:\` to shape persistence.
1525
+ - Set \`type: "runtime.<lang>"\` on every sidecar step and register the node by name.
1526
+
1527
+ **Do NOT:**
1528
+ - Default to the HTTP trigger because it's familiar — pick by intent.
1529
+ - Emit \`trigger.queue\` — it throws; use \`worker\`.
1530
+ - Use \`"*"\` for the wildcard method (use \`"ANY"\`), or \`set_var\` (removed in v0.5).
1531
+ - Write class-based \`BlokService\` nodes, or the stale Python \`BlokService\`/\`async def handle()\` shape.
1532
+ - Write to \`ctx.state\` inside a node — return your output (or \`ctx.publish(...)\` for a side-channel value).
1533
+ - Reuse a step \`id\`, combine \`as\` + \`spread\`, or use \`any\` types.
1534
+ - Read a typed sidecar node's data from \`ctx.request.body\` — read the step \`inputs\` / typed input.
1535
+ - Edit files under \`.blok/runtimes/\` — they are generated.
1536
+ `;
1537
+ const claude_md = `
1538
+ # Blok — Claude Code Quick Reference
916
1539
 
917
- ## Blok Studio
1540
+ This 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.
918
1541
 
919
- Real-time workflow trace visualization UI.
1542
+ ## Quick Commands
920
1543
 
921
- - Launch: \\\`blokctl trace\\\` or \\\`blokctl studio\\\`
922
- - API: \\\`/__blok/runs\\\`, \\\`/__blok/runs/:id\\\`, \\\`/__blok/runs/:id/stream\\\` (SSE)
923
- - Disable: \\\`BLOK_TRACE_ENABLED=false\\\`
1544
+ \`\`\`bash
1545
+ blokctl dev # Full dev server (spawns trigger runtimes + runner)
1546
+ blokctl create workflow <name> # Scaffold a workflow
1547
+ blokctl create node <name> # Scaffold a TS node
1548
+ blokctl create node <name> --runtime go # Scaffold a node in another runtime (go|rust|java|csharp|php|ruby|python3)
1549
+ blokctl trace # Open Blok Studio (or visit /__blok on the running trigger)
1550
+ \`\`\`
924
1551
 
925
1552
  ---
926
1553
 
927
- ## Do NOT
1554
+ ## 1. Pick the right trigger FIRST (do NOT default to HTTP)
928
1555
 
929
- - Do NOT rely on \\\`ctx.response.data\\\` for data from non-previous steps — it gets overwritten
930
- - Do NOT create class-based nodes — use \\\`defineNode()\\\` instead
931
- - Do NOT use \\\`any\\\` type — use \\\`unknown\\\` and narrow with Zod
932
- - Do NOT hardcode runtime ports — use environment variables
933
- - Do NOT skip Zod input/output schemas
934
- - Do NOT edit files in \\\`.blok/runtimes/\\\` — they are auto-generated
1556
+ Blok has **9 trigger kinds**. HTTP is **one of nine**, not the default — it is correct only for synchronous request/response. Every workflow declares exactly **one** trigger.
935
1557
 
936
- ## Do
1558
+ **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.
937
1559
 
938
- - Use \\\`$.state.<id>\\\` (or \\\`js/ctx.state.<id>\\\`) to pass data between non-adjacent steps every step default-stores its output there
939
- - Opt out per step with \\\`ephemeral: true\\\` when the step is a side effect only
940
- - Use Zod schemas for all input/output validation
941
- - Use \\\`defineNode()\\\` for all new nodes
942
- - Handle errors via GlobalError with appropriate HTTP status codes
943
- - Keep nodes focused — one responsibility per node
944
- `;
945
- const claude_md = `# Blok Project — Claude Code Guide
1560
+ ### Trigger Decision Tablechoose by intent
946
1561
 
947
- Read \\\`AGENTS.md\\\` for full architecture and API details. This file contains Claude-specific guidance.
1562
+ | What you're building | Trigger |
1563
+ |---|---|
1564
+ | Respond to an HTTP/REST request; JSON API; HTML page; file download | **\`http\`** |
1565
+ | Process a background / queued / async job; offload slow work | **\`worker\`** |
1566
+ | Run on a schedule / recurring time-based job (nightly, hourly) | **\`cron\`** |
1567
+ | React to messages on a cloud topic/subscription (cross-service events) | **\`pubsub\`** |
1568
+ | Stream live updates one-way to a browser (tokens, progress, feed) | **\`sse\`** |
1569
+ | Bidirectional realtime (chat, live cursors, client↔server messages) | **\`websocket\`** |
1570
+ | Receive a signed provider webhook (Stripe / GitHub / Slack / Shopify / Svix) | **\`webhook\`** |
1571
+ | Expose a workflow as a tool/resource to an AI/LLM client (Cursor, Claude) | **\`mcp\`** |
1572
+ | High-throughput typed RPC between services with a \`.proto\` contract | **\`grpc\`** |
948
1573
 
949
- ## Quick Commands
1574
+ Tie-breakers: one-way stream → \`sse\`; two-way → \`websocket\`. In-process pub/sub (single Node process, HTTP+SSE) → \`sse\` bus, not \`pubsub\`. Queue consumer → **\`worker\`** (the \`queue\` kind is dead — it throws at construction; never emit \`trigger: { queue: ... }\`).
950
1575
 
951
- \\\`\\\`\\\`bash
952
- npm run dev # Start dev server
953
- blokctl dev # Multi-runtime dev server
954
- blokctl create node <name> # Scaffold new node
955
- blokctl create workflow <name> # Scaffold new workflow
956
- blokctl trace # Open Blok Studio
957
- npm test # Run tests
958
- \\\`\\\`\\\`
1576
+ \`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 — only the \`trigger:\` block changes. See \`AGENTS.md\` for each kind's full config + a runnable example.
959
1577
 
960
- ## Context Rules (Memorize These)
1578
+ ---
961
1579
 
962
- 1. **\\\`ctx.prev\\\` is the immediately previous step's output.** Overwritten every step.
963
- 2. **\\\`ctx.state[<id>]\\\` PERSISTS across the workflow.** Every step default-stores its output there; reference via \\\`$.state.<id>\\\` or \\\`js/ctx.state.<id>\\\`. Opt out with \\\`ephemeral: true\\\`.
964
- 3. **Blueprint Mapper resolves \\\`$.<path>\\\` and \\\`js/\\\` expressions BEFORE node execution.**
1580
+ ## 2. Context & State (v2)
965
1581
 
966
- When users have data flow issues, check these three things first.
1582
+ **Every step's output auto-persists to \`ctx.state[id]\` — 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.
967
1583
 
968
- ## Workflow Naming
1584
+ **The four reads** (the \`$\` proxy compiles to \`"js/ctx.<path>"\` strings; in JSON write those strings by hand):
969
1585
 
970
- Workflow \\\`name\\\` must be UNIQUE across the project — the
971
- \\\`WorkflowRegistry\\\` rejects duplicates at boot. Use a dotted
972
- \\\`domain.action\\\` convention (\\\`countries.list\\\`, \\\`users.create\\\`)
973
- so the typed client (\\\`@blokjs/client\\\`) and \\\`blokctl gen app-types\\\`
974
- expose clean nested accessors like \\\`blok.countries.list(...)\\\`. Duplicate
975
- names make \\\`gen app-types\\\` flag a collision and drop one workflow from
976
- the generated \\\`BlokApp\\\` type.
1586
+ | Read | Resolves to | Scope |
1587
+ |---|---|---|
1588
+ | \`$.state.<id>\` | A prior step's stored output | Whole workflow (cross-step) |
1589
+ | \`$.prev\` | Immediately previous step's output | Adjacent only — overwritten every step |
1590
+ | \`$.req\` | Request envelope (body/headers/params/query/method) | Whole run |
1591
+ | \`$.error\` | Captured error (\`.message\`/\`.code\`/\`.stepId\`) | \`tryCatch.catch\` arm only |
977
1592
 
978
- ## Debugging Workflows
1593
+ **Persistence knobs (per-step):**
979
1594
 
980
- 1. **Verify structure**: Every step has an \\\`id\\\` and a \\\`use\\\` (v2). v1's \\\`name\\\` + \\\`nodes{}\\\` still works but is normalized at load time.
981
- 2. **Trace data flow**: Does the target step reference the correct source id (\\\`$.state.<id>\\\`)? Did the source step have \\\`ephemeral: true\\\` accidentally?
982
- 3. **Check runtimes**: SDK containers running? \\\`GET http://localhost:{port}/health\\\`
983
- 4. **Check Studio traces**: \\\`/__blok/runs/:id\\\` shows step-by-step inputs/outputs/errors
1595
+ | Knob | Effect |
1596
+ |---|---|
1597
+ | *(none)* | Store at \`ctx.state[id]\` (the 95% case) |
1598
+ | \`as: "name"\` | Store at \`ctx.state[name]\` instead. Mutually exclusive with \`spread\` |
1599
+ | \`spread: true\` | Shallow-merge \`result.data\`'s keys into \`ctx.state\` (multi-output nodes) |
1600
+ | \`ephemeral: true\` | Skip storage; only \`$.prev\` carries it to the next step (logging/audit) |
984
1601
 
985
- ### Common Errors
1602
+ Per-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.
986
1603
 
987
- | Error | Fix |
988
- |-------|-----|
989
- | \\\`Node type X not found\\\` | Wrong \\\`type\\\` in step — use module, local, or runtime.* |
990
- | \\\`Validation failed\\\` | Zod schema mismatch — check input schema vs actual data |
991
- | \\\`Runtime execution error\\\` | SDK container not running — check health endpoint |
992
- | \\\`ctx.state['X'] undefined\\\` | Source step has \\\`ephemeral: true\\\`, or the id doesn't match what's referenced in \\\`$.state.<id>\\\` |
993
- | \\\`set_var, which was removed in v0.5\\\` | Drop \\\`set_var: true\\\` (it's the default) or replace \\\`set_var: false\\\` with \\\`ephemeral: true\\\`. Run \\\`blokctl migrate workflows\\\`. |
1604
+ ---
994
1605
 
995
- ## Generating Code
1606
+ ## 3. Generating Nodes
996
1607
 
997
- Always use \\\`defineNode()\\\`. Never class-based BlokService.
1608
+ 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()\`.
998
1609
 
999
- \\\`\\\`\\\`typescript
1610
+ \`\`\`typescript
1000
1611
  import { defineNode } from "@blokjs/runner";
1001
1612
  import { z } from "zod";
1002
1613
 
1003
1614
  export default defineNode({
1004
- name: "node-name",
1005
- description: "What this node does",
1006
- input: z.object({ /* Zod schema */ }),
1007
- output: z.object({ /* Zod schema */ }),
1615
+ name: "fetch-user",
1616
+ description: "Fetches a user by ID",
1617
+ input: z.object({ userId: z.string().uuid() }), // validated BEFORE execute → 400 on fail
1618
+ output: z.object({ user: z.object({ id: z.string(), name: z.string() }) }), // validated AFTER → 500 on fail
1008
1619
  async execute(ctx, input) {
1009
- return { /* must match output schema */ };
1620
+ const user = await fetchUser(input.userId); // input is type-safe
1621
+ return { user }; // MUST match the output schema
1010
1622
  },
1011
1623
  });
1012
- \\\`\\\`\\\`
1624
+ \`\`\`
1625
+
1626
+ ### Nodes in other runtimes
1627
+
1628
+ A 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/\`.
1629
+
1630
+ | Runtime | Step \`type\` | gRPC port |
1631
+ |---|---|---|
1632
+ | Go | \`runtime.go\` | 10001 |
1633
+ | Rust | \`runtime.rust\` | 10002 |
1634
+ | Java | \`runtime.java\` | 10003 |
1635
+ | C# | \`runtime.csharp\` | 10004 |
1636
+ | PHP | \`runtime.php\` | 10005 |
1637
+ | Ruby | \`runtime.ruby\` | 10006 |
1638
+ | Python3 | \`runtime.python3\` | 10007 |
1013
1639
 
1014
- ### Checklist:
1015
- - Zod input schema covers all inputs
1016
- - Zod output schema matches execute() return
1017
- - Node name matches workflow references
1018
- - No \\\`any\\\` types — use \\\`z.unknown()\\\` if dynamic
1019
- - \\\`export default defineNode(...)\\\`
1640
+ **Inline cross-runtime example (Python3 — \`@node\` is the Python \`defineNode\`):**
1020
1641
 
1021
- ## Worker Workflows
1642
+ \`\`\`python
1643
+ # runtimes/python3/nodes/add_numbers/node.py
1644
+ from pydantic import BaseModel, Field
1645
+ from blok import node, Context
1022
1646
 
1023
- Worker trigger processes background jobs from a queue:
1647
+ class AddNumbersInput(BaseModel):
1648
+ a: float
1649
+ b: float = Field(0)
1024
1650
 
1025
- \\\`\\\`\\\`typescript
1651
+ class AddNumbersOutput(BaseModel):
1652
+ sum: float
1653
+
1654
+ @node("add-numbers", "Adds two numbers and returns their sum")
1655
+ def add_numbers(ctx: Context, input: AddNumbersInput) -> AddNumbersOutput:
1656
+ return AddNumbersOutput(sum=input.a + input.b)
1657
+ \`\`\`
1658
+
1659
+ Registration is **manual** in non-TS runtimes — 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.
1660
+
1661
+ ---
1662
+
1663
+ ## 4. Generating Workflows
1664
+
1665
+ Canonical form: \`workflow({ name, version, trigger, steps })\` from \`@blokjs/helper\` — one object literal, no chained builder, no separate \`nodes{}\` map. \`name\` ≥ 3 chars, \`version\` ≥ 5 chars (semver). Reference earlier outputs with \`$.state.<id>\` / \`$.req.body\`. Use \`branch\`, \`switchOn\`, \`forEach\`, \`loop\`, \`tryCatch\` (all from \`@blokjs/helper\`) for control flow.
1666
+
1667
+ \`\`\`typescript
1026
1668
  import { workflow, $ } from "@blokjs/helper";
1027
1669
 
1028
1670
  export default workflow({
1029
- name: "Process Job",
1671
+ name: "Process Order",
1030
1672
  version: "1.0.0",
1031
- trigger: { worker: { queue: "background-jobs" } },
1673
+ trigger: { http: { method: "POST", path: "/orders" } }, // path optional → derived from file path
1032
1674
  steps: [
1033
- { id: "process", use: "my-processor",
1034
- inputs: { payload: $.req.body, jobId: $.req.params.jobId } },
1675
+ { id: "validate", use: "order-validator", inputs: { order: $.req.body } },
1676
+ { id: "save", use: "order-store", inputs: { data: $.state.validate } },
1035
1677
  ],
1036
1678
  });
1037
- \\\`\\\`\\\`
1038
-
1039
- Job data: \\\`ctx.request.body\\\` = payload, \\\`ctx.request.params.queue/jobId/attempt\\\` = metadata.
1040
- Providers: \\\`in-memory\\\` (dev default), \\\`nats\\\`, \\\`bullmq\\\`, \\\`rabbitmq\\\`, \\\`sqs\\\`, \\\`kafka\\\`, \\\`redis\\\`, \\\`pg-boss\\\` — set via \\\`trigger.worker.provider\\\` or \\\`BLOK_WORKER_ADAPTER\\\`.
1041
-
1042
- ## Testing
1679
+ \`\`\`
1043
1680
 
1044
- \\\`\\\`\\\`typescript
1045
- import { NodeTestHarness, WorkflowTestRunner } from "@blokjs/runner";
1681
+ **Swap the \`trigger:\` block for any other kind** (body stays the same). Full configs + examples in \`AGENTS.md\`:
1046
1682
 
1047
- // Unit test a node
1048
- const harness = new NodeTestHarness(myNode);
1049
- const result = await harness.execute({ input: "data" });
1050
- harness.assertSuccess(result);
1683
+ \`\`\`typescript
1684
+ trigger: { worker: { queue: "background-jobs" } } // background jobs
1685
+ trigger: { cron: { schedule: "0 2 * * *", timezone: "America/New_York" } } // recurring
1686
+ trigger: { pubsub: { provider: "gcp", topic: "orders.placed", subscription: "fulfillment-svc" } }
1687
+ trigger: { sse: { path: "/sse/clock", heartbeatInterval: 15000 } } // one-way stream
1688
+ trigger: { websocket: { path: "/ws/echo", events: ["message", "open", "close"] } }
1689
+ trigger: { webhook: { provider: "stripe", secretEnv: "STRIPE_WEBHOOK_SECRET", idempotencyKey: "js/ctx.request.body.id" } }
1690
+ trigger: { mcp: { path: "/mcp", tool: { description: "..." } } } // needs a workflow-level input: z.object({...})
1691
+ trigger: { grpc: { service: "UserService", method: "GetUser", proto: "users.proto" } }
1692
+ \`\`\`
1051
1693
 
1052
- // Integration test a workflow
1053
- const runner = new WorkflowTestRunner({ mockAllNodes: true });
1054
- runner.loadWorkflow(definition);
1055
- const wfResult = await runner.execute({ input: "data" });
1056
- \\\`\\\`\\\`
1694
+ **Branch:**
1057
1695
 
1058
- ## Blok Studio Help
1696
+ \`\`\`typescript
1697
+ import { workflow, branch, $ } from "@blokjs/helper";
1698
+ branch({ id: "route",
1699
+ when: '$.req.method === "POST"', // when is a JS-expression STRING ($ can't intercept ===)
1700
+ then: [{ id: "create", use: "...", inputs: {...} }],
1701
+ else: [{ id: "read", use: "...", inputs: {...} }] })
1702
+ \`\`\`
1059
1703
 
1060
- - Launch: \\\`blokctl trace\\\` or navigate to \\\`/__blok\\\`
1061
- - "No output" → Node not returning data or Zod output validation failed
1062
- - "Step error" → Expand error — check if 400 (validation) or 500 (runtime)
1063
- - "State not passing" → Source step has \\\`ephemeral: true\\\`, OR target's \\\`$.state.<id>\\\` references a non-existent step id
1704
+ **Worker/pubsub/broker projects** need local infra. The scaffold ships an \`infra/development\` docker-compose with the broker stack \`cd infra/development && docker compose up -d\` to start NATS/Redis (run \`docker network create shared-network\` once if prompted).
1064
1705
 
1065
- ## Debugging Workers
1706
+ ---
1066
1707
 
1067
- - NATS not reachable → Check \\\`NATS_SERVERS\\\` env var, ensure NATS is running
1068
- - Job timeout → Increase \\\`timeout\\\` in trigger config or optimize node
1069
- - Max retries exceeded Check node errors, job moves to DLQ
1708
+ ## 5. Common Errors
1709
+
1710
+ | Error | Cause | Fix |
1711
+ |---|---|---|
1712
+ | \`Trigger kind 'queue' has no runtime\` | Used \`trigger: { queue: ... }\` | Use \`trigger: { worker: { queue: "<name>" } }\` |
1713
+ | \`Validation failed: name must be at least 3 characters\` | Workflow \`name\` < 3 chars / \`version\` < 5 chars | Lengthen name; use full semver \`x.x.x\` |
1714
+ | \`Unrecognized key(s) in object: "..."\` | Misspelled / unknown field — every v2 step schema is \`.strict()\` | Fix the spelling; trigger-only fields (\`concurrencyKey\`, \`delay\`, \`ttl\`, \`debounce\`) belong on the trigger, not a step |
1715
+ | \`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 |
1716
+ | \`as and spread are mutually exclusive\` | Step set both | Pick one |
1717
+ | \`branch step is missing 'when'\` | No condition string | Set \`when: "..."\` |
1718
+ | \`step "..." uses set_var\` | Legacy field (removed v0.5) | Drop \`set_var: true\`; replace \`set_var: false\` with \`ephemeral: true\` |
1719
+ | \`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 |
1720
+ | \`Node type X not found\` | Missing runtime resolver / wrong \`type\` | Check \`type: "runtime.<lang>"\` and that the runtime is scaffolded |
1721
+ | \`[blok][mapper] Failed to resolve ...\` | A \`js/...\` input expression threw | Fix the expression; set \`BLOK_MAPPER_MODE=strict\` to fail-fast in prod |
1070
1722
 
1071
- ## Do NOT
1723
+ ---
1072
1724
 
1073
- - Do NOT suggest class-based BlokService for new nodes
1074
- - Do NOT generate code with \\\`any\\\` types
1075
- - Do NOT assume \\\`ctx.response.data\\\` persists across steps
1076
- - Do NOT skip Zod schemas when creating nodes
1077
- - Do NOT edit files in \\\`.blok/runtimes/\\\`
1725
+ ## 6. Do NOT
1726
+
1727
+ - Do NOT default to the HTTP trigger — read \`.blok/config.json\` and pick the trigger by intent (Section 1).
1728
+ - Do NOT use \`trigger: { queue: ... }\` — it has no runtime and throws. Use \`worker\`.
1729
+ - Do NOT reuse a step \`id\` anywhere — 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.
1730
+ - Do NOT write to \`ctx.state\` inside a node's \`execute()\` — return your output; use \`ctx.publish(name, value)\` for a side-channel.
1731
+ - Do NOT assume \`$.prev\` (or \`ctx.response.data\`) survives more than one step — use \`$.state.<id>\` for cross-step reads.
1732
+ - Do NOT prefix \`@blokjs/expr\`'s \`expression\` input with \`js/\` — it double-evaluates. Write plain JS: \`expression: "ctx.state.x.y"\`.
1733
+ - Do NOT use \`set_var\` — removed in v0.5, throws at load.
1734
+ - Do NOT use \`"*"\` for the wildcard HTTP method — use \`"ANY"\`.
1735
+ - Do NOT generate class-based \`BlokService\` nodes or use \`any\` types — always \`defineNode()\` (TS) / \`@node\` (Python) with Zod/Pydantic schemas.
1736
+ - Do NOT use ESLint/Prettier — this project uses Biome. Do NOT edit auto-generated files in \`.blok/runtimes/\`.
1078
1737
  `;
1079
1738
  const function_first_node_file = `import { defineNode } from "@blokjs/runner";
1080
1739
  import { z } from "zod";