blokctl 0.6.18 → 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,458 +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
+ }}
620
683
  \`\`\`
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
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
+ });
634
697
  \`\`\`
635
698
 
636
- ## Commands
699
+ ### 2.2 WORKER — \`trigger: { worker: {...} }\`
637
700
 
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
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
+ }}
646
716
  \`\`\`
647
717
 
648
- ## Context — Critical Data Flow
718
+ \`\`\`ts
719
+ import { workflow } from "@blokjs/helper";
649
720
 
650
- The Context type is the central execution state passed through every step.
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
+ });
729
+ \`\`\`
651
730
 
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
- };
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\`).
732
+
733
+ ### 2.3 CRON — \`trigger: { cron: {...} }\`
734
+
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
+ }}
663
746
  \`\`\`
664
747
 
665
- ### The Two Critical Rules
748
+ \`\`\`ts
749
+ import { workflow } from "@blokjs/helper";
666
750
 
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.
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
+ });
759
+ \`\`\`
669
760
 
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\\\`.
761
+ \`ctx.request.body\` is \`{}\`; fire metadata is on \`ctx.request.params.{schedule,firedAt}\`. To serialize overlapping runs use \`concurrencyKey: "self"\`, \`concurrencyLimit: 1\`.
672
762
 
673
- ### Data Flow Example
763
+ ### 2.4 PUBSUB — \`trigger: { pubsub: {...} }\`
674
764
 
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.
766
+
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\`).
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";
785
+
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
+ });
795
+ \`\`\`
684
796
 
685
- Step 3: id "output"
686
- → Can read ctx.state["fetch-user"].name ← still "Alice"
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\`.
798
+
799
+ ### 2.5 SSE — \`trigger: { sse: {...} }\`
800
+
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
+ });
687
828
  \`\`\`
688
829
 
689
- ### Blueprint Mapper Expression Resolution
830
+ A sibling HTTP workflow publishes via \`@blokjs/sse-publish\`; both share the in-process bus. Cross-process needs a Redis pub/sub backplane.
690
831
 
691
- Node inputs support dynamic expressions resolved BEFORE node execution:
832
+ ### 2.6 WEBSOCKET \`trigger: { websocket: {...} }\`
692
833
 
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
- }
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
+ });
861
+ \`\`\`
862
+
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
+ });
701
938
  \`\`\`
702
939
 
703
- Available in js/ expressions: \\\`ctx\\\` (full context), \\\`data\\\` (ctx.prev.data), \\\`func\\\` (ctx.func), \\\`vars\\\` (alias for ctx.state).
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
+ \`\`\`
747
1176
 
748
- ## Workflow Structure (JSON)
1177
+ #### Authoring a node in go
749
1178
 
750
- \`\`\`json
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;
1300
+
1301
+ public sealed record AddNumbersInput([property: Required] double A, [property: Required] double B);
1302
+ public sealed record AddNumbersOutput(double Sum);
1303
+
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 — \\\`countries.list\\\`,
772
- \\\`users.create\\\`, \\\`orders.refund\\\`. The typed client
773
- (\\\`@blokjs/client\\\`) and \\\`blokctl gen app-types\\\` nest workflows by
774
- their dotted name, so a clean name surfaces as
775
- \\\`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
- ### Step Types
781
-
782
- | Type | Description |
783
- |------|-------------|
784
- | \\\`module\\\` | TypeScript node from registered modules |
785
- | \\\`local\\\` | TypeScript node from filesystem (NODES_PATH) |
786
- | \\\`runtime.python3\\\` | Python3 SDK container (port 9007) |
787
- | \\\`runtime.go\\\` | Go SDK container (port 9001) |
788
- | \\\`runtime.rust\\\` | Rust SDK container (port 9002) |
789
- | \\\`runtime.java\\\` | Java SDK container (port 9003) |
790
- | \\\`runtime.csharp\\\` | C# SDK container (port 9004) |
791
- | \\\`runtime.php\\\` | PHP SDK container (port 9005) |
792
- | \\\`runtime.ruby\\\` | Ruby SDK container (port 9006) |
793
-
794
- ### Conditional Workflow (if-else)
795
-
796
- \`\`\`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
797
1337
  {
798
- "nodes": {
799
- "filter": {
800
- "conditions": [
801
- {
802
- "type": "if",
803
- "condition": "ctx.request.query.active === \\\\"true\\\\"",
804
- "steps": [{ "name": "active-path", "node": "handle-active", "type": "module" }]
805
- },
806
- {
807
- "type": "else",
808
- "steps": [{ "name": "default-path", "node": "handle-default", "type": "module" }]
809
- }
810
- ]
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
1348
+ {
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
- | \\\`queue\\\` | \\\`{ "provider": "kafka", "topic": "events" }\\\` |
826
- | \\\`pubsub\\\` | \\\`{ "provider": "gcp", "topic": "updates" }\\\` |
827
- | \\\`webhook\\\` | \\\`{ "source": "github", "events": ["push"] }\\\` |
828
- | \\\`websocket\\\` | \\\`{ "events": ["message"], "path": "/ws" }\\\` |
829
- | \\\`sse\\\` | \\\`{ "events": ["update"], "path": "/stream" }\\\` |
830
- | \\\`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\`.
831
1365
 
832
- ### Worker Trigger
1366
+ #### Authoring a node in ruby
833
1367
 
834
- 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\`:
835
1369
 
836
- \\\`\\\`\\\`typescript
837
- Workflow({ name: "Process Job", version: "1.0.0" })
838
- .addTrigger("worker", { queue: "background-jobs" })
839
- .addStep({
840
- name: "process",
841
- node: "my-processor",
842
- type: "module",
843
- inputs: { payload: "js/ctx.request.body", jobId: "js/ctx.request.params.jobId" },
844
- });
845
- \\\`\\\`\\\`
1370
+ \`\`\`ruby
1371
+ # frozen_string_literal: true
1372
+ require "blok"
846
1373
 
847
- 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.
1374
+ class AddNumbersNode < Blok::Node::TypedNode
1375
+ node_name "add-numbers"
1376
+ description "Adds two numbers and returns their sum"
848
1377
 
849
- Adapters: NATS JetStream (recommended), BullMQ (Redis), InMemory (dev only).
1378
+ input do
1379
+ field :a, :number, required: true
1380
+ field :b, :number, required: true
1381
+ end
1382
+ output { field :sum, :number }
850
1383
 
851
- ### NATS JetStream
1384
+ def run(_ctx, input)
1385
+ { "sum" => input[:a] + input[:b] } # string-keyed Hash is idiomatic
1386
+ end
1387
+ end
1388
+ \`\`\`
852
1389
 
853
- Recommended queue/worker backend. Environment variables:
854
- \\\`\\\`\\\`
855
- NATS_SERVERS=localhost:4222
856
- NATS_STREAM_NAME=blok-queue # or blok-worker for worker trigger
857
- NATS_TOKEN= # optional auth
858
- \\\`\\\`\\\`
1390
+ Register in \`runtimes/ruby/bin/serve.rb\`:
859
1391
 
860
- Queue providers: \\\`kafka\\\`, \\\`rabbitmq\\\`, \\\`sqs\\\`, \\\`redis\\\`, \\\`beanstalk\\\`, \\\`nats\\\`
1392
+ \`\`\`ruby
1393
+ require_relative "../nodes/add_numbers_node"
861
1394
 
862
- ### Standalone Workers (Go, Rust, Python)
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
+ \`\`\`
863
1401
 
864
- Go, Rust, and Python SDKs include standalone NATS workers that connect directly to NATS without the TypeScript runner:
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\`.
865
1403
 
866
- \\\`\\\`\\\`
867
- WORKER_CONCURRENCY=1 # Max concurrent jobs
868
- WORKER_MAX_RETRIES=3 # Max delivery attempts
869
- WORKER_QUEUES=queue1,queue2 # Queues to consume
870
- \\\`\\\`\\\`
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
1421
+
1422
+
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.
1454
+
1455
+ **For worker/pubsub, start the broker stack** with the dev compose (Redis + NATS + Postgres/Adminer):
1456
+
1457
+ \`\`\`bash
1458
+ cd infra/development && docker compose up -d nats # or: redis redis-commander
1459
+ \`\`\`
1460
+
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\`.
1462
+
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.
871
1464
 
872
1465
  ---
873
1466
 
874
- ## Testing Utilities
1467
+ ## 7. FOOTGUN LIST (read before authoring)
1468
+
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.
875
1479
 
876
- \\\`@blokjs/runner\\\` provides testing utilities for nodes and workflows.
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\`.
877
1481
 
878
- ### NodeTestHarnessUnit test a single node:
879
- \\\`\\\`\\\`typescript
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.
1483
+
1484
+ ---
1485
+
1486
+ ## 8. TESTING
1487
+
1488
+ Use the \`@blokjs/runner\` testing utilities with Vitest.
1489
+
1490
+ **Unit-test a node** with \`NodeTestHarness\`:
1491
+
1492
+ \`\`\`ts
880
1493
  import { NodeTestHarness } from "@blokjs/runner";
1494
+ import myNode from "../src/nodes/my-node";
1495
+
881
1496
  const harness = new NodeTestHarness(myNode);
882
- const result = await harness.execute({ input: "data" });
1497
+ const result = await harness.execute({ userId: "abc-123" });
883
1498
  harness.assertSuccess(result);
884
- harness.assertOutput(result, { expected: "output" });
885
- \\\`\\\`\\\`
1499
+ harness.assertOutput(result, { user: { id: "abc-123" } });
1500
+ \`\`\`
1501
+
1502
+ **Integration-test a workflow** with \`WorkflowTestRunner\`:
886
1503
 
887
- ### WorkflowTestRunner — Integration test a workflow:
888
- \\\`\\\`\\\`typescript
1504
+ \`\`\`ts
889
1505
  import { WorkflowTestRunner } from "@blokjs/runner";
890
- const runner = new WorkflowTestRunner({ verbose: true });
1506
+
1507
+ const runner = new WorkflowTestRunner({ verbose: true, mockAllNodes: true });
891
1508
  runner.registerNode("validate", ValidateNode);
892
1509
  runner.mockNode("external-api", async (input) => ({ result: "mocked" }));
893
- runner.loadWorkflow(workflowDefinition);
1510
+ runner.loadWorkflow(myWorkflowDefinition);
894
1511
  const result = await runner.execute({ input: "data" });
895
- // result.success, result.output, result.trace, result.nodeResults
896
- \\\`\\\`\\\`
1512
+ expect(result.success).toBe(true);
1513
+ \`\`\`
897
1514
 
898
1515
  ---
899
1516
 
900
- ## Runtime Adapter System
901
-
902
- All non-NodeJS SDKs communicate via HTTP:
903
- - **POST /execute** Execute node with context
904
- - **GET /health** Health check
905
-
906
- Environment variables: \\\`RUNTIME_{LANG}_HOST\\\` / \\\`RUNTIME_{LANG}_PORT\\\`
907
-
908
- Runtime nodes auto-save \\\`result.data\\\` to \\\`ctx.vars[stepName]\\\`.
909
-
910
- ---
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
911
1539
 
912
- ## 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.
913
1541
 
914
- Real-time workflow trace visualization UI.
1542
+ ## Quick Commands
915
1543
 
916
- - Launch: \\\`blokctl trace\\\` or \\\`blokctl studio\\\`
917
- - API: \\\`/__blok/runs\\\`, \\\`/__blok/runs/:id\\\`, \\\`/__blok/runs/:id/stream\\\` (SSE)
918
- - 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
+ \`\`\`
919
1551
 
920
1552
  ---
921
1553
 
922
- ## Do NOT
1554
+ ## 1. Pick the right trigger FIRST (do NOT default to HTTP)
923
1555
 
924
- - Do NOT rely on \\\`ctx.response.data\\\` for data from non-previous steps — it gets overwritten
925
- - Do NOT create class-based nodes — use \\\`defineNode()\\\` instead
926
- - Do NOT use \\\`any\\\` type — use \\\`unknown\\\` and narrow with Zod
927
- - Do NOT hardcode runtime ports — use environment variables
928
- - Do NOT skip Zod input/output schemas
929
- - 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.
930
1557
 
931
- ## 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.
932
1559
 
933
- - Use \\\`$.state.<id>\\\` (or \\\`js/ctx.state.<id>\\\`) to pass data between non-adjacent steps every step default-stores its output there
934
- - Opt out per step with \\\`ephemeral: true\\\` when the step is a side effect only
935
- - Use Zod schemas for all input/output validation
936
- - Use \\\`defineNode()\\\` for all new nodes
937
- - Handle errors via GlobalError with appropriate HTTP status codes
938
- - Keep nodes focused — one responsibility per node
939
- `;
940
- const claude_md = `# Blok Project — Claude Code Guide
1560
+ ### Trigger Decision Tablechoose by intent
941
1561
 
942
- 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\`** |
943
1573
 
944
- ## 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: ... }\`).
945
1575
 
946
- \\\`\\\`\\\`bash
947
- npm run dev # Start dev server
948
- blokctl dev # Multi-runtime dev server
949
- blokctl create node <name> # Scaffold new node
950
- blokctl create workflow <name> # Scaffold new workflow
951
- blokctl trace # Open Blok Studio
952
- npm test # Run tests
953
- \\\`\\\`\\\`
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.
954
1577
 
955
- ## Context Rules (Memorize These)
1578
+ ---
956
1579
 
957
- 1. **\\\`ctx.prev\\\` is the immediately previous step's output.** Overwritten every step.
958
- 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\\\`.
959
- 3. **Blueprint Mapper resolves \\\`$.<path>\\\` and \\\`js/\\\` expressions BEFORE node execution.**
1580
+ ## 2. Context & State (v2)
960
1581
 
961
- 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.
962
1583
 
963
- ## Workflow Naming
1584
+ **The four reads** (the \`$\` proxy compiles to \`"js/ctx.<path>"\` strings; in JSON write those strings by hand):
964
1585
 
965
- Workflow \\\`name\\\` must be UNIQUE across the project — the
966
- \\\`WorkflowRegistry\\\` rejects duplicates at boot. Use a dotted
967
- \\\`domain.action\\\` convention (\\\`countries.list\\\`, \\\`users.create\\\`)
968
- so the typed client (\\\`@blokjs/client\\\`) and \\\`blokctl gen app-types\\\`
969
- expose clean nested accessors like \\\`blok.countries.list(...)\\\`. Duplicate
970
- names make \\\`gen app-types\\\` flag a collision and drop one workflow from
971
- 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 |
972
1592
 
973
- ## Debugging Workflows
1593
+ **Persistence knobs (per-step):**
974
1594
 
975
- 1. **Verify structure**: Every step has an \\\`id\\\` and a \\\`use\\\` (v2). v1's \\\`name\\\` + \\\`nodes{}\\\` still works but is normalized at load time.
976
- 2. **Trace data flow**: Does the target step reference the correct source id (\\\`$.state.<id>\\\`)? Did the source step have \\\`ephemeral: true\\\` accidentally?
977
- 3. **Check runtimes**: SDK containers running? \\\`GET http://localhost:{port}/health\\\`
978
- 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) |
979
1601
 
980
- ### 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.
981
1603
 
982
- | Error | Fix |
983
- |-------|-----|
984
- | \\\`Node type X not found\\\` | Wrong \\\`type\\\` in step — use module, local, or runtime.* |
985
- | \\\`Validation failed\\\` | Zod schema mismatch — check input schema vs actual data |
986
- | \\\`Runtime execution error\\\` | SDK container not running — check health endpoint |
987
- | \\\`ctx.state['X'] undefined\\\` | Source step has \\\`ephemeral: true\\\`, or the id doesn't match what's referenced in \\\`$.state.<id>\\\` |
988
- | \\\`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
+ ---
989
1605
 
990
- ## Generating Code
1606
+ ## 3. Generating Nodes
991
1607
 
992
- 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()\`.
993
1609
 
994
- \\\`\\\`\\\`typescript
1610
+ \`\`\`typescript
995
1611
  import { defineNode } from "@blokjs/runner";
996
1612
  import { z } from "zod";
997
1613
 
998
1614
  export default defineNode({
999
- name: "node-name",
1000
- description: "What this node does",
1001
- input: z.object({ /* Zod schema */ }),
1002
- 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
1003
1619
  async execute(ctx, input) {
1004
- return { /* must match output schema */ };
1620
+ const user = await fetchUser(input.userId); // input is type-safe
1621
+ return { user }; // MUST match the output schema
1005
1622
  },
1006
1623
  });
1007
- \\\`\\\`\\\`
1624
+ \`\`\`
1008
1625
 
1009
- ### Checklist:
1010
- - Zod input schema covers all inputs
1011
- - Zod output schema matches execute() return
1012
- - Node name matches workflow references
1013
- - No \\\`any\\\` types — use \\\`z.unknown()\\\` if dynamic
1014
- - \\\`export default defineNode(...)\\\`
1626
+ ### Nodes in other runtimes
1015
1627
 
1016
- ## Worker Workflows
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/\`.
1017
1629
 
1018
- Worker trigger processes background jobs from a queue:
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 |
1019
1639
 
1020
- \\\`\\\`\\\`typescript
1021
- Workflow({ name: "Process Job", version: "1.0.0" })
1022
- .addTrigger("worker", { queue: "background-jobs" })
1023
- .addStep({ name: "process", node: "my-processor", type: "module",
1024
- inputs: { payload: "js/ctx.request.body", jobId: "js/ctx.request.params.jobId" } });
1025
- \\\`\\\`\\\`
1640
+ **Inline cross-runtime example (Python3 — \`@node\` is the Python \`defineNode\`):**
1026
1641
 
1027
- Job data: \\\`ctx.request.body\\\` = payload, \\\`ctx.request.params.queue/jobId/attempt\\\` = metadata.
1028
- Adapters: NATS JetStream (recommended), BullMQ (Redis), InMemory (dev).
1642
+ \`\`\`python
1643
+ # runtimes/python3/nodes/add_numbers/node.py
1644
+ from pydantic import BaseModel, Field
1645
+ from blok import node, Context
1029
1646
 
1030
- ## Testing
1647
+ class AddNumbersInput(BaseModel):
1648
+ a: float
1649
+ b: float = Field(0)
1031
1650
 
1032
- \\\`\\\`\\\`typescript
1033
- import { NodeTestHarness, WorkflowTestRunner } from "@blokjs/runner";
1651
+ class AddNumbersOutput(BaseModel):
1652
+ sum: float
1034
1653
 
1035
- // Unit test a node
1036
- const harness = new NodeTestHarness(myNode);
1037
- const result = await harness.execute({ input: "data" });
1038
- harness.assertSuccess(result);
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
+ ---
1039
1662
 
1040
- // Integration test a workflow
1041
- const runner = new WorkflowTestRunner({ mockAllNodes: true });
1042
- runner.loadWorkflow(definition);
1043
- const wfResult = await runner.execute({ input: "data" });
1044
- \\\`\\\`\\\`
1663
+ ## 4. Generating Workflows
1045
1664
 
1046
- ## Blok Studio Help
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.
1047
1666
 
1048
- - Launch: \\\`blokctl trace\\\` or navigate to \\\`/__blok\\\`
1049
- - "No output" Node not returning data or Zod output validation failed
1050
- - "Step error" → Expand error — check if 400 (validation) or 500 (runtime)
1051
- - "State not passing" → Source step has \\\`ephemeral: true\\\`, OR target's \\\`$.state.<id>\\\` references a non-existent step id
1667
+ \`\`\`typescript
1668
+ import { workflow, $ } from "@blokjs/helper";
1669
+
1670
+ export default workflow({
1671
+ name: "Process Order",
1672
+ version: "1.0.0",
1673
+ trigger: { http: { method: "POST", path: "/orders" } }, // path optional → derived from file path
1674
+ steps: [
1675
+ { id: "validate", use: "order-validator", inputs: { order: $.req.body } },
1676
+ { id: "save", use: "order-store", inputs: { data: $.state.validate } },
1677
+ ],
1678
+ });
1679
+ \`\`\`
1052
1680
 
1053
- ## Debugging Workers
1681
+ **Swap the \`trigger:\` block for any other kind** (body stays the same). Full configs + examples in \`AGENTS.md\`:
1054
1682
 
1055
- - NATS not reachable → Check \\\`NATS_SERVERS\\\` env var, ensure NATS is running
1056
- - Job timeout Increase \\\`timeout\\\` in trigger config or optimize node
1057
- - Max retries exceeded Check node errors, job moves to DLQ
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
+ \`\`\`
1058
1693
 
1059
- ## Do NOT
1694
+ **Branch:**
1695
+
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
+ \`\`\`
1703
+
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).
1705
+
1706
+ ---
1707
+
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 |
1722
+
1723
+ ---
1060
1724
 
1061
- - Do NOT suggest class-based BlokService for new nodes
1062
- - Do NOT generate code with \\\`any\\\` types
1063
- - Do NOT assume \\\`ctx.response.data\\\` persists across steps
1064
- - Do NOT skip Zod schemas when creating nodes
1065
- - 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/\`.
1066
1737
  `;
1067
1738
  const function_first_node_file = `import { defineNode } from "@blokjs/runner";
1068
1739
  import { z } from "zod";