@codemation/agent-skills 0.5.1 → 0.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # @codemation/agent-skills
2
2
 
3
+ ## 0.5.2
4
+
5
+ ### Patch Changes
6
+
7
+ - [#247](https://github.com/MadeRelevant/codemation/pull/247) [`bfdd759`](https://github.com/MadeRelevant/codemation/commit/bfdd7590b4903676b223c2f302b9bcd0f4a4583c) Thanks [@cblokland90](https://github.com/cblokland90)! - Remove all human-written comments from TypeScript source files and add `codemation/no-comments` ESLint rule to enforce self-describing code going forward.
8
+
9
+ - [#276](https://github.com/MadeRelevant/codemation/pull/276) [`0a681b3`](https://github.com/MadeRelevant/codemation/commit/0a681b357afd7b15ba3925788c732fd439d8e6b0) Thanks [@cblokland90](https://github.com/cblokland90)! - Add `schedulePollingTrigger` to core-nodes — a credential-less polling trigger that emits one `{ firedAt, tick }` item per cycle with a configurable `pollIntervalMs` (default 60 s). Its title is "Run on schedule" and it fires one tick item on every poll cycle. Fix missing `packageName` on `onNewMsGraphMailTrigger` so its `nodeTypeId` resolves correctly in `PersistedWorkflowTokenRegistry`. Add `nodeTypeId` field to `ManifestTriggerConfig` and populate it in `emitWorkflowManifest` so the CP scheduler can source the correct value to forward to the trigger-service `/poll` endpoint. Swap the legacy `CronTrigger` out of the builder agent-skills and examples in favour of `schedulePollingTrigger.create(...)` so the building agent is steered to the interval trigger.
10
+
3
11
  ## 0.5.1
4
12
 
5
13
  ### Patch Changes
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
3
  "packageName": "@codemation/agent-skills",
4
- "packageVersion": "0.5.1",
4
+ "packageVersion": "0.5.2",
5
5
  "description": "Reusable agent skills for Codemation projects and plugin development.",
6
6
  "kind": "skills",
7
7
  "skills": [
@@ -18,8 +18,8 @@
18
18
  ],
19
19
  "sourcePath": "skills/builder/ai-agent/SKILL.md",
20
20
  "dependencies": {
21
- "@codemation/core-nodes": "0.12.0",
22
- "@codemation/core": "0.14.0"
21
+ "@codemation/core-nodes": "0.14.0",
22
+ "@codemation/core": "0.15.0"
23
23
  },
24
24
  "code": "---\nname: ai-agent\ndescription: Add an LLM step to a workflow with the AIAgent node — classification, extraction, drafting, or tool-using reasoning. It runs one chat completion per item and emits the model's reply on its port. Read this when a step needs an LLM (and prefer a plain Callback when the logic is deterministic).\ncompatibility: Designed for Codemation workflows authored with @codemation/core-nodes.\ntags: agent, llm, ai, aiagent, classification, extraction\nuses: \"@codemation/core-nodes, @codemation/core\"\n---\n\n# Codemation AI Agent Node\n\n`AIAgent` is the single building block for any LLM step in a workflow. It receives items, runs one chat completion **per item** using the configured `chatModel` and `messages`, and emits the result on its `main` port — always exactly one output item per input item (it never fans out or filters). Reach for it when a step needs an LLM: classification, extraction, summarisation, drafting, or a decision. For deterministic logic use a plain `Callback` instead (cheaper, faster, no LLM billing). See `workflow-dsl` for the surrounding workflow chain.\n\n## The node — one-liner\n\n`AIAgent<TInput, TOutput>` takes an options object. `messages` are `{ role, content }` lines; `content` is a string or a function `({ item, itemIndex, items, ctx }) => string`. Parameterise the generics so `item.json` is typed inside the content callbacks.\n\n```typescript\nimport { AIAgent, CodemationChatModelConfig } from \"@codemation/core-nodes\";\n\ntype Mail = { body: string };\n\n// No outputSchema → output shape is NON-DETERMINISTIC: JSON.parse succeeds → the parsed object;\n// JSON.parse fails → { output: string }. Always set outputSchema (see below).\nconst classify = new AIAgent<Mail>({\n name: \"Classify email\",\n id: \"classify-email\",\n messages: [\n { role: \"system\", content: \"Classify the email as spam or not-spam. Reply with one word.\" },\n { role: \"user\", content: ({ item }) => item.json.body },\n ],\n chatModel: new CodemationChatModelConfig(\"Managed AI\", \"low\"),\n});\n```\n\n## chatModel — managed is the default\n\n**Managed (default — no API key needed):** `CodemationChatModelConfig(label, complexity)`. The LLM broker auto-authenticates via the workspace HMAC pairing — no API key, no credential slot, no user setup. Do **not** tell managed users to \"get an API key\". The first argument is just a **neutral display label** for the node (e.g. `\"Managed AI\"`) — never put a provider or model name (`\"Claude\"`, `\"GPT\"`, …) in it, because the framework does **not** choose the provider: the broker does, from the complexity token. The second argument is that **complexity token** (`\"low\" | \"medium\" | \"high\" | \"xhigh\"`), **not** a model id — the broker maps the token to a concrete provider model and thinking effort. Never hard-code a model id; there is no live list to discover and no endpoint to call.\n\n```typescript\nimport { CodemationChatModelConfig } from \"@codemation/core-nodes\";\n\nconst cheap = new CodemationChatModelConfig(\"Managed AI\", \"low\");\n```\n\n**BYOK (self-hosted / non-managed only):** `OpenAIChatModelConfig(label, modelId, slotKey)` — it creates a credential slot the operator must bind with an API key. Only use this where no managed broker is available; in managed mode it prompts the user to bind a key they don't need.\n\n```typescript\nimport { OpenAIChatModelConfig } from \"@codemation/core-nodes\";\n\nconst gpt = new OpenAIChatModelConfig(\"OpenAI\", \"gpt-4.1-mini\", \"openai\"); // creates a bindable \"openai\" slot\n```\n\n`chatModel` does **not** accept a plain string — pass a config instance.\n\n## Structured output and the downstream shape\n\nWithout `outputSchema` the output shape is **non-deterministic**: internally `AgentOutputFactory.fromAgentContent` does `JSON.parse(content)` — if that succeeds the output json is the parsed object; if it throws (plain-text reply) the fallback is `{ output: content }`. This means you can't reliably read `item.json.output` unless you also own the prompt.\n\n**Prefer `outputSchema` — read typed fields directly; do NOT hand-parse `output`.** With `outputSchema` (a Zod object) the model is instructed to reply with structured JSON, the response is validated and parsed, and the output json **is the parsed object** — read its fields directly (`item.json.sentiment`, not `item.json.output`). Match the `AIAgent<TInput, TOutput>` second generic to the schema so the downstream node is correctly typed.\n\n```typescript\nimport { z } from \"zod\";\nimport { AIAgent, CodemationChatModelConfig } from \"@codemation/core-nodes\";\n\ntype Feedback = { text: string };\nconst VerdictSchema = z.object({\n sentiment: z.enum([\"positive\", \"neutral\", \"negative\"]),\n topic: z.string(),\n});\ntype Verdict = z.infer<typeof VerdictSchema>;\n\n// outputSchema → output json IS the parsed object; read item.json.sentiment / item.json.topic directly.\nnew AIAgent<Feedback, Verdict>({\n name: \"Classify feedback\",\n id: \"classify-feedback\",\n messages: [\n { role: \"system\", content: 'Reply with strict JSON {\"sentiment\",\"topic\"}.' },\n { role: \"user\", content: ({ item }) => item.json.text },\n ],\n chatModel: new CodemationChatModelConfig(\"Managed AI\", \"low\"),\n outputSchema: VerdictSchema,\n guardrails: { maxTurns: 2 },\n});\n// Downstream reads item.json.sentiment / item.json.topic — `output` does not exist when a schema is set.\n```\n\n## Tools and MCP servers\n\nAn `AIAgent` becomes a reasoning loop when it has **tools**. The model calls tools mid-turn; `guardrails.maxTurns` caps the loop. Three tool kinds, in priority order:\n\n### (a) Node-backed tools — `AgentToolFactory.asTool` (preferred for Codemation integrations)\n\nWrap any Codemation node as an agent tool so the real node does the ERP/service RPC — not a hand-rolled `execute` body. The node config carries the static parts (model, fields); `mapInput` maps the agent's tool-call args to the node's item input; `mapOutput` maps the node's output back to the tool result. Use `onRejected: \"halt\"` for HITL nodes (e.g. `inboxApproval`).\n\n```typescript\nimport { AIAgent, CodemationChatModelConfig, inboxApproval } from \"@codemation/core-nodes\";\nimport { AgentToolFactory, callableTool } from \"@codemation/core\";\nimport { z } from \"zod\";\n\ntype AnalysisInput = { text: string };\n\n// (a) Node-backed: wrap inboxApproval so the agent can escalate to a human.\nconst escalateToHuman = AgentToolFactory.asTool(\n inboxApproval.create(\n {\n title: ({ item }: { item: { json: unknown } }) => String((item.json as { title?: unknown }).title ?? \"Review\"),\n body: ({ item }: { item: { json: unknown } }) => String((item.json as { body?: unknown }).body ?? \"\"),\n priority: \"normal\",\n timeout: \"8h\",\n onTimeout: \"halt\",\n },\n \"Human escalation\",\n ),\n {\n name: \"escalate_to_human\",\n description: \"Pause and ask a human reviewer to approve or reject. Halt if rejected.\",\n onRejected: \"halt\",\n inputSchema: z.object({\n title: z.string().describe(\"Short summary for the reviewer\"),\n reason: z.string().describe(\"Why this needs human review\"),\n }),\n outputSchema: z.object({ approved: z.boolean(), note: z.string().optional() }),\n mapInput: ({ input, item }) => ({\n json: {\n ...((item.json as Record<string, unknown>) ?? {}),\n title: input.title,\n body: input.reason,\n },\n }),\n mapOutput: ({ outputs }: { outputs: { main?: ReadonlyArray<{ json: unknown }> } }) => {\n const first = outputs.main?.[0]?.json as { decision?: { status?: string; note?: string } } | undefined;\n return { approved: first?.decision?.status === \"approved\", note: first?.decision?.note };\n },\n },\n);\n\n// (b) Inline callable: custom logic without a Codemation node.\nconst lookup_rate = callableTool({\n name: \"lookup_rate\",\n description: \"Look up the current EUR/USD exchange rate.\",\n inputSchema: z.object({ pair: z.string() }),\n outputSchema: z.object({ rate: z.number() }),\n execute: async () => ({ rate: 1.08 }),\n});\n\nconst SyncOutput = z.object({ decision: z.string(), note: z.string().optional() });\ntype SyncOutputT = z.infer<typeof SyncOutput>;\n\n// Agent with both tool kinds — model reasons, calls tools as needed, emits structured output.\nnew AIAgent<AnalysisInput, SyncOutputT>({\n name: \"Analysis agent\",\n id: \"analysis-agent\",\n messages: [\n {\n role: \"system\",\n content:\n \"Analyse the text. If you need current exchange rates call lookup_rate. \" +\n \"If the text describes a critical unresolvable issue, call escalate_to_human. \" +\n \"Reply with { decision, note }.\",\n },\n { role: \"user\", content: ({ item }) => item.json.text },\n ],\n chatModel: new CodemationChatModelConfig(\"Managed AI\", \"medium\"),\n tools: [escalateToHuman, lookup_rate],\n outputSchema: SyncOutput,\n guardrails: { maxTurns: 10 },\n});\nexport {};\n```\n\nKey constraints:\n\n- `mapInput` returns `{ json: <node-input> }` (an `Item`) or a bare `<node-input>` object. Use the `{ json: {} }` form when you need to merge additional fields.\n- `mapOutput` receives `{ outputs }` where `outputs.main` is the node's output items. Always handle the empty-array case (`outputs.main?.[0]?.json`) — some nodes (e.g. `odooQueryNode`) emit zero items on no-match, which throws without a custom `mapOutput`.\n- `outputSchema` of `asTool` is the **tool result** schema (what the agent sees back), not the node's own output type.\n\n### (b) Inline callable tools — `callableTool`\n\nFor custom logic with no Codemation node behind it. Declare a credential slot if the execute body needs auth:\n\n```typescript\nimport { callableTool } from \"@codemation/core\";\nimport { z } from \"zod\";\n\nconst ping = callableTool({\n name: \"ping\",\n description: \"Check that a URL is reachable.\",\n inputSchema: z.object({ url: z.string().url() }),\n outputSchema: z.object({ ok: z.boolean() }),\n execute: async ({ input }) => {\n const res = await fetch(input.url, { method: \"HEAD\" });\n return { ok: res.ok };\n },\n});\nexport {};\n```\n\n### (c) MCP servers — `mcpServers`\n\nFor tool access to services registered on the control plane, pass `mcpServers: [\"<server-id>\"]`. The agent gets `find_tools` auto-injected when `mcpServers` is set — but **the agent will not use it unless you tell it to** in the system `messages`. Add this line to the system prompt:\n\n```text\nUse find_tools(query) to discover available tools before calling them. Never call a tool name you have not seen returned by find_tools.\n```\n\nNode-backed and `callableTool` tools in `tools: [...]` are always present — no `find_tools` needed for them. Soft limit: 8 pinned MCP tools (`pinnedMcpTools`); hard limit: 16.\n\n## One realistic complete example\n\nManual trigger → classify each feedback item with a managed model → tag the result downstream. Uses `outputSchema` so the downstream `MapData` reads `item.json.sentiment` directly — no hand-parsing, no non-determinism.\n\n```typescript\nimport { z } from \"zod\";\nimport {\n createWorkflowBuilder,\n ManualTrigger,\n AIAgent,\n MapData,\n CodemationChatModelConfig,\n} from \"@codemation/core-nodes\";\n\ntype Feedback = { text: string };\nconst SentimentSchema = z.object({ sentiment: z.enum([\"positive\", \"neutral\", \"negative\"]) });\ntype SentimentOutput = z.infer<typeof SentimentSchema>;\n\nexport default createWorkflowBuilder({ id: \"wf.classify-feedback\", name: \"Classify customer feedback\" })\n .trigger(\n new ManualTrigger<Feedback>(\"Classify feedback\", [\n { text: \"The new onboarding flow is really intuitive — great job!\" },\n { text: \"Couldn't find the export button anywhere, very confusing.\" },\n ]),\n )\n .then(\n new AIAgent<Feedback, SentimentOutput>({\n name: \"Classify feedback\",\n id: \"classify-feedback-agent\",\n messages: [\n { role: \"system\", content: 'Reply with strict JSON {\"sentiment\":\"positive\"|\"neutral\"|\"negative\"}.' },\n { role: \"user\", content: ({ item }) => `Feedback: ${item.json.text}` },\n ],\n chatModel: new CodemationChatModelConfig(\"Managed AI\", \"low\"),\n outputSchema: SentimentSchema,\n guardrails: { maxTurns: 1 },\n }),\n )\n .then(\n new MapData<SentimentOutput, { label: string }>(\"Tag sentiment\", (item) => ({ label: item.json.sentiment }), {\n id: \"tag-sentiment\",\n }),\n )\n .build();\n```\n\n## Choosing the model\n\nPick by the complexity and size of the content being processed — not by habit. The second arg to `CodemationChatModelConfig` is a **complexity token**; the broker maps it to a concrete model and thinking effort.\n\n| Tier | Use when | Complexity token |\n| --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------- |\n| **Small/cheap** | Short, simple text: classification, triage, single-field extraction from a clean snippet. | `\"low\"` |\n| **Mid** | Long or complex content: order PDFs, multi-page invoices, multi-field extraction, documents that would tax a smaller context. A `\"low\"` model may truncate or underperform on large inputs. | `\"medium\"` |\n| **Heavy** | Multi-step reasoning loops, tricky multi-tool decisions, demanding extraction where `\"medium\"` is unreliable. | `\"high\"` |\n| **Frontier** | The hardest problems where you need the most capable model regardless of cost. | `\"xhigh\"` |\n\nRule of thumb: if the prompt payload exceeds a paragraph or the output schema has more than two or three fields, step up to `\"medium\"`. Never hard-code a model id — the token IS the API; the broker picks the concrete model.\n\n```typescript\nimport { AIAgent, CodemationChatModelConfig } from \"@codemation/core-nodes\";\nimport { z } from \"zod\";\n\n// Small/cheap: one-field classification of a short text snippet.\nconst triage = new AIAgent<{ subject: string }>({\n name: \"Triage email\",\n id: \"triage-email\",\n messages: [\n { role: \"system\", content: \"Reply with one word: order, complaint, or other.\" },\n { role: \"user\", content: ({ item }) => item.json.subject },\n ],\n chatModel: new CodemationChatModelConfig(\"Managed AI\", \"low\"),\n outputSchema: z.object({ category: z.enum([\"order\", \"complaint\", \"other\"]) }),\n});\n\n// Larger: multi-field extraction from a full order document (PDF → markdown from OCR node upstream).\n// Step up the complexity token instead of hard-coding a model id.\nconst extract = new AIAgent<{ markdown: string }>({\n name: \"Extract order\",\n id: \"extract-order\",\n messages: [\n { role: \"system\", content: \"Extract vendor, total, and line items from the order document.\" },\n { role: \"user\", content: ({ item }) => item.json.markdown },\n ],\n chatModel: new CodemationChatModelConfig(\"Managed AI\", \"medium\"),\n outputSchema: z.object({\n vendor: z.string(),\n total: z.number(),\n lines: z.array(z.object({ description: z.string(), amount: z.number() })),\n }),\n guardrails: { maxTurns: 1 },\n});\nexport {};\n```\n\n## Gotchas\n\n- **Set an explicit `id`.** Without one the id derives from `name`; renaming the label re-keys any credential binding (BYOK / MCP). See the id-stability rule in `workflow-dsl`.\n- **Always set `outputSchema`.** Without it the output shape is non-deterministic (parsed JSON object or `{ output: string }` depending on whether the reply parses). With it, read parsed fields directly (`item.json.field`). Never read `item.json.output` when a schema is set, and avoid relying on `output` without a schema.\n- **One output per input.** `AIAgent` never fans out or filters — use `Split`/`Filter` for that.\n- **Don't use `AIAgent` for deterministic logic** — a `Callback` is cheaper and not billed.\n- **Managed = no key.** Never use `OpenAIChatModelConfig` (or \"get an API key\" guidance) in managed mode; the broker authenticates transparently.\n- **Binaries pass natively — never hand-roll the encoding.** Every attachment on the incoming item's `binary` is sent to the chat model AUTOMATICALLY as an inline multimodal block — images as `image` blocks, every other type (PDF, xlsx, pptx, docx, CSV, JSON, …) as `file`/`document` blocks — so the agent reads the file's actual bytes directly. You prepare nothing: the agent attaches whatever is on `item.binary` for you.\n - **DON'T** add a \"convert/encode to base64\" node, stringify the bytes into a prompt/message, or OCR-to-text upstream just to feed an agent. Putting a base64 string into a text prompt is the #1 binary mistake — the model gets unreadable text, not a real image/document, and the extra hand-rolled node fails at runtime when the binary isn't where its code looked (`\"No binary attachment found on item\"`). There is no `Encode`/`Base64` node and you never need one here.\n - **DO (binary from the direct upstream node):** keep the file on `item.binary` and feed that item straight into the `AIAgent` — a Gmail attachment, a file-pool read, or an HTTP download just flows in; the passdown is automatic. (To make an `HttpRequest` node hand you the raw bytes, set `responseFormat: \"binary\"` on it — it writes the response to `ctx.binary` under `binaryName` (default `\"body\"`) instead of parsing JSON/text. There is no `responseType` option.)\n - **DO (binary from a NON-adjacent earlier node):** when the bytes were produced several nodes back, not by the direct upstream one, don't thread them through every node by hand — forward them explicitly with the agent's `binaries` option, e.g. `binaries: ({ item }) => item.binary ? Object.values(item.binary) : []` (a `BinaryAttachment[]`, or a `({ item }) => BinaryAttachment[]` selector that pulls the bytes from wherever they live on the item).\n - Binaries are not filtered by type; an unsupported type surfaces a runtime error you can filter out upstream. Set `passBinariesToModel: false` only to deliberately turn the passdown off.\n",
25
25
  "format": "doc",
@@ -58,8 +58,8 @@
58
58
  ],
59
59
  "sourcePath": "skills/builder/connect-external-systems/SKILL.md",
60
60
  "dependencies": {
61
- "@codemation/core-nodes": "0.12.0",
62
- "@codemation/core": "0.14.0"
61
+ "@codemation/core-nodes": "0.14.0",
62
+ "@codemation/core": "0.15.0"
63
63
  },
64
64
  "code": "---\nname: connect-external-systems\ndescription: The decision guide for connecting a workflow step to ANY external system — an ERP, CRM, mailbox, or any HTTP/REST API. Follow this EVERY time a step talks to a service outside the workflow: pick the least-code route in strict priority order (specialized node → MCP server → defineRestNode → hand-rolled Callback). Read this before authoring any integration step.\ncompatibility: Cross-vertical guidance for Codemation workflows. No specific package required.\ntags: integration, external, api, http, rest, mcp, connect, erp, crm, credential, routing\nuses: \"@codemation/core-nodes, @codemation/core\"\n---\n\n# Connecting to external systems — the routing hierarchy\n\nWhenever a workflow step must talk to a system outside the workflow (an ERP, CRM, mailbox, payment\nprovider, any HTTP/REST or JSON-RPC API), pick the **least-code route** that works, in this **strict\npriority order**. Always start at route 1 and only descend when the route above genuinely doesn't apply.\n\n> **The rule, plainly: never hand-roll an external API in a `Callback` when a specialized node, an MCP\n> server, or `defineRestNode` can do it.** A hand-rolled `fetch`/JSON-RPC call is the last resort only —\n> it is considered non-production-grade whenever any route above it is available (no declared credential\n> slot, no shared session, no inspector surface, easy to get auth/retries wrong).\n\n**Discipline:** run this decision tree for **every** external step before you write code. Then author\nthe step from the route's deeper skill, run `verify_workflow`, and fix only what it flags.\n\n## The decision tree\n\n```text\nStep talks to an external system?\n│\n├─ 1. Is there a SPECIALIZED NODE for this service?\n│ discover: search_skills(\"<service>\") → e.g. odoo / gmail / msgraph\n│ yes → use that package's nodes (they declare the credential slot for you). DONE.\n│\n├─ 2. Is there an MCP SERVER for this service on the control plane?\n│ discover: list_mcp_servers (or GET /api/registry/capabilities?query=<service>)\n│ yes → drive it from an AIAgent: new AIAgent({ ..., mcpServers: [\"<id>\"] }). DONE.\n│\n├─ 3. Is it a plain REST/HTTP JSON API (no node, no MCP)?\n│ yes → wrap one endpoint with defineRestNode({ api, credentials, request, response }). DONE.\n│\n└─ 4. None of the above fits (multi-step JSON-RPC, streaming, an SDK with no REST surface)?\n LAST RESORT → hand-roll fetch / JSON-RPC inside a Callback, resolving auth via\n ctx.getCredential(...). Justify why routes 1–3 didn't apply.\n```\n\n## Route 1 — specialized node (preferred)\n\nIf a node package exists for the service, use it. It ships typed nodes, declares the credential slot for\nyou, and encodes the service's quirks. **Discover it before assuming it doesn't exist:**\n\n```text\nsearch_skills(\"odoo\") → odoo skill: odooQueryNode / odooCreateNode / call_kw, slot \"odoo\"\nsearch_skills(\"gmail\") → gmail skill: OnNewGmailTrigger / ReplyToGmailMessage, slot \"auth\"\nsearch_skills(\"msgraph\") → Microsoft Graph (Outlook / Teams / SharePoint) nodes\n```\n\nWhen `search_skills` returns a matching integration skill, open it and author from its nodes. Deeper\nskills: **`odoo`** (ERP — match/create sale orders, partners, products), **`gmail`** (mailbox),\n**`msgraph`** (Microsoft 365). This is always the least-code, most-correct option — never reach past a\nspecialized node to a hand-rolled call.\n\n## Route 2 — MCP server on the control plane\n\nNo specialized node, but the service may be reachable as an **MCP server** registered on the control\nplane. An `AIAgent` step can then call its tools. **Discover the server id first — never guess it:**\n\n```text\nlist_mcp_servers\nGET /api/registry/capabilities?query=<service> → { id, displayName, acceptedCredentialTypes }\n```\n\nPass the discovered `id` into the agent's `mcpServers` array. The credential binds on the materialized\nMCP connection node via the canvas dropdown (the operator binds; you only declare):\n\n```typescript\nimport { AIAgent, CodemationChatModelConfig } from \"@codemation/core-nodes\";\n\ntype Ticket = { summary: string };\n\n// Drive a CP-registered MCP server's tools from an LLM step. \"<server-id>\" comes from\n// list_mcp_servers / the capabilities registry — do not invent it.\nconst triage = new AIAgent<Ticket>({\n name: \"Triage via MCP\",\n id: \"triage-via-mcp\",\n messages: [\n { role: \"system\", content: \"Use the available tools to look up and update the ticket.\" },\n { role: \"user\", content: ({ item }) => item.json.summary },\n ],\n chatModel: new CodemationChatModelConfig(\"Managed AI\", \"low\"),\n mcpServers: [\"<server-id>\"],\n});\n```\n\nDeeper skills: **`mcp-capabilities`** (discovery + credential rules) and **`ai-agent`** (the `AIAgent`\nnode, `mcpServers`, tools, and structured output).\n\n## Route 3 — `defineRestNode` for a plain REST API\n\nNo specialized node and no MCP server, but the service is a plain REST/HTTP JSON API. Wrap the endpoint\nwith **`defineRestNode`** — declare `api` (base URL, path, method), a `credentials` slot, and the\n`request`/`response` mappers, then use `node.create(...)` in the builder. This is strongly preferred over\na raw `fetch`: you get a typed node, a declared credential slot, a shared HTTP session, and SSRF guards\nfor free. Deeper skill: **`rest-node`** (full API + a complete example).\n\n## Route 4 — hand-rolled `Callback` (last resort)\n\nOnly when none of routes 1–3 can express the call — e.g. a multi-step JSON-RPC handshake, a streaming\nprotocol, or an SDK with no REST surface — hand-roll the call inside a `Callback`, resolving auth via\n`ctx.getCredential<T>(slotKey)` for a slot a credentialed node owns. State why routes 1–3 didn't apply.\nA plain `Callback` slot is not itself bindable — see `workflow-dsl` (Credentials) for the pattern.\n\n## Iterative / fuzzy matching: prefer an AIAgent with that integration's nodes as tools\n\nWhen a step must **match real-world text against integration records** (fuzzy company names, partial product codes, contacts that may or may not exist), a deterministic node chain fans out, loses the canonical payload, and fabricates ids on no-match. Instead, author an `AIAgent` whose `tools` are that integration's nodes wrapped via `AgentToolFactory.asTool(node.create(...), { mapInput, mapOutput })` — the agent calls one search per entity, reasons over the results, links matched ids, and explicitly reports what it cannot resolve. See the `odoo` skill for the full node-backed tool pattern (and the `ai-agent` skill for `AgentToolFactory.asTool` mechanics).\n\n## Anti-patterns\n\n- **Don't hand-roll `fetch`/JSON-RPC when a route above works.** A specialized node, MCP server, or\n `defineRestNode` is always preferred — the raw `Callback` is the last resort, not the default.\n- **Don't skip discovery.** Run `search_skills` and `list_mcp_servers` _before_ concluding \"there's no\n node/server for this\" — guessing leads straight to an unnecessary hand-rolled call.\n- **Don't put credentials in code.** Every route declares a bindable slot; the operator binds the\n instance via the canvas. Never inline secrets or hard-code tokens.\n- **Never wire to fabricated external resource IDs.** Gmail label IDs, Drive folder IDs, ERP record IDs,\n and any other external-system identifiers must come from config, a credential, or a prior lookup node.\n If the ID isn't guaranteed to exist at runtime, **omit the feature or flag it for human setup** —\n shipping a workflow wired to a hard-coded ID not guaranteed to exist is worse than a missing feature.\n Use a placeholder comment (`// TODO: bind real label id from config`) or a config field instead.\n\n## Build what works; report what's missing — and never claim what you didn't wire\n\n**If you don't have all the required information at build time, do NOT invent it.** Build everything\nthat DOES work with what you know, and explicitly surface every gap so the concierge can ask the user\nfor the missing detail.\n\n**Don't claim/comment functionality you didn't wire.** If a comment says \"includes order_line\ncommands\" but the code doesn't actually build them, remove the comment. A misleading comment is\nworse than silence: it tells the next agent (and the operator) the step works when it doesn't.\nThe rule: every claim in a comment must be true of the code immediately below it. If you INTEND\nto wire something but haven't, either wire it now OR omit it and call `record_decision` to surface\nthe gap — never write a claim that the code doesn't back.\n\n**A `callableTool` whose `execute` is a stub is the same defect as claiming unwired functionality.**\nA `callableTool` with `execute: async () => ({ ok: true })`, a `// TODO` for the real call, or\nany `execute` body that doesn't perform the intended external operation is a runtime lie — it builds\nand typechecks but does nothing. For any real external or ERP operation, use a NODE-BACKED tool\n(`AgentToolFactory.asTool(theNode.create(...), { mapInput, mapOutput })`) so the specialized node\nperforms the call. If you genuinely cannot wire the operation (missing credential, unreachable\nendpoint), omit the tool entirely and call `record_decision` to surface the gap — never ship a\nstubbed `callableTool` standing in for a real integration call.\n\n**What counts as \"invented\":** a Gmail label ID that doesn't come from the user, an ERP record ID you\ndon't know, a folder path you assume, a queue name you guessed. Any identifier that will fail at\nruntime if the real account/system is different.\n\n**What to do instead:**\n\n1. **Use runtime-resolved references when the node supports it.** Gmail's `addLabels`/`removeLabels`\n (on `ModifyGmailLabels`) and `labelIds` (on `OnNewGmailTrigger`) accept display names — the\n plugin resolves them at runtime. Prefer those over guessed IDs. Check the target node's type\n definition before assuming you need a raw ID.\n\n2. **If no runtime-resolved reference exists,** build the surrounding workflow but declare the missing\n value as a named required constant at the top of the file with a `// TODO(setup): <what's needed>`\n comment, so the gap is unmissable:\n\n ```text\n // TODO(setup): replace with the real Jira project key from the customer's account\n const JIRA_PROJECT_KEY = \"TODO_REPLACE\";\n ```\n\n3. **Call `record_decision` to surface the gap** to the concierge and the build report:\n\n ```text\n record_decision({\n choice: \"Left JIRA_PROJECT_KEY as TODO_REPLACE — user must supply their Jira project key\",\n why: \"No project key was provided; inventing one would break every run on the real account.\",\n })\n ```\n\n4. **Do NOT omit the entire feature.** Build the node/step with the placeholder; the concierge can\n bind or configure the real value once the user provides it. An incomplete-but-correct skeleton is\n always better than a fully wired but runtime-broken workflow.\n\nThe eval scoring criteria: a workflow that builds AND explicitly reports missing info is CORRECT. A\nworkflow that fabricates values that will fail at runtime is WRONG regardless of how \"complete\" it looks.\n\n## Read next when needed\n\n- `odoo` / `gmail` / `msgraph` — route 1 specialized-node skills.\n- `mcp-capabilities` + `ai-agent` — route 2: discover MCP servers and drive them from `AIAgent`.\n- `rest-node` — route 3: wrap a plain REST endpoint with `defineRestNode`.\n- `workflow-dsl` — the surrounding builder, and the `Callback` + `ctx.getCredential` pattern for route 4.\n",
65
65
  "format": "doc",
@@ -91,7 +91,7 @@
91
91
  ],
92
92
  "sourcePath": "skills/builder/custom-node-development/SKILL.md",
93
93
  "dependencies": {
94
- "@codemation/core": "0.14.0"
94
+ "@codemation/core": "0.15.0"
95
95
  },
96
96
  "code": "---\nname: custom-node-development\ndescription: Authors a reusable Codemation node with defineNode(...) (per-item execute) or defineBatchNode(...) (batch run), including credential slots, binary payloads, and the class-based fallback. Use when creating or updating custom nodes in an app or plugin package.\ntags: node, custom, plugin\nuses: \"@codemation/core\"\n---\n\n# Codemation Custom Node Development\n\nCustom nodes are the extension point for reusable business logic that doesn't belong inline in a workflow callback. `defineNode(...)` wraps a per-item `execute` function with a typed contract (config, credential slots, output shape); the engine calls it once per item. `defineBatchNode(...)` is the batch variant for logic that must see all items at once. A node definition exposes `.create(config, name?, id?)` to wire it into a workflow.\n\n## Per-item vs batch\n\n**`defineNode(...)` (per-item)** — the engine calls `execute(args, context)` once per item. This is the right default for the vast majority of nodes: straightforward logic, credential slots, input schema, optional fan-out.\n\n**`defineBatchNode(...)` (batch)** — the engine calls `run(items, context)` with the full activation batch. Use only when the node genuinely needs to see all items at once (aggregation, bulk API calls, cross-item correlation).\n\nWhen in doubt, start with `defineNode`.\n\n## Node rules\n\n1. Keep nodes deterministic and focused.\n2. Request credentials through named slots — never hard-code secrets.\n3. Put **static** options (credentials, retry policy, labels) on `input` (the config defaults); read them in `execute` via the second arg's `config`.\n4. **Emit files with `ctx.binary`, not base64 in `json`** — base64 in `item.json` bloats persisted run data. See `references/node-patterns.md`.\n5. Drop to class-based node APIs only when you need constructor-injected collaborators, decorators, or deeper runtime metadata.\n\n## Minimal `defineNode` example\n\n`execute(args, context)` receives `args = { input, item, itemIndex, items, ctx }` and `context = { config, credentials, execution }`. `input` is the per-item `item.json`; `config` is the resolved static config declared in `input:`.\n\n```ts\nimport { defineNode } from \"@codemation/core\";\n\nexport const normalizeTextField = defineNode({\n key: \"example.normalize-text-field\",\n title: \"Normalize text field\",\n icon: \"lucide:case-lower\",\n input: {\n field: \"text\",\n trim: true as boolean,\n lowercase: true as boolean,\n },\n execute({ input }, { config }) {\n const rawValue = String((input as Record<string, unknown>)[config.field as string] ?? \"\");\n let normalized = rawValue;\n if (config.trim) normalized = normalized.trim();\n if (config.lowercase) normalized = normalized.toLowerCase();\n return { ...(input as Record<string, unknown>), [config.field as string]: normalized };\n },\n});\n```\n\nWire it into a workflow with `normalizeTextField.create({ field: \"text\" }, \"Normalize text\", \"normalize-text\")`.\n\n## Read next\n\n- `references/define-node-per-item.md` — `defineNode(...)` contract, `inputSchema`, fan-out, and assertion nodes.\n- `references/define-batch-node.md` — `defineBatchNode(...)` contract and when to choose batch over per-item.\n- `references/credential-aware-nodes.md` — credential slots and typed sessions.\n- `references/node-patterns.md` — binary payloads (`ctx.binary`, `attach`, `withAttachment`), fan-out shapes, polling-trigger binary patterns, and HTTP binary round-trips.\n",
97
97
  "format": "doc",
@@ -116,7 +116,7 @@
116
116
  ],
117
117
  "sourcePath": "skills/builder/document-ai/SKILL.md",
118
118
  "dependencies": {
119
- "@codemation/core-nodes": "0.12.0"
119
+ "@codemation/core-nodes": "0.14.0"
120
120
  },
121
121
  "code": "---\nname: document-ai\ndescription: Extracts markdown text and structured fields from a document, invoice, or image with the managed codemationDocumentScannerNode — no Azure or BYOK credential needed. Use this whenever a workflow scans an attachment (PDF, receipt, photo) and reads back markdown or per-field values.\ncompatibility: Codemation core-nodes. Requires @codemation/core-nodes import.\ntags: ocr, document, invoice, image, scan, extract, markdown, fields, confidence, managed, binary\nuses: \"@codemation/core-nodes\"\n---\n\n# Codemation Document Scanner\n\n`codemationDocumentScannerNode` reads the bytes of a binary attachment off `item.binary` and returns\n`{ markdown, fields }` from the **managed** Codemation doc-scanner service. It signs each call with the\nworkspace pairing secret over HMAC — the workspace holds **no Azure key**. This is the default for any\nscanning step on the managed platform.\n\n**Discipline:** author straight from this file, then run `verify_workflow` and fix only what it flags.\nUse `workflow-dsl` for the surrounding builder, trigger, and flow-control surface.\n\n## A complete scanning workflow\n\nA webhook receives a file upload; the framework places the bytes at `item.binary[\"data\"]`; the node\nanalyzes them and replaces the item payload with `{ markdown, fields }`.\n\n```typescript\nimport { createWorkflowBuilder, WebhookTrigger, codemationDocumentScannerNode } from \"@codemation/core-nodes\";\n\nexport default createWorkflowBuilder({\n id: \"wf.scan-document\",\n name: \"Scan an uploaded document\",\n})\n .trigger(\n new WebhookTrigger(\"Receive upload\", { endpointKey: \"doc-upload\", methods: [\"POST\"] }, undefined, {\n id: \"receive-upload\",\n description: \"Accepts an uploaded file and starts the scan.\",\n }),\n )\n .then(\n codemationDocumentScannerNode.create(\n {\n binaryField: \"data\", // key on item.binary holding the bytes — default \"data\"\n analyzerType: \"auto\", // routes on Content-Type; set explicitly when you know the class\n },\n \"Scan document\",\n { id: \"scan-document\", description: \"Reads the uploaded file and pulls out its text and key fields.\" },\n ),\n )\n .build();\n```\n\n`.create(config, label, idOrOptions)` is the `defineNode` call shape — same for every built-in node here.\nThe third argument takes either a bare `\"nodeId\"` string OR an options object `{ id, description }` — **always\npass the object so the scan step carries a plain-language `description`** (it is a node like any other; a\ndocument/OCR step with no description is the most-forgotten gap). Set an explicit `id` whenever a downstream\nnode references this output or the label may change later.\n\n## Choosing `analyzerType`\n\nDefault is `\"auto\"`. Set an explicit type whenever you know the content class — it avoids re-routing\nand self-documents the workflow.\n\n| `analyzerType` | When | Field extraction |\n| -------------- | ---------------------------------------------------- | ------------------ |\n| `\"document\"` | General PDFs, Word, HTML, text-heavy files | Yes |\n| `\"invoice\"` | Invoices and receipts | Yes |\n| `\"image\"` | Photos, screenshots, diagrams | No (markdown only) |\n| `\"auto\"` | Unknown mime type — `image/*` → image, else document | Depends on routing |\n\n## Output shape\n\nThe node replaces the item payload with this shape (`DocScannerOutput`):\n\n```text\n{\n markdown: string; // full text rendering of the document\n fields: Record<string, {\n value: unknown; // scalar, ISO date string, nested object, or array\n confidence: number | null; // 0–1 when includeConfidence:true; otherwise null\n }>;\n}\n```\n\n`item.json.markdown` is the Markdown rendering. `item.json.fields` is a flat-or-nested map of\nstructured fields the analyzer found — sparse or empty for generic documents (best-effort), and\nalways `{}` for `analyzerType: \"image\"`.\n\n## Per-field confidence (opt-in)\n\nBy default `confidence` is `null` on every field — this keeps token cost low for the common case that\nonly needs `value`. Set `includeConfidence: true` to populate it:\n\n```typescript\nimport { createWorkflowBuilder, WebhookTrigger, codemationDocumentScannerNode } from \"@codemation/core-nodes\";\n\nexport default createWorkflowBuilder({ id: \"wf.scan-invoice\", name: \"Scan invoice with confidence\" })\n .trigger(new WebhookTrigger(\"Receive invoice\", { endpointKey: \"invoice-upload\", methods: [\"POST\"] }))\n .then(\n codemationDocumentScannerNode.create(\n {\n analyzerType: \"invoice\",\n includeConfidence: true, // fields now carry confidence 0–1\n },\n \"Scan invoice\",\n \"scan-invoice\",\n ),\n )\n .build();\n```\n\n**Cost:** enabling confidence routes to a confidence-enabled analyzer variant, roughly doubling the\ncontextualization token count for `document`/`invoice`. Only enable it when downstream logic reads\n`field.confidence`. `image` (and auto-routed-to-image) requests ignore the flag silently — confidence\nstays `null`, never a 400.\n\n## Consuming fields downstream\n\nRead named fields off `item.json.fields` in a following step. Type the `Callback` input with\n`DocScannerOutput` so the field map is inferred:\n\n```typescript\nimport { Callback } from \"@codemation/core-nodes\";\nimport type { DocScannerOutput } from \"@codemation/core-nodes\";\n\nconst useFields = new Callback<DocScannerOutput, { vendorName?: string; vendorConfidence: number | null }>(\n \"Use invoice fields\",\n (items) =>\n items.map((item) => {\n const vendorName = item.json.fields[\"VendorName\"]?.value as string | undefined;\n const vendorConfidence = item.json.fields[\"VendorName\"]?.confidence ?? null;\n return { ...item, json: { vendorName, vendorConfidence } };\n }),\n { id: \"use-invoice-fields\" },\n);\n```\n\n## Config reference\n\n```text\ncodemationDocumentScannerNode.create(\n {\n binaryField?: string; // key on item.binary — default \"data\"\n analyzerType?: \"document\" | \"invoice\" | \"image\" | \"auto\"; // default \"auto\"\n contentType?: string; // MIME override — falls back to the attachment's mimeType\n includeConfidence?: boolean; // default false — see the cost note above\n maxBytes?: number; // size cap before reading — default 50 MiB\n },\n label?: string, // node label on the canvas\n nodeId?: string, // explicit stable id — set when output is used downstream\n)\n```\n\n## Gotchas\n\n- **Bytes come from `item.binary`, never base64 on `item.json`.** The node reads\n `item.binary[binaryField]`; a webhook, Gmail, or `readWorkspaceFileNode` must have attached the bytes\n to that slot upstream. Missing attachment → the node throws.\n- **The output replaces the item payload.** After scanning, `item.json` is `{ markdown, fields }` — the\n original payload is gone. Carry forward anything you still need before this step, or read it from a\n retained binary slot.\n- **`fields` is best-effort.** Guard every `fields[\"Name\"]?.value` access; the analyzer may not find it.\n- **Managed only.** The node needs `DOC_SCANNER_GATEWAY_URL` + workspace pairing in the env; it throws a\n clear error when run outside a paired workspace.\n\n## Read next when needed\n\n- `workflow-dsl` — builder, triggers, flow control, the per-item contract.\n- `workspace-files` — read a stored file's bytes into `item.binary` before scanning.\n- `ai-agent` — pass `item.json.markdown` to an LLM for summarization or extraction.\n",
122
122
  "format": "doc",
@@ -147,8 +147,8 @@
147
147
  ],
148
148
  "sourcePath": "skills/builder/execution-context/SKILL.md",
149
149
  "dependencies": {
150
- "@codemation/core-nodes": "0.12.0",
151
- "@codemation/core": "0.14.0"
150
+ "@codemation/core-nodes": "0.14.0",
151
+ "@codemation/core": "0.15.0"
152
152
  },
153
153
  "code": "---\nname: execution-context\ndescription: Teaches how data moves between nodes in Codemation workflows — read the immediate prior node's output from item.json (the engine put it there), read ANY earlier node's output directly from its source via ctx.data.getOutputItem<T>(NODE_ID, idx) (you never carry it forward), and reserve ctx.binary for actual file bytes. Essential for any multi-step workflow (order intake, document processing, ETL) where a later step needs a value that an intermediate node replaced. Read this before writing any node that reads data from a prior step.\ncompatibility: Applies to all Codemation workflow nodes and the Callback, MapData, defineNode, and itemExpr APIs.\ntags: execution, context, item, json, binary, mapdata, data-passing, anti-pattern, ocr, workflow, authoring, nodes, between-steps, getoutputitem, cross-step-read, order-intake, document-processing\nuses: \"@codemation/core-nodes, @codemation/core\"\n---\n\n# Execution context — how data moves between nodes\n\nThe engine passes every node's output to the **immediate next** node as `item.json`. That covers the\ncommon case. But many workflows need a value that lives **further back** — the trigger's `messageId`,\nan early extraction field — after an intermediate node (a structured-output `AIAgent`, an OCR scanner)\n**replaced** `item.json`. For that, you do not carry the value forward through every step. You read it\n**directly from its source node** with `ctx.data.getOutputItem<T>(NODE_ID, idx)`.\n\n**Core rules:**\n\n1. **Immediate prior node's output → `item.json`.** The previous node already put its result there.\n Read it directly.\n2. **Any EARLIER node's output → `ctx.data.getOutputItem<T>(NODE_ID, idx)`.** Fetch it from the SOURCE\n node by id. It does not matter what an intermediate agent did to `item.json` in between — the source\n node's output is still recorded in the run and you read it from there. No carry-forward, no re-merge.\n3. **`ctx.binary` is for file bytes only.** Binary attachments (PDFs, images, uploaded files) live in\n `item.binary` and are read via `ctx.binary`. Never stash structured JSON as a fabricated binary to\n \"carry it along\" — structured data belongs on `item.json`.\n\n## Reading an earlier node's output — `ctx.data.getOutputItem`\n\n`RunDataSnapshot` (available as `ctx.data` inside any node) exposes the recorded output of every node\nthat has already run this turn:\n\n```ts no-check\ngetOutputItem<TJson = unknown>(nodeId: NodeId, itemIndex: number, output?: OutputPortKey): Item<TJson> | undefined\n```\n\n- `nodeId` — the **id you gave the source node** (the trigger, an extraction step, …). `NodeId` is a\n plain `string`, so a stable `const` id (e.g. `const TRIGGER_ID = \"gmail-trigger\"`) passes directly.\n- `itemIndex` — the index into that node's output, aligned to the **current item's lineage**. For 1:1\n flows pass the current index — often the loop index `idx`, or `0` at a single-item point such as the\n trunk start. Under fan-out (one upstream item produced many, or vice versa) the indices no longer line\n up 1:1, so align carefully.\n- Returns `Item<TJson> | undefined` — always guard with `?.json` and a fallback.\n- `output` defaults to `\"main\"`; omit it for the common case.\n\n### Recover a clobbered field from its source node\n\nA structured-output `AIAgent` (e.g. an Odoo sync) **replaces** `item.json` with its schema-typed object\n— the trigger's `messageId` / email metadata is GONE from `item.json` after it. You do **not** thread\nthat metadata through every node. You read it back from the **trigger** directly:\n\n```typescript\nimport { z } from \"zod\";\nimport { createWorkflowBuilder, AIAgent, CodemationChatModelConfig } from \"@codemation/core-nodes\";\nimport { OnNewGmailTrigger, ReplyToGmailMessage } from \"@codemation/core-nodes-gmail\";\nimport { itemExpr } from \"@codemation/core\";\nimport type { OnNewGmailTriggerItemJson } from \"@codemation/core-nodes-gmail\";\n\n// Stable ids so getOutputItem can back-reference the source node by id.\nconst TRIGGER_ID = \"gmail-trigger\";\nconst SYNC_ID = \"odoo-sync\";\n\nconst model = new CodemationChatModelConfig(\"Managed AI\", \"medium\");\n\n// The Odoo-sync agent's output schema — this REPLACES item.json. No messageId here.\nconst SyncOutput = z.object({ saleOrderId: z.string(), unmatched: z.array(z.string()) });\ntype SyncOutputT = z.infer<typeof SyncOutput>;\n\nexport default createWorkflowBuilder({ id: \"wf.order-sync-reply\", name: \"Order sync + reply\" })\n .trigger(\n new OnNewGmailTrigger(\"New order email\", {\n mailbox: \"me\",\n labelIds: [\"orders\"],\n downloadAttachments: true,\n }),\n )\n // Step 1: structured-output agent. Its { saleOrderId, unmatched } REPLACES item.json —\n // the trigger's messageId is no longer on item.json after this point.\n .then(\n new AIAgent<OnNewGmailTriggerItemJson, SyncOutputT>({\n name: \"Odoo sync agent\",\n id: SYNC_ID,\n messages: [{ role: \"system\", content: \"Sync the order to Odoo. Respond with strict JSON.\" }],\n chatModel: model,\n outputSchema: SyncOutput,\n }),\n )\n // Step 2: reply to the sender. messageId is NOT on item.json anymore — read it straight\n // from the TRIGGER's recorded output via getOutputItem. The current item is the sync output.\n .then(\n new ReplyToGmailMessage(\n \"Reply to sender\",\n {\n // ✅ Read messageId from the SOURCE node (the trigger), not from item.json.\n messageId: itemExpr(({ ctx }) => {\n const triggerItem = ctx.data.getOutputItem<OnNewGmailTriggerItemJson>(TRIGGER_ID, 0);\n return triggerItem?.json.messageId ?? \"\";\n }),\n text: itemExpr(({ item }) => {\n const sync = item.json as SyncOutputT; // current item.json IS the sync output\n return `Your order has been processed (Odoo SO: ${sync.saleOrderId}).`;\n }),\n },\n \"reply-to-sender\",\n ),\n )\n .build();\n```\n\n### The same read inside a `Callback` (item-index aligned)\n\nInside a `Callback` you iterate items, so the `idx` from the loop is the lineage index to pass — it\naligns the current item with the matching trigger item in a 1:1 flow:\n\n```typescript\nimport { Callback } from \"@codemation/core-nodes\";\nimport type { Items } from \"@codemation/core\";\nimport type { OnNewGmailTriggerItemJson } from \"@codemation/core-nodes-gmail\";\nimport type { DocScannerOutput } from \"@codemation/core-nodes\";\n\nconst TRIGGER_ID = \"gmail-trigger\";\n\n// The OCR scanner already replaced item.json with { markdown, fields }. We need the email\n// subject + body from the trigger to feed the next agent — read them from the trigger by id.\ntype RouterInput = DocScannerOutput & {\n mailSubject?: string;\n mailBody?: string;\n messageId: string;\n};\n\n// ✅ Read the trigger item by id, using the loop idx as the lineage index (1:1 flow).\nconst mergeMailBody = new Callback<DocScannerOutput, RouterInput>(\n \"Merge mail body into item\",\n (items: Items<DocScannerOutput>, ctx) =>\n items.map((item, idx) => {\n const triggerItem = ctx.data.getOutputItem<OnNewGmailTriggerItemJson>(TRIGGER_ID, idx);\n return {\n ...item,\n json: {\n ...item.json,\n mailSubject: triggerItem?.json.subject,\n mailBody: triggerItem?.json.textPlain ?? triggerItem?.json.snippet,\n messageId: triggerItem?.json.messageId ?? \"\",\n } satisfies RouterInput,\n };\n }),\n { id: \"merge-mail-body\" },\n);\nexport {};\n```\n\nThe key point: the **graph between the trigger and this node is irrelevant**. An OCR step, an agent, a\n`Switch` — whatever ran in between and however it reshaped `item.json` — the trigger's output is still\nrecorded under `TRIGGER_ID`, and `getOutputItem` reads it from there.\n\n## Anti-patterns vs. good patterns\n\n### (a) A recovery cast for a field that was clobbered — read it from the source instead\n\nWhen a structured-output agent or OCR node replaces `item.json`, a field you still need (e.g.\n`messageId`) is no longer there. The **broken** fix is a phantom cast that pretends the field survived —\nit compiles but is `undefined` at runtime because the field was clobbered and never re-fetched. The\nclean fix is to read it from its source node with `getOutputItem`.\n\n```typescript\nimport { z } from \"zod\";\nimport { Callback } from \"@codemation/core-nodes\";\nimport type { Items } from \"@codemation/core\";\nimport type { OnNewGmailTriggerItemJson } from \"@codemation/core-nodes-gmail\";\n\nconst TRIGGER_ID = \"gmail-trigger\";\n\nconst SyncOutput = z.object({ saleOrderId: z.string() });\ntype SyncOutputT = z.infer<typeof SyncOutput>;\n// The agent's output replaced item.json — messageId is NOT on it. The phantom cast below\n// pretends otherwise: it type-checks but `messageId` is `undefined` at runtime.\ntype SyncOutputWithMessageId = SyncOutputT & { messageId: string };\n\n// ❌ WRONG — phantom recovery cast. messageId was clobbered by the agent and never re-fetched,\n// so item.json.messageId is undefined at runtime even though TypeScript is satisfied.\nconst badRecover = new Callback<SyncOutputT, { ack: string }>(\n \"Acknowledge\",\n (items: Items<SyncOutputT>) =>\n items.map((item) => {\n const clobbered = item.json as SyncOutputWithMessageId; // ← lie: messageId isn't there\n return { json: { ack: `ack for ${clobbered.messageId}` } }; // undefined at runtime\n }),\n { id: \"bad-recover\" },\n);\n\n// ✅ CORRECT — read messageId from its SOURCE node (the trigger) via getOutputItem.\nconst goodRecover = new Callback<SyncOutputT, { ack: string }>(\n \"Acknowledge\",\n (items: Items<SyncOutputT>, ctx) =>\n items.map((item, idx) => {\n const triggerItem = ctx.data.getOutputItem<OnNewGmailTriggerItemJson>(TRIGGER_ID, idx);\n return { json: { ack: `ack for ${triggerItem?.json.messageId ?? \"unknown\"}` } };\n }),\n { id: \"good-recover\" },\n);\nexport {};\n```\n\n### (b) Re-deriving the same shape repeatedly instead of reading the source\n\nDon't recompute a value from raw inputs in every node. Compute it once and read it where you need it —\neither from the immediate prior node's `item.json`, or, for an earlier node, from its source via\n`getOutputItem`.\n\n```typescript\nimport { createWorkflowBuilder, ManualTrigger, MapData, Callback } from \"@codemation/core-nodes\";\nimport type { Items, Item } from \"@codemation/core\";\n\ntype Raw = { companyName: string; vatId: string; lines: Array<{ sku: string; qty: number }> };\ntype Canonical = { company: string; vat: string; lineCount: number };\n\n// ❌ WRONG — deriving the same shape twice, in two separate nodes.\nconst badDerive1 = new Callback<Raw, { tag: string }>(\n \"Tag order\",\n (items: Items<Raw>) =>\n items.map((item) => ({\n json: { tag: `${item.json.companyName}-${item.json.lines.length}` }, // ← derives from raw again\n })),\n { id: \"bad-derive-1\" },\n);\n\n// ✅ GOOD — one MapData shapes the value once; the next node reads THAT shape from item.json.\nconst canonical = new MapData<Raw, Canonical>(\n \"Canonicalize\",\n (item: Item<Raw>) => ({\n company: item.json.companyName,\n vat: item.json.vatId,\n lineCount: item.json.lines.length,\n }),\n { id: \"canonical\" },\n);\n\n// Reads item.json.company / item.json.lineCount — never re-derives from raw.\nconst tagOrder = new Callback<Canonical, { tag: string }>(\n \"Tag order\",\n (items: Items<Canonical>) => items.map((item) => ({ json: { tag: `${item.json.company}-${item.json.lineCount}` } })),\n { id: \"tag-order\" },\n);\nexport {};\n```\n\n### (c) JSON stashed inside a fabricated binary instead of on item.json\n\n```typescript\nimport { createWorkflowBuilder, ManualTrigger, Callback } from \"@codemation/core-nodes\";\nimport type { Items, NodeExecutionContext } from \"@codemation/core\";\n\ntype Order = { orderId: string; total: number };\n\n// ❌ WRONG — encoding structured JSON as bytes and attaching it as a binary is wasted ceremony.\n// item.json already carries structured data between nodes at zero cost.\nconst badBinaryStash = new Callback<Order, Order>(\n \"Stash to binary\",\n async (items: Items<Order>, ctx: NodeExecutionContext) => {\n const results = [];\n for (const item of items) {\n // Attaching structured JSON as a binary → retrieving it in the next node via ctx.binary.getJson\n // is needless overhead when the engine just passes item.json for free.\n const att = await ctx.binary.attach({\n name: \"order\",\n body: Buffer.from(JSON.stringify(item.json)),\n mimeType: \"application/json\",\n });\n results.push(ctx.binary.withAttachment(item, \"order\", att));\n }\n return results;\n },\n { id: \"bad-binary-stash\" },\n);\n\n// ✅ GOOD — structured data belongs on item.json. The engine carries it for you.\n// Use ctx.binary ONLY for real file bytes (PDFs, images, CSVs from readWorkspaceFileNode, etc.).\nconst passThrough = new Callback<Order, Order>(\n \"Pass order\",\n (items: Items<Order>) => items.map((item) => ({ json: item.json })),\n { id: \"pass-order\" },\n);\nexport {};\n```\n\n## MapData — narrow/shape an extraction output (not a cross-step carry)\n\nA `MapData` is still a good tool to **narrow or shape** a noisy extraction output (`{ markdown, fields }`\nfrom OCR, or a raw agent object) into the tight shape the **immediate next** step wants. That is its job:\na clean local transform. It is **not** required for cross-step survival — a downstream node that needs an\nearlier node's data should `getOutputItem` it from the source, not depend on it having been threaded\nthrough a canonical shape.\n\n```typescript\nimport {\n createWorkflowBuilder,\n WebhookTrigger,\n MapData,\n Callback,\n codemationDocumentScannerNode,\n} from \"@codemation/core-nodes\";\nimport type { Item, Items } from \"@codemation/core\";\nimport type { DocScannerOutput } from \"@codemation/core-nodes\";\n\n// A tight shape for the immediate next step — narrowed from the noisy OCR output.\ntype OrderFields = { vendor: string; totalAmount: number };\n\nexport default createWorkflowBuilder({ id: \"wf.invoice-intake\", name: \"Invoice intake\" })\n .trigger(new WebhookTrigger(\"Receive invoice\", { endpointKey: \"invoice-upload\", methods: [\"POST\"] }))\n // Step 1: OCR — replaces item.json with { markdown, fields }.\n .then(codemationDocumentScannerNode.create({ analyzerType: \"invoice\" }, \"Scan invoice\", \"scan-invoice\"))\n // Step 2: MapData NARROWS the noisy OCR output into the shape the next step needs. This is a\n // local convenience for the immediate next node — NOT a carry-forward of earlier-node data.\n .then(\n new MapData<DocScannerOutput, OrderFields>(\n \"Narrow OCR fields\",\n (item: Item<DocScannerOutput>) => ({\n vendor: (item.json.fields[\"VendorName\"]?.value as string | undefined) ?? \"Unknown\",\n totalAmount: (item.json.fields[\"InvoiceTotal\"]?.value as number | undefined) ?? 0,\n }),\n { id: \"narrow-fields\" },\n ),\n )\n // Step 3: the immediate next node reads the narrowed shape from item.json.\n .then(\n new Callback<OrderFields, { summary: string }>(\n \"Summarize\",\n (items: Items<OrderFields>) =>\n items.map((item) => ({ json: { summary: `${item.json.vendor}: €${item.json.totalAmount}` } })),\n { id: \"summarize\" },\n ),\n )\n .build();\n```\n\n## ctx.binary — when to use it\n\n`ctx.binary` is the API for **file bytes**. Use it when:\n\n- A `readWorkspaceFileNode`, Gmail attachment, or webhook upload put bytes on `item.binary`.\n- You need to read a PDF, image, CSV, or JSON file that arrived as a binary attachment.\n\nNever use it as a workaround for passing structured data between nodes. If it's JSON, it belongs on\n`item.json` (immediate prior) or is read via `getOutputItem` (earlier node).\n\n```typescript\nimport { Callback } from \"@codemation/core-nodes\";\nimport type { NodeExecutionContext } from \"@codemation/core\";\n\ntype FileMeta = { fileId: string; binarySlot: string };\ntype Product = { sku: string; price: number };\n\n// ✅ Correct: ctx.binary reads actual file bytes from a slot an upstream node attached.\nconst parseFile = new Callback<FileMeta, { count: number }>(\n \"Parse product file\",\n async (items, ctx: NodeExecutionContext) => {\n const results: Array<{ json: { count: number } }> = [];\n for (const item of items) {\n const attachment = item.binary?.[\"data\"]; // bytes put there by readWorkspaceFileNode\n if (!attachment) throw new Error('No binary at slot \"data\"');\n const products = await ctx.binary.getJson<Product[]>(attachment); // bounded read + parse\n results.push({ json: { count: products.length } });\n }\n return results;\n },\n { id: \"parse-file\" },\n);\nexport {};\n```\n\n### (d) Enrichment Callback REPLACES item.json — always spread the existing payload\n\nA `Callback`'s return value **replaces `item.json` entirely** — exactly like any other node.\nAn enrichment step (one that looks up a derived field, e.g. a matched ERP partner id) that\nreturns ONLY the new field DISCARDS the entire prior payload (companyName, lineItems,\ncontactEmail, …) that the **immediate next** node needs.\n\n**The rule:** an enrichment `Callback` MUST return `{ ...item.json, newField }` — not just\n`{ newField }`. (If an even earlier node's value is needed downstream, that's `getOutputItem`'s job,\nnot this spread's.)\n\n```typescript\nimport { Callback } from \"@codemation/core-nodes\";\nimport type { Items } from \"@codemation/core\";\n\ntype OrderCanonical = {\n companyName: string;\n contactEmail: string;\n lineItems: Array<{ sku: string; qty: number }>;\n};\ntype WithPartner = OrderCanonical & { partnerId: number; partnerName: string };\n\n// ❌ WRONG — returns only the looked-up fields, replacing item.json and discarding\n// companyName, contactEmail, lineItems that the next node needs.\nconst badEnrich = new Callback<OrderCanonical, { partnerId: number; partnerName: string }>(\n \"Enrich partner\",\n (items: Items<OrderCanonical>) =>\n items.map((i) => ({\n json: { partnerId: 42, partnerName: i.json.companyName }, // ← DISCARDS the rest\n })),\n { id: \"bad-enrich\" },\n);\n\n// ✅ CORRECT — spreads the entire existing payload, THEN adds the new fields.\n// The next node still sees companyName, contactEmail, lineItems, AND the new ids.\nconst goodEnrich = new Callback<OrderCanonical, WithPartner>(\n \"Enrich partner\",\n (items: Items<OrderCanonical>) =>\n items.map((i) => ({\n json: { ...i.json, partnerId: 42, partnerName: i.json.companyName },\n })),\n { id: \"good-enrich\" },\n);\nexport {};\n```\n\n## Summary\n\n| Question | Answer |\n| --------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- |\n| Where is the IMMEDIATE prior node's output? | `item.json` — read it directly. |\n| How do I read an EARLIER node's output (trigger, an extraction step)? | `ctx.data.getOutputItem<T>(NODE_ID, idx)` — from the source node by id. You never carry it forward. |\n| A field I need was clobbered by an intermediate agent/OCR — now what? | Read it from its source node via `getOutputItem`. Never a phantom recovery cast — that's `undefined` at runtime. |\n| When do I need a MapData? | To NARROW/shape an extraction output for the IMMEDIATE next step. Not required for cross-step survival. |\n| When do I use `ctx.binary`? | Only for file bytes (PDF, image, CSV, JSON file from a read node). |\n| Can I JSON-encode structured data into a binary to pass it along? | No — that's waste. `item.json` carries it for free; earlier nodes via `getOutputItem`. |\n| My enrichment Callback looks up a field — what do I return? | `{ ...item.json, newField }` — spread first, add second. Never only `{ newField }`. |\n\n## Read next when needed\n\n- `document-ai` — the OCR node that produces `{ markdown, fields }` (one source you may later `getOutputItem`).\n- `ai-agent` — extraction via LLM; `outputSchema` produces a typed object that REPLACES `item.json`.\n- `workflow-dsl` — `Callback`, `MapData`, `itemExpr`, and the full builder API.\n- `connect-external-systems` → \"Build what works; report what's missing\" — if a required value (label\n name, folder ID, record ID) is unknown at build time, use a named TODO placeholder and\n `record_decision`; never fabricate an identifier.\n",
154
154
  "format": "doc",
@@ -164,10 +164,10 @@
164
164
  ],
165
165
  "sourcePath": "skills/builder/framework-concepts/SKILL.md",
166
166
  "dependencies": {},
167
- "code": "---\nname: framework-concepts\ndescription: Explains Codemation package boundaries, runtime concepts, and the consumer-versus-framework mental model across @codemation/core, @codemation/host, @codemation/next-host, and @codemation/cli. Use to orient on where code belongs before picking a more specific skill.\ntags: concepts, architecture\n---\n\n# Codemation Framework Concepts\n\nCodemation is a workflow engine with a layered package structure. `@codemation/core` owns the engine and runtime contracts (must stay pure — no HTTP, UI, or vendor SDKs). `@codemation/host` adds persistence, credentials, APIs, and scheduler wiring. `@codemation/next-host` is the framework UI shell. `@codemation/cli` runs local development, build, and serve. Consumer apps define behavior in `codemation.config.ts` and `src/workflows/` — they never touch core internals.\n\n## Core concepts\n\n- **workflows** define behavior; **triggers** start runs; **nodes** process items; **items** carry `item.json` data.\n- **credentials** provide typed runtime resources (bound per operator instance, not per workflow code).\n- **activation** is framework-managed and happens in the UI — consumer code does not call it directly.\n- **telemetry** is observability-first: traces, spans, artifacts, and metric points are framework-owned runtime data.\n- **workflow testing** is a first-class primitive: `TestTrigger` yields one item per test case; `Assertion` nodes record per-run results into `TestAssertion` rows; the canvas exposes a Tests tab.\n- **run retention** and **telemetry retention** can differ — trend data can outlive raw run state.\n\n## Where to go next\n\n| Task | Skill |\n| ------------------------------------- | ------------------------- |\n| Authoring workflows | `workflow-dsl` |\n| Building a reusable node | `custom-node-development` |\n| Building a credential type | `credential-development` |\n| Packaging as a plugin | `plugin-development` |\n| Calling an MCP server from a workflow | `mcp-capabilities` |\n| CLI commands / dev loop | `cli` |\n\n## Read next when needed\n\n- Read `references/architecture-map.md` for package ownership and runtime-mode guidance.\n",
167
+ "code": "---\nname: framework-concepts\ndescription: Explains Codemation package boundaries, runtime concepts, and the consumer-versus-framework mental model across @codemation/core, @codemation/host, @codemation/next-host, and @codemation/cli. Use to orient on where code belongs before picking a more specific skill.\ntags: concepts, architecture\n---\n\n# Codemation Framework Concepts\n\nCodemation is a workflow engine with a layered package structure. `@codemation/core` owns the engine and runtime contracts (must stay pure — no HTTP, UI, or vendor SDKs). `@codemation/host` adds persistence, credentials, APIs, and scheduler wiring. `@codemation/next-host` is the framework UI shell. `@codemation/cli` runs local development, build, and serve. Consumer apps define behavior in `codemation.config.ts` and `src/workflows/` — they never touch core internals.\n\n## Core concepts\n\n- **workflows** define behavior; **triggers** start runs; **nodes** process items; **items** carry `item.json` data.\n- **credentials** provide typed runtime resources (bound per operator instance, not per workflow code).\n- **activation** is framework-managed and happens in the UI — consumer code does not call it directly.\n- **telemetry** is observability-first: traces, spans, artifacts, and metric points are framework-owned runtime data.\n- **workflow testing** is a first-class primitive: `TestTrigger` yields one item per test case; `Assertion` nodes record per-run results into `TestAssertion` rows; the canvas exposes a Tests tab.\n- **run retention** and **telemetry retention** can differ — trend data can outlive raw run state.\n\n## Where to go next\n\n| Task | Skill |\n| ------------------------------------- | ------------------------- |\n| Authoring workflows | `workflow-dsl` |\n| Building a reusable node | `custom-node-development` |\n| Building a credential type | `credential-development` |\n| Packaging as a plugin | `plugin-development` |\n| Calling an MCP server from a workflow | `mcp-capabilities` |\n| CLI commands / dev loop | `cli` |\n\n## Code conventions (enforced by ESLint — these will fail your PR)\n\n- **No comments.** The `codemation/no-comments` rule blocks all human-written comments at error level. Name identifiers, narrow types, and write tests instead. Allowed exceptions: `// @ts-ignore`, `// @ts-expect-error`, and `/// <reference ...>` triple-slash directives.\n- **No `console.log`** under `packages/*/src` — inject `LoggerFactory`/`Logger`.\n- **One class per file.** The `codemation/single-class-per-file` rule enforces it. Split rather than add.\n- **No `new PascalCase()`** outside composition-root files (`AppContainerFactory.ts`, `bootstrap/`). Everything goes through DI.\n- **No `static` methods** on non-Factory/Builder/Registry classes.\n\n## Read next when needed\n\n- Read `references/architecture-map.md` for package ownership and runtime-mode guidance.\n",
168
168
  "format": "doc",
169
169
  "verified": false,
170
- "l2": "- Codemation Framework Concepts\n - Core concepts\n - Where to go next\n - Read next when needed"
170
+ "l2": "- Codemation Framework Concepts\n - Core concepts\n - Where to go next\n - Code conventions (enforced by ESLint — these will fail your PR)\n - Read next when needed"
171
171
  },
172
172
  {
173
173
  "name": "gmail",
@@ -183,9 +183,9 @@
183
183
  ],
184
184
  "sourcePath": "skills/builder/gmail/SKILL.md",
185
185
  "dependencies": {
186
- "@codemation/core-nodes-gmail": "0.5.0",
187
- "@codemation/core-nodes": "0.12.0",
188
- "@codemation/core": "0.14.0"
186
+ "@codemation/core-nodes-gmail": "0.6.0",
187
+ "@codemation/core-nodes": "0.14.0",
188
+ "@codemation/core": "0.15.0"
189
189
  },
190
190
  "code": "---\nname: gmail\ndescription: Builds a Codemation workflow that reacts to Gmail and acts on it — trigger on a label, reply, modify labels, and hand attachment bytes to the document scanner. Use whenever a workflow reads from or writes to a Gmail mailbox.\ncompatibility: Requires @codemation/core-nodes-gmail. A Gmail OAuth credential binds on slot \"auth\".\ntags: gmail, email, label, reply, attachment, trigger, integration\nuses: \"@codemation/core-nodes-gmail, @codemation/core-nodes, @codemation/core\"\n---\n\n# Codemation Gmail\n\nFour building blocks, imported from `@codemation/core-nodes-gmail`:\n\n- **`OnNewGmailTrigger`** — fires one item per new message (optionally on a label).\n- **`ReplyToGmailMessage`** — reply to a message by id.\n- **`ModifyGmailLabels`** — add/remove labels on a message or thread.\n- **`SendGmailMessage`** — send a fresh message (not a reply).\n\nEvery node binds a Gmail OAuth credential on slot **`\"auth\"`** (accepted type `oauth.google.gmail`). You\ndeclare the slot by adding the node; the concierge binds the credential (separate skill).\n\n**Discipline:** author straight from this file, then run `verify_workflow` and fix only what it flags.\nUse `workflow-dsl` for the surrounding builder, trigger, and flow-control surface.\n\n## The default flow: trigger on a label → reply → mark processed\n\nThe trigger's `labelIds` filters to messages carrying a Gmail label. `messageId` from each item targets\nthe reply and the label change. Both action nodes take **config expressions** resolved per item — read\nthe trigger payload with `item.json` cast to `OnNewGmailTriggerItemJson` (the class nodes' expression\nslots are item-untyped, so cast inside the `itemExpr`).\n\n```typescript\nimport { createWorkflowBuilder } from \"@codemation/core-nodes\";\nimport { itemExpr } from \"@codemation/core\";\nimport { OnNewGmailTrigger, ReplyToGmailMessage, ModifyGmailLabels } from \"@codemation/core-nodes-gmail\";\nimport type { OnNewGmailTriggerItemJson } from \"@codemation/core-nodes-gmail\";\n\nexport default createWorkflowBuilder({ id: \"wf.gmail.acknowledge\", name: \"Acknowledge inbound mail\" })\n .trigger(\n // One item per new message carrying the \"to-process\" label. Slot \"auth\" must be bound.\n new OnNewGmailTrigger(\"New email\", { mailbox: \"me\", labelIds: [\"to-process\"] }),\n )\n .then(\n new ReplyToGmailMessage(\"Acknowledge\", {\n messageId: itemExpr(({ item }) => (item.json as OnNewGmailTriggerItemJson).messageId),\n text: itemExpr(\n ({ item }) => `Thanks — we received \"${(item.json as OnNewGmailTriggerItemJson).subject ?? \"your email\"}\".`,\n ),\n }),\n )\n .then(\n new ModifyGmailLabels(\n \"Mark processed\",\n {\n target: \"message\", // or \"thread\" to label the whole conversation\n messageId: itemExpr(({ item }) => (item.json as OnNewGmailTriggerItemJson).messageId),\n addLabels: [\"Processed\"], // display name — resolved to the real label ID at runtime\n removeLabelIds: [\"UNREAD\"], // system label IDs (UNREAD, INBOX, SENT, …) use addLabelIds/removeLabelIds\n },\n \"mark-processed\",\n ),\n )\n .build();\n```\n\n## Trigger item shape\n\n`OnNewGmailTrigger` emits `OnNewGmailTriggerItemJson` — import the type, do not redefine it:\n\n```text\nOnNewGmailTriggerItemJson = {\n mailbox: string;\n historyId: string;\n messageId: string; // target for ReplyToGmailMessage / ModifyGmailLabels (target: \"message\")\n threadId?: string;\n subject?: string;\n from?: string;\n to?: string;\n deliveredTo?: string;\n snippet?: string;\n internalDate?: string;\n labelIds: readonly string[];\n headers: Record<string, string>;\n textPlain?: string; // inline plain-text body — prefer this for an LLM step\n textHtml?: string; // inline HTML body\n attachments: readonly GmailMessageAttachmentRecord[]; // metadata only — see below\n}\n```\n\nTrigger options: `{ mailbox: string; labelIds?: string[]; query?: string; downloadAttachments?: boolean }`.\nSet `query` for a raw Gmail search; set `downloadAttachments: true` to fetch attachment bytes.\n\n## Attachments → binary → document scanner (the important one)\n\n`item.json.attachments` is **metadata only** — bytes never ride on `item.json` (binary always goes\nthrough `ctx.binary`). Each record:\n\n```text\nGmailMessageAttachmentRecord = {\n attachmentId: string;\n filename?: string;\n mimeType: string; // e.g. \"application/pdf\"\n size?: number;\n binaryName: string; // ← the KEY the bytes live under in ctx.binary\n}\n```\n\nConstruct the trigger with `downloadAttachments: true` so the bytes are fetched. Then feed the **record's\n`binaryName`** to the scanner's `binaryField` — never hardcode `\"data\"`. The scanner node is a\n`defineNode` (`.create(config, label, id)`), and its config is item-typed, so use a typed `itemExpr` to\npick the first PDF's `binaryName`:\n\n```typescript\nimport { createWorkflowBuilder, codemationDocumentScannerNode } from \"@codemation/core-nodes\";\nimport { itemExpr } from \"@codemation/core\";\nimport { OnNewGmailTrigger } from \"@codemation/core-nodes-gmail\";\nimport type { OnNewGmailTriggerItemJson } from \"@codemation/core-nodes-gmail\";\n\nexport default createWorkflowBuilder({ id: \"wf.gmail.scan-invoice\", name: \"Scan emailed invoice\" })\n .trigger(\n new OnNewGmailTrigger(\"New invoice email\", {\n mailbox: \"me\",\n labelIds: [\"invoices\"],\n downloadAttachments: true, // required — fetches the bytes into ctx.binary\n }),\n )\n .then(\n codemationDocumentScannerNode.create<OnNewGmailTriggerItemJson>(\n {\n // Pick the first PDF attachment's binary key; fall back to \"data\" when none.\n binaryField: itemExpr<string, OnNewGmailTriggerItemJson>(\n ({ item }) => item.json.attachments.find((a) => a.mimeType === \"application/pdf\")?.binaryName ?? \"data\",\n ),\n analyzerType: \"invoice\",\n },\n \"Scan invoice\",\n \"scan-invoice\",\n ),\n )\n .build();\n```\n\nWhen there is no attachment, `attachments` is empty — branch on that (an `If` on\n`item.json.attachments.length`) rather than assuming a PDF. The scanner replaces `item.json` with\n`{ markdown, fields }`; see `document-ai`.\n\n## Gotchas\n\n- **Bind slot `\"auth\"`.** Every Gmail node requires a `oauth.google.gmail` credential on `\"auth\"`. The\n trigger and each action node each declare their own slot — bind them all before activation.\n- **Use display names; don't fabricate IDs.** `addLabels`/`removeLabels` (on `ModifyGmailLabels`)\n and `labelIds` (on `OnNewGmailTrigger`) accept display name strings — the plugin resolves them to\n real Gmail label IDs at runtime via `GmailConfiguredLabelService`. **Prefer these over raw IDs**:\n display names work across accounts; a hard-coded `\"Label_123\"` only works on one specific account.\n Use `addLabelIds`/`removeLabelIds` only for system labels that have stable IDs: `\"UNREAD\"`,\n `\"INBOX\"`, `\"SENT\"`, `\"IMPORTANT\"`, etc.\n Never invent a Gmail label ID (`\"Label_orders\"`, `\"Label_processed\"`) — those will not exist in a\n real account and will throw at runtime. If you don't know the real label name, use a named\n `// TODO(setup): <description>` placeholder and call `record_decision` to surface the gap\n (see `connect-external-systems` → \"Build what works; report what's missing\").\n- **Cast inside the `itemExpr` for class nodes.** `ReplyToGmailMessage` / `ModifyGmailLabels` expression\n slots are item-untyped, so cast inside: `itemExpr(({ item }) => (item.json as OnNewGmailTriggerItemJson).messageId)`.\n- **`ItemExpr<T>` is invariant — return type must match `T` exactly.** Prefer bare `itemExpr(...)` (no explicit generics) and let TypeScript infer. When the field is typed `ItemExpr<string>` but your access is `string | undefined`, narrow it: add `?? \"\"` so the return is `string`. Do NOT annotate `itemExpr<string, SomeType>` and then return `string | undefined` — it looks like it should work but `ItemExpr<T>` is invariant in `T` and TypeScript will reject it. Example: `messageId: itemExpr(({ item }) => (item.json as OnNewGmailTriggerItemJson).messageId ?? \"\")`. For the scanner's `binaryField` (also `ItemExpr<string>`), the `?.binaryName ?? \"data\"` fallback already returns `string` — the explicit `itemExpr<string, T>` annotation there is fine because the return is concrete.\n- **Attachment bytes need `downloadAttachments: true`.** Without it `attachments` carries metadata but\n `ctx.binary[binaryName]` is empty and the scanner throws.\n- **Remove `UNREAD` (or add a processed label) to avoid retriggering.** The trigger re-emits matching\n unprocessed mail each poll.\n\n## Testing a Gmail workflow on real mail (not fabricated fixtures)\n\nUse `GmailLabelTestSource` (a `TestTriggerNodeConfig`) to run the persistent Tests-tab suite on\nreal emails from a **designated test label** in the same mailbox. Each message becomes one test\ncase and yields items in the **identical `OnNewGmailTriggerItemJson` shape** the live trigger emits\n— so OCR, extraction, and matching all run downstream in tests too.\n\n**The designated test label is communicated to the builder by the concierge as part of the build\ntask.** If the label name is absent from the task, call `report_flag({ kind: \"gap\" })` and note\nthat the test label must be provided. Prefer declaring the label name as a named constant or config\nthe owner fills (e.g. `const TEST_LABEL = \"TODO(setup): paste the test label name here\"`) rather\nthan leaving an unexplained placeholder string buried inside the node config. NEVER fabricate a\nlabel name or fall back to hard-coded fixture data.\n\nThe topology that matters:\n\n```\ntrigger → [shared processing: OCR / extraction / matching] → IsTestRun → {\n true (test): Assertion — checks the EXTRACTION OUTCOME, not the raw email\n false (live): ALL side-effects — reply + label change + ERP write, all gated together\n}\n```\n\n`IsTestRun` must go **after** the shared processing and **just before** the side-effects. If it\nforks before the extraction, the test path asserts raw trigger bytes and proves nothing.\n\n```typescript\nimport { z } from \"zod\";\nimport {\n createWorkflowBuilder,\n AIAgent,\n IsTestRun,\n Assertion,\n CodemationChatModelConfig,\n} from \"@codemation/core-nodes\";\nimport { itemExpr } from \"@codemation/core\";\nimport {\n OnNewGmailTrigger,\n GmailLabelTestSource,\n ModifyGmailLabels,\n ReplyToGmailMessage,\n} from \"@codemation/core-nodes-gmail\";\nimport type { OnNewGmailTriggerItemJson } from \"@codemation/core-nodes-gmail\";\n\n// ── output schema for the extraction step ─────────────────────────────────\n// messageId is passed through so the side-effect nodes can access it after extraction.\nconst ExtractionSchema = z.object({\n messageId: z.string(), // pass-through from trigger — needed by reply/label nodes\n customer: z.string(), // recognised company/customer name; empty string when unrecognised\n lineItems: z.array(z.object({ description: z.string(), amount: z.number() })),\n});\ntype ExtractionOutput = z.infer<typeof ExtractionSchema>;\n\n// ── test label ────────────────────────────────────────────────────────────\n// Declare the label as a named constant the owner fills.\n// If the concierge did not provide it → call report_flag({ kind: \"gap\" }) instead.\nconst TEST_LABEL = \"TODO(setup): paste the test label name here\";\n\n// SEPARATE top-level export — the Tests tab discovers it. NOT a second .trigger().\nexport const testSource = new GmailLabelTestSource(\n \"Gmail test cases\",\n { mailbox: \"me\", labelIds: [TEST_LABEL], maxResults: 20 },\n \"gmail-test-source\",\n);\n\nexport default createWorkflowBuilder({ id: \"wf.gmail.procurement\", name: \"Procurement intake\" })\n .trigger(new OnNewGmailTrigger(\"New email\", { mailbox: \"me\", labelIds: [\"procurement/inbox\"] }))\n // ── shared processing (runs in BOTH test and live paths) ────────────────\n // Extract structured fields from the email body. Include messageId so\n // downstream side-effect nodes (reply, label, ERP) can address the message.\n .then(\n new AIAgent<OnNewGmailTriggerItemJson, ExtractionOutput>({\n name: \"Extract procurement data\",\n id: \"extract-procurement-data\",\n messages: [\n {\n role: \"system\",\n content:\n \"Extract the supplier/customer name and line items from the email. \" +\n \"Include the messageId field verbatim from the input. \" +\n 'Reply with strict JSON matching {\"messageId\",\"customer\",\"lineItems\":[{\"description\",\"amount\"}]}.',\n },\n {\n role: \"user\",\n content: ({ item }) =>\n `messageId: ${item.json.messageId}\\n\\n${item.json.textPlain ?? item.json.snippet ?? \"\"}`,\n },\n ],\n chatModel: new CodemationChatModelConfig(\"Managed AI\", \"low\"),\n outputSchema: ExtractionSchema,\n guardrails: { maxTurns: 1 },\n }),\n )\n // ── IsTestRun: AFTER all shared processing, JUST BEFORE side-effects ───\n .then(new IsTestRun<ExtractionOutput>(\"Is this a test run?\", \"is-test-run\"))\n .when({\n // true = test path: assert the EXTRACTION OUTCOME, not the raw email fields\n true: [\n new Assertion<ExtractionOutput>({\n name: \"Extraction outcome\",\n id: \"assert-extraction-outcome\",\n assertions: (item) => [\n {\n // Did the model recognise a company/customer?\n name: \"customer recognised\",\n score: item.json.customer.trim().length > 0 ? 1 : 0,\n actual: item.json.customer,\n },\n {\n // Did the model extract at least one line item?\n name: \"line items extracted\",\n score: item.json.lineItems.length > 0 ? 1 : 0,\n actual: item.json.lineItems.length,\n },\n ],\n }),\n ],\n // false = live path: EVERY side-effect is gated here together\n // (ERP write / create-order call belongs here too — never outside this branch)\n false: [\n new ReplyToGmailMessage(\"Acknowledge receipt\", {\n messageId: itemExpr(({ item }) => (item.json as ExtractionOutput).messageId ?? \"\"),\n text: itemExpr(\n ({ item }) =>\n `Thank you — we received your procurement request and are processing it.` +\n ((item.json as ExtractionOutput).customer ? ` Customer: ${(item.json as ExtractionOutput).customer}.` : \"\"),\n ),\n }),\n new ModifyGmailLabels(\n \"Mark processed\",\n {\n target: \"message\",\n messageId: itemExpr(({ item }) => (item.json as ExtractionOutput).messageId ?? \"\"),\n addLabels: [\"Processed\"],\n removeLabelIds: [\"UNREAD\"],\n },\n \"mark-processed\",\n ),\n // → Add the ERP write node here (e.g. odooCreateSaleOrderNode) before marking processed.\n ],\n })\n .build();\n```\n\n**Key rules:**\n\n- `GmailLabelTestSource` is a **separate `export const`**, never a second `.trigger(...)` on the chain.\n- **Place `IsTestRun` after all shared processing and just before the side-effects.** Processing steps (OCR, extraction, matching) must be upstream of `IsTestRun` so both the test path and the live path exercise the same logic. An `IsTestRun` placed right after the trigger asserts nothing useful.\n- **Assert the processing outcome, not the raw email.** The `Assertion` on the `true` branch must check the extractor's structured output (recognised customer, extracted line items) — not `subject`, `from`, or other raw trigger fields. Asserting `subject.length > 0` proves only that an email arrived; asserting `lineItems.length > 0` proves the extraction worked.\n- **Gate EVERY side-effect on the `false` branch.** Reply, label change, and ERP writes all belong on the `false` branch — together. A single gated label change while the reply or ERP write runs unconditionally defeats the purpose. If you add a node that writes to an external system, it must live on the `false` branch.\n- **Absent test label → `report_flag` + declare a required input, never a silent placeholder.** If the concierge did not provide the test label, call `report_flag({ kind: \"gap\" })`. When a placeholder is unavoidable, declare it as a named constant at the top of the file (as `TEST_LABEL` above) so the owner knows exactly what to fill in — never bury an opaque string inside a node config.\n- `GmailLabelTestSource` yields items in the same raw `OnNewGmailTriggerItemJson` shape as the live `OnNewGmailTrigger` — no pre-processing, no canonicalization. That's what makes the test meaningful.\n- The `caseLabel` on `GmailLabelTestSource` defaults to the email subject so each test-case row in the Tests tab is human-readable.\n\n## Read next when needed\n\n- `workflow-dsl` — builder, triggers, flow control, the per-item contract.\n- `document-ai` — the attachment → `{ markdown, fields }` handoff in full.\n- `ai-agent` — summarize or triage `item.json.textPlain` with an LLM before replying.\n- `testing` — the full `IsTestRun` + `Assertion` + `TestTrigger` pattern (trigger-agnostic).\n",
191
191
  "format": "doc",
@@ -204,8 +204,8 @@
204
204
  ],
205
205
  "sourcePath": "skills/builder/human-in-the-loop/SKILL.md",
206
206
  "dependencies": {
207
- "@codemation/core-nodes": "0.12.0",
208
- "@codemation/core": "0.14.0"
207
+ "@codemation/core-nodes": "0.14.0",
208
+ "@codemation/core": "0.15.0"
209
209
  },
210
210
  "code": "---\nname: human-in-the-loop\ndescription: Pause a workflow for a person to approve, reject, or note before it continues, using the inboxApproval node and the builder's .humanApproval() step. The gate sits ON the live path and the run resumes on the human decision. Read this to gate any step that is expensive or irreversible to undo.\ncompatibility: Designed for Codemation workflows authored with @codemation/core-nodes.\ntags: hitl, approval, inbox, review, gate\nuses: \"@codemation/core-nodes, @codemation/core\"\n---\n\n# Codemation Human-in-the-loop\n\nGate a step that's expensive to undo — a payment, an external write, a customer reply — by suspending the run and waiting for a person to decide (approve / reject). Use `inboxApproval` with the builder's `.humanApproval(...)` step so the gate sits **on the live path**: a real node the items flow through, not a dangling node next to a `NoOp`. The run resumes when the reviewer decides; **rejected items never reach the downstream nodes** (the engine discards them at the gate). The same node auto-routes to the managed control-plane inbox in managed mode and the local `/dev/inbox` otherwise — no per-deployment wiring.\n\n## The gate — one step on the chain\n\n`inboxApproval` is a `defineHumanApprovalNode` node imported from `@codemation/core-nodes`. Place it with `.humanApproval(inboxApproval, config)`. The `title`/`body` fields are a static string or a callback `({ item }) => string`; the callback receives an **untyped `Item`**, so cast `item.json` to your type inside it. After the gate, the item json is `Input & { decision }` — downstream reads `item.json.decision.status`.\n\n```typescript\nimport { inboxApproval } from \"@codemation/core-nodes\";\n\ntype Invoice = { vendor: string; amount: number; currency: string };\n\nconst approvalConfig = {\n title: ({ item }: { item: { json: unknown } }) => `Approve invoice from ${(item.json as Invoice).vendor}`,\n body: ({ item }: { item: { json: unknown } }) => {\n const inv = item.json as Invoice;\n return `Amount: ${inv.amount} ${inv.currency}`;\n },\n priority: \"normal\" as const, // \"low\" | \"normal\" | \"high\"\n timeout: \"24h\",\n onTimeout: \"halt\" as const, // \"halt\" stops the run on timeout; \"auto-accept\" continues\n};\n```\n\nThe decision shape merged into the item: `decision.status` is `\"approved\" | \"rejected\" | \"timed-out\" | \"auto-accepted\"`, plus optional `decision.actor`, `decision.decidedAt`, and `decision.note`.\n\n## One realistic complete example\n\nWebhook receives an invoice → `.humanApproval` suspends for a reviewer → on approval the run continues and posts to accounting. The gate is the live path; rejected items stop here. This folds in the former `hitl-cp-inbox-approval` example.\n\n```typescript\nimport { createWorkflowBuilder, WebhookTrigger, inboxApproval, HttpRequest } from \"@codemation/core-nodes\";\nimport type { Item } from \"@codemation/core\";\n\ntype Invoice = { messageId: string; vendor: string; amount: number; currency: string };\n\nexport default createWorkflowBuilder({ id: \"wf.invoice-approval\", name: \"Invoice approval (HITL)\" })\n .trigger(new WebhookTrigger(\"Receive invoice\", { endpointKey: \"invoices\", methods: [\"POST\"] }))\n // Suspend for a human. inboxApproval auto-routes to the CP inbox (managed) or /dev/inbox (local).\n // title/body callbacks read the item at runtime — cast item.json to your type.\n .humanApproval(inboxApproval, {\n title: ({ item }: { item: Item }) => `Approve invoice from ${(item.json as Invoice).vendor}`,\n body: ({ item }: { item: Item }) => {\n const inv = item.json as Invoice;\n return `Amount: ${inv.amount} ${inv.currency}\\nMessage ID: ${inv.messageId}`;\n },\n priority: \"normal\",\n timeout: \"24h\",\n onTimeout: \"halt\",\n })\n // Reached only on approval — rejected items were discarded at the gate.\n // After .humanApproval the json is Invoice & { decision }, so item.json.decision.status is typed.\n .then(\n new HttpRequest<Invoice & { decision: { status: string } }>(\"Post to accounting\", {\n method: \"POST\",\n url: \"https://accounting.example.com/api/invoices\",\n body: { kind: \"json\", data: { vendor: \"${item.json.vendor}\", amount: \"${item.json.amount}\" } },\n id: \"post-to-accounting\",\n }),\n )\n .build();\n```\n\n## Gate inside an agent (one line)\n\nTo let an `AIAgent` request approval as a tool instead of as a fixed step, bind the same node config with `AgentToolFactory.asTool(inboxApproval.create(config, \"Inbox approval\"), { name, onRejected, inputSchema, outputSchema, mapInput, mapOutput })`. `onRejected: \"halt\"` stops the run on rejection; `onRejected: \"return\"` feeds the rejection back to the agent so it can adapt. Use the fixed `.humanApproval` step for a single mandatory gate; use the tool form when the agent decides whether to escalate.\n\n## Gotchas\n\n- **Gate the irreversible step.** Put `.humanApproval` immediately before the expensive write, not after it.\n- **On the live path, not beside it.** `.humanApproval` returns the cursor for the next step — chain the downstream node off it. Don't approve next to a `NoOp` and continue regardless.\n- **Cast `item.json` in title/body callbacks.** They receive an untyped `Item`; `item.json as YourType` keeps the gate compiling.\n- **Rejected items stop at the gate.** Downstream nodes only see approved (and, with `onTimeout: \"auto-accept\"`, auto-accepted) items — read `item.json.decision.status` if you need to branch on the outcome.\n",
211
211
  "format": "doc",
@@ -222,7 +222,7 @@
222
222
  ],
223
223
  "sourcePath": "skills/builder/mcp-capabilities/SKILL.md",
224
224
  "dependencies": {},
225
- "code": "---\nname: mcp-capabilities\ndescription: Discover MCP servers registered on the Codemation control plane. Use before authoring agent workflows that reference mcpServers to find available server ids and their credential requirements.\ncompatibility: Requires an installation paired with a connected control plane.\ntags: mcp, agent, tool\n---\n\n# Codemation MCP Capabilities\n\nMCP servers extend `AIAgent` with tool access to external services (Gmail, Sheets, etc.). Server ids and credential requirements come from the control-plane registry — they are not hard-coded in framework code. The agent's `mcpServers` array contains stable server id slugs; each declared server surfaces a credential slot the operator must bind in the canvas before activation. Use this skill before writing `agent({ mcpServers: [\"...\"] })` to discover available server ids and their credential types.\n\n## Managed mode: CP-loaded MCP servers (default path)\n\nIn **managed mode**, MCP servers are loaded from the **control plane (CP)** — not declared in plugin code. Discover available servers by querying the CP registry:\n\n```\nGET /api/registry/capabilities?query=gmail\n```\n\nResponse contains objects with `{ kind, id, displayName, description, acceptedCredentialTypes }`. Use `id` in the workflow's `mcpServers` array. An empty `query` string returns all registered servers.\n\nFor a full wired example — cron workflow + AIAgent + mcpServers — use your harness's example-discovery tool: `find_examples({ query: \"AIAgent gmail mcpServers\" })` or `find_examples({ query: \"mcp server\" })`.\n\n## Non-managed: plugin-declared MCP servers\n\nIn self-hosted / non-managed deployments, MCP servers can also be declared via `mcpServers: [...]` in a `definePlugin(...)` call. This is a framework-author pattern — do not use it in managed-mode workflows. See `references/plugin-anatomy.md` in the `plugin-development` skill for the plugin declaration syntax.\n\n## Decision branches & gotchas\n\n**Credential types:** `\"oauth.google.gmail\"` requires the user to connect a Google account via the credential dialog before the workflow runs. The same instance can be shared between a `GmailTrigger` and the Gmail MCP server. An empty `acceptedCredentialTypes` array means no credential is needed.\n\n**Multiple instances:** a user may have multiple instances of the same credential type (personal vs work Gmail). The canvas credential dropdown surfaces all matching instances — the operator picks the one to bind.\n\n**Bind via UI only:** there is no inline credential field on the workflow definition. The operator binds the credential instance via the canvas credential dropdown before activation.\n\n**Typical flow (managed):**\n\n1. `GET /api/registry/capabilities?query=<term>` → find `id` and `acceptedCredentialTypes`.\n2. Add `id` to `mcpServers` in the `AIAgent` config.\n3. Report: \"The user will need to bind a `<type>` credential instance via the canvas before activating.\"\n\n## Anti-patterns\n\n- Do not guess server ids — always query the registry first.\n- Do not add `acceptedCredentialTypes` to the workflow definition — credential binding is UI-driven, not code-driven.\n- Do not declare MCP servers inside plugin code for managed-mode workflows — use the CP registry instead.\n",
225
+ "code": "---\nname: mcp-capabilities\ndescription: Discover MCP servers registered on the Codemation control plane. Use before authoring agent workflows that reference mcpServers to find available server ids and their credential requirements.\ncompatibility: Requires an installation paired with a connected control plane.\ntags: mcp, agent, tool\n---\n\n# Codemation MCP Capabilities\n\nMCP servers extend `AIAgent` with tool access to external services (Gmail, Sheets, etc.). Server ids and credential requirements come from the control-plane registry — they are not hard-coded in framework code. The agent's `mcpServers` array contains stable server id slugs; each declared server surfaces a credential slot the operator must bind in the canvas before activation. Use this skill before writing `agent({ mcpServers: [\"...\"] })` to discover available server ids and their credential types.\n\n## Managed mode: CP-loaded MCP servers (default path)\n\nIn **managed mode**, MCP servers are loaded from the **control plane (CP)** — not declared in plugin code. Discover available servers by querying the CP registry:\n\n```\nGET /api/registry/capabilities?query=gmail\n```\n\nResponse contains objects with `{ kind, id, displayName, description, acceptedCredentialTypes }`. Use `id` in the workflow's `mcpServers` array. An empty `query` string returns all registered servers.\n\nFor a full wired example — scheduled workflow + AIAgent + mcpServers — use your harness's example-discovery tool: `find_examples({ query: \"AIAgent gmail mcpServers\" })` or `find_examples({ query: \"mcp server\" })`.\n\n## Non-managed: plugin-declared MCP servers\n\nIn self-hosted / non-managed deployments, MCP servers can also be declared via `mcpServers: [...]` in a `definePlugin(...)` call. This is a framework-author pattern — do not use it in managed-mode workflows. See `references/plugin-anatomy.md` in the `plugin-development` skill for the plugin declaration syntax.\n\n## Decision branches & gotchas\n\n**Credential types:** `\"oauth.google.gmail\"` requires the user to connect a Google account via the credential dialog before the workflow runs. The same instance can be shared between a `GmailTrigger` and the Gmail MCP server. An empty `acceptedCredentialTypes` array means no credential is needed.\n\n**Multiple instances:** a user may have multiple instances of the same credential type (personal vs work Gmail). The canvas credential dropdown surfaces all matching instances — the operator picks the one to bind.\n\n**Bind via UI only:** there is no inline credential field on the workflow definition. The operator binds the credential instance via the canvas credential dropdown before activation.\n\n**Typical flow (managed):**\n\n1. `GET /api/registry/capabilities?query=<term>` → find `id` and `acceptedCredentialTypes`.\n2. Add `id` to `mcpServers` in the `AIAgent` config.\n3. Report: \"The user will need to bind a `<type>` credential instance via the canvas before activating.\"\n\n## Anti-patterns\n\n- Do not guess server ids — always query the registry first.\n- Do not add `acceptedCredentialTypes` to the workflow definition — credential binding is UI-driven, not code-driven.\n- Do not declare MCP servers inside plugin code for managed-mode workflows — use the CP registry instead.\n",
226
226
  "format": "doc",
227
227
  "verified": false,
228
228
  "l2": "- Codemation MCP Capabilities\n - Managed mode: CP-loaded MCP servers (default path)\n - Non-managed: plugin-declared MCP servers\n - Decision branches & gotchas\n - Anti-patterns"
@@ -241,9 +241,9 @@
241
241
  ],
242
242
  "sourcePath": "skills/builder/msgraph/SKILL.md",
243
243
  "dependencies": {
244
- "@codemation/core-nodes-msgraph": "0.4.1",
245
- "@codemation/core-nodes": "0.12.0",
246
- "@codemation/core": "0.14.0"
244
+ "@codemation/core-nodes-msgraph": "0.4.3",
245
+ "@codemation/core-nodes": "0.14.0",
246
+ "@codemation/core": "0.15.0"
247
247
  },
248
248
  "code": "---\nname: msgraph\ndescription: Builds a Codemation workflow against Microsoft 365 via Microsoft Graph — trigger on new Outlook mail, reply, patch (read-state/categories/move), and reach OneDrive/Excel. Use whenever a workflow reads from or writes to a Microsoft 365 mailbox, OneDrive, or Excel workbook.\ncompatibility: Requires @codemation/core-nodes-msgraph. A Microsoft Graph OAuth credential binds on slot \"auth\".\ntags: msgraph, microsoft, outlook, email, onedrive, excel, integration\nuses: \"@codemation/core-nodes-msgraph, @codemation/core-nodes, @codemation/core\"\n---\n\n# Codemation Microsoft Graph\n\n`@codemation/core-nodes-msgraph` covers three Microsoft 365 surfaces, each binding a Graph OAuth\ncredential on slot **`\"auth\"`**:\n\n- **Outlook mail** — trigger on new mail, plus reply / send / patch / get / attachment-download / folder-resolve.\n- **OneDrive** — resolve, list, get, download, upload, copy, list-drives.\n- **Excel** — open/close workbook, list/add sheets, read/write/style ranges.\n\nMail and Excel-on-mailbox bind the **`msgraph-mail-oauth`** credential type; OneDrive/Excel-on-drive\nbind **`msgraph-drive-oauth`**. The two differ only in granted scopes — pick by surface.\n\nThese nodes are `defineNode` / `definePollingTrigger` definitions: instantiate with\n**`node.create(config, label?, id?)`**, not `new`. Config fields accept per-item expressions via\n`itemExpr` (type the expression with the item shape, e.g. `itemExpr<string, MsGraphMailItem>`).\n\n**Discipline:** author straight from this file, then run `verify_workflow` and fix only what it flags.\nUse `workflow-dsl` for the surrounding builder, trigger, and flow-control surface.\n\n## The default flow: trigger on new mail → reply\n\n`onNewMsGraphMailTrigger` polls a folder and emits one `MsGraphMailItem` per new message.\n`outlookMessageReplyNode` replies by `messageId`. Type the `.create<MsGraphMailItem>` call so the\n`itemExpr` callbacks see the trigger payload.\n\n```typescript\nimport { createWorkflowBuilder } from \"@codemation/core-nodes\";\nimport { itemExpr } from \"@codemation/core\";\nimport { onNewMsGraphMailTrigger, outlookMessageReplyNode, type MsGraphMailItem } from \"@codemation/core-nodes-msgraph\";\n\nexport default createWorkflowBuilder({ id: \"wf.msgraph.acknowledge\", name: \"Acknowledge inbound mail\" })\n .trigger(\n // Polls the mailbox folder; default filter is \"isRead eq false\". Slot \"auth\" must be bound.\n onNewMsGraphMailTrigger.create(\n { mailbox: \"me\", folderId: \"inbox\", filter: \"isRead eq false\" },\n \"On new mail\",\n \"mail-trigger\",\n ),\n )\n .then(\n outlookMessageReplyNode.create<MsGraphMailItem>(\n {\n mailbox: \"me\",\n messageId: itemExpr<string, MsGraphMailItem>(({ item }) => item.json.messageId),\n body: itemExpr<string, MsGraphMailItem>(\n ({ item }) => `Thanks — we received \"${item.json.subject ?? \"your email\"}\" and are processing it.`,\n ),\n bodyType: \"text\", // or \"html\"\n // replyAll: true / forward: true (forward requires `to`) / draftOnly: true to save without sending\n },\n \"Reply\",\n \"reply\",\n ),\n )\n .build();\n```\n\n## Mail item shape\n\n`onNewMsGraphMailTrigger` emits `MsGraphMailItem` — import the type, do not redefine it:\n\n```text\nMsGraphMailItem = {\n messageId: string; // target for reply / patch / get\n conversationId?: string;\n receivedDateTime: string;\n internetMessageId?: string;\n replyToMessageId?: string;\n from?: { name?: string; address?: string };\n to: { name?: string; address?: string }[];\n cc?, bcc?: same shape;\n subject?: string;\n bodyText?: string; // prefer this for an LLM step\n bodyHtml?: string;\n attachments?: MsGraphMailAttachment[]; // metadata only — see below\n headers?: Record<string, string>;\n skippedAttachments?: { name; size; reason: \"size-cap\" }[];\n}\n```\n\nTrigger options: `{ mailbox; folderId?=\"inbox\"; filter?; downloadAttachments?; attachmentSizeCapBytes?; pollIntervalMs? }`.\n`mailbox: \"me\"` watches the credential owner's inbox; a UPN watches a shared mailbox (needs\n`Mail.Read.Shared`). `filter` is a raw Graph OData `$filter` (e.g. `\"hasAttachments eq true\"`).\n\n## Patch a message (mark read, categorize, move)\n\n`outlookMessagePatchNode` is the common \"tidy up after handling\" step. Chain it directly off the\ntrigger.\n\n```typescript\nimport { createWorkflowBuilder } from \"@codemation/core-nodes\";\nimport { itemExpr } from \"@codemation/core\";\nimport { onNewMsGraphMailTrigger, outlookMessagePatchNode, type MsGraphMailItem } from \"@codemation/core-nodes-msgraph\";\n\nexport default createWorkflowBuilder({ id: \"wf.msgraph.triage\", name: \"Triage inbound mail\" })\n .trigger(onNewMsGraphMailTrigger.create({ mailbox: \"me\" }, \"On new mail\", \"trigger\"))\n .then(\n outlookMessagePatchNode.create<MsGraphMailItem>(\n {\n mailbox: \"me\",\n messageId: itemExpr<string, MsGraphMailItem>(({ item }) => item.json.messageId),\n isRead: true,\n categories: [\"Processed\"],\n move: { folderId: \"Archive\" }, // a well-known name or a folder id from outlookFolderResolveNode\n },\n \"Mark processed\",\n \"mark-processed\",\n ),\n )\n .build();\n```\n\n## Attachments → binary\n\nSet the trigger's `downloadAttachments: true` and the trigger's `execute` step stores each attachment's\nbytes via `ctx.binary` under a slot named after the attachment (`name`, or `inline:{contentId}` for\nembedded images). `item.json.attachments` carries metadata only\n(`{ id, name, contentType, size, isInline?, contentId? }`) — never the payload. To pull one attachment\non demand later, use `outlookAttachmentDownloadNode` (`{ mailbox?, messageId, attachmentId, binarySlot?=\"attachment\" }`).\nRead the stored bytes off `item.binary[slot]`; feed them to `document-ai` for OCR.\n\n## Node catalog\n\nInstantiate each with `node.create(config, label?, id?)`. Bind `\"auth\"` to the matching credential type.\n\n```text\nOutlook mail (msgraph-mail-oauth)\n onNewMsGraphMailTrigger { mailbox; folderId?; filter?; downloadAttachments?; pollIntervalMs? }\n outlookMessageReplyNode { mailbox; messageId; body; bodyType; replyAll?; forward?; to?; cc?; bcc?; draftOnly?; attachments? }\n outlookMessageSendNode { mailbox; to; subject; body; bodyType; cc?; bcc?; attachments?; draftOnly? }\n outlookMessagePatchNode { mailbox; messageId; isRead?; categories?; move?: { folderId } }\n outlookMessageGetNode { mailbox; messageId; expandAttachments? }\n outlookAttachmentDownloadNode{ mailbox?; messageId; attachmentId; binarySlot?; sizeCapBytes? }\n outlookFolderResolveNode { mailbox; folderPath } → { folderId, path, mailbox }\n\nOneDrive (msgraph-drive-oauth)\n driveResolveNode · driveListChildrenNode · driveItemGetNode · driveDownloadNode\n driveUploadNode · driveCopyNode · driveListMyDrivesNode · driveListSharedWithMeNode\n\nExcel (msgraph-drive-oauth; open a workbook first, pass the handle downstream)\n excelOpenWorkbookNode · excelCloseWorkbookNode · excelListWorksheetsNode · excelAddSheetNode\n excelReadRangeNode · excelWriteRangeNode · excelStyleRangeNode\n```\n\nFor a OneDrive/Excel case not covered, resolve `ctx.getCredential<MsGraphSession>(\"auth\")` in a\n`Callback` and call `createGraphClient(session)` (exported from `@codemation/core-nodes-msgraph`) to\nreach the raw Graph SDK.\n\n## Gotchas\n\n- **`.create(...)`, never `new`.** These are `defineNode` definitions — `new outlookMessageReplyNode(...)`\n does not exist.\n- **Type the `itemExpr`.** `.create<MsGraphMailItem>` alone leaves a bare `itemExpr` callback's\n `item.json` as `unknown`. Write `itemExpr<string, MsGraphMailItem>(({ item }) => item.json.messageId)`.\n- **Pick the right credential type on `\"auth\"`.** Mail nodes → `msgraph-mail-oauth`; OneDrive/Excel →\n `msgraph-drive-oauth`. Bind every node's slot before activation.\n- **`filter` is server-side OData.** It is a raw `$filter` string — malformed expressions fail the poll.\n The trigger establishes a baseline on its first cycle (emits nothing) so existing mail doesn't replay.\n- **Attachment bytes need `downloadAttachments: true`** (or a later `outlookAttachmentDownloadNode`);\n the item JSON only ever carries attachment metadata.\n\n## Testing an Outlook workflow on real mail (not fabricated fixtures)\n\nUse `OutlookFolderTestSource` (a `TestTriggerNodeConfig`) to run the persistent Tests-tab suite on\nreal emails from a **designated test folder** in the same mailbox. Each message becomes one test\ncase and yields items in the **identical `MsGraphMailItem` shape** the live trigger emits — so OCR,\nextraction, and matching all run downstream in tests too.\n\n**The designated test folder is communicated to the builder by the concierge as part of the build\ntask.** If the folder id/name is absent from the task, call `report_flag({ kind: \"gap\" })` and note\nthat the test folder must be provided. Prefer declaring the folder id as a named constant the owner\nfills (e.g. `const TEST_FOLDER = \"TODO(setup): paste the test folder id here\"`) rather than leaving\nan unexplained placeholder string buried inside the node config. NEVER fabricate a folder id or fall\nback to hard-coded fixture data.\n\nThe topology that matters:\n\n```text\ntrigger → [shared processing: OCR / extraction / matching] → IsTestRun → {\n true (test): Assertion — checks the EXTRACTION OUTCOME, not the raw email\n false (live): ALL side-effects — reply + patch (mark read/categorize) + ERP write, all gated together\n}\n```\n\n`IsTestRun` must go **after** the shared processing and **just before** the side-effects. If it\nforks before the extraction, the test path asserts raw trigger bytes and proves nothing.\n\n```typescript\nimport { z } from \"zod\";\nimport {\n createWorkflowBuilder,\n AIAgent,\n IsTestRun,\n Assertion,\n CodemationChatModelConfig,\n} from \"@codemation/core-nodes\";\nimport { itemExpr } from \"@codemation/core\";\nimport {\n onNewMsGraphMailTrigger,\n OutlookFolderTestSource,\n outlookMessageReplyNode,\n outlookMessagePatchNode,\n type MsGraphMailItem,\n} from \"@codemation/core-nodes-msgraph\";\n\n// ── output schema for the extraction step ─────────────────────────────────\n// messageId is passed through so the side-effect nodes can address the message after extraction.\nconst ExtractionSchema = z.object({\n messageId: z.string(), // pass-through from trigger — needed by reply/patch nodes\n customer: z.string(), // recognised company/customer name; empty string when unrecognised\n lineItems: z.array(z.object({ description: z.string(), amount: z.number() })),\n});\ntype ExtractionOutput = z.infer<typeof ExtractionSchema>;\n\n// ── test folder ───────────────────────────────────────────────────────────\n// Declare the folder id as a named constant the owner fills.\n// If the concierge did not provide it → call report_flag({ kind: \"gap\" }) instead.\nconst TEST_FOLDER = \"TODO(setup): paste the test folder id here\";\n\n// SEPARATE top-level export — the Tests tab discovers it. NOT a second .trigger().\n// `new`, not `.create(...)`: the test source is a class (like GmailLabelTestSource), not a defineNode.\nexport const testSource = new OutlookFolderTestSource(\n \"Outlook test cases\",\n { mailbox: \"me\", folderId: TEST_FOLDER, maxResults: 20 },\n \"outlook-test-source\",\n);\n\nexport default createWorkflowBuilder({ id: \"wf.msgraph.procurement\", name: \"Procurement intake\" })\n .trigger(onNewMsGraphMailTrigger.create({ mailbox: \"me\", folderId: \"inbox\" }, \"New email\", \"mail-trigger\"))\n // ── shared processing (runs in BOTH test and live paths) ────────────────\n // Extract structured fields from the email body. Include messageId so\n // downstream side-effect nodes (reply, patch, ERP) can address the message.\n .then(\n new AIAgent<MsGraphMailItem, ExtractionOutput>({\n name: \"Extract procurement data\",\n id: \"extract-procurement-data\",\n messages: [\n {\n role: \"system\",\n content:\n \"Extract the supplier/customer name and line items from the email. \" +\n \"Include the messageId field verbatim from the input. \" +\n 'Reply with strict JSON matching {\"messageId\",\"customer\",\"lineItems\":[{\"description\",\"amount\"}]}.',\n },\n {\n role: \"user\",\n content: ({ item }) => `messageId: ${item.json.messageId}\\n\\n${item.json.bodyText ?? \"\"}`,\n },\n ],\n chatModel: new CodemationChatModelConfig(\"Managed AI\", \"low\"),\n outputSchema: ExtractionSchema,\n guardrails: { maxTurns: 1 },\n }),\n )\n // ── IsTestRun: AFTER all shared processing, JUST BEFORE side-effects ───\n .then(new IsTestRun<ExtractionOutput>(\"Is this a test run?\", \"is-test-run\"))\n .when({\n // true = test path: assert the EXTRACTION OUTCOME, not the raw email fields\n true: [\n new Assertion<ExtractionOutput>({\n name: \"Extraction outcome\",\n id: \"assert-extraction-outcome\",\n assertions: (item) => [\n {\n // Did the model recognise a company/customer?\n name: \"customer recognised\",\n score: item.json.customer.trim().length > 0 ? 1 : 0,\n actual: item.json.customer,\n },\n {\n // Did the model extract at least one line item?\n name: \"line items extracted\",\n score: item.json.lineItems.length > 0 ? 1 : 0,\n actual: item.json.lineItems.length,\n },\n ],\n }),\n ],\n // false = live path: EVERY side-effect is gated here together\n // (ERP write / create-order call belongs here too — never outside this branch)\n false: [\n outlookMessageReplyNode.create<ExtractionOutput>(\n {\n mailbox: \"me\",\n messageId: itemExpr<string, ExtractionOutput>(({ item }) => item.json.messageId),\n body: itemExpr<string, ExtractionOutput>(\n ({ item }) =>\n `Thank you — we received your procurement request and are processing it.` +\n (item.json.customer ? ` Customer: ${item.json.customer}.` : \"\"),\n ),\n bodyType: \"text\",\n // draftOnly is NOT a test switch — see the note below. Sending is gated by IsTestRun.\n },\n \"Acknowledge receipt\",\n \"reply\",\n ),\n outlookMessagePatchNode.create<ExtractionOutput>(\n {\n mailbox: \"me\",\n messageId: itemExpr<string, ExtractionOutput>(({ item }) => item.json.messageId),\n isRead: true,\n categories: [\"Processed\"],\n // move: { folderId: \"Archive\" } — a well-known name or an id from outlookFolderResolveNode\n },\n \"Mark processed\",\n \"mark-processed\",\n ),\n // → Add the ERP write node here (e.g. odooCreateSaleOrderNode) before marking processed.\n ],\n })\n .build();\n```\n\n**Key rules:**\n\n- `OutlookFolderTestSource` is a **separate `export const`**, never a second `.trigger(...)` on the chain. It is a class (mirroring `GmailLabelTestSource`) — instantiate with `new`, not `.create(...)`.\n- **Place `IsTestRun` after all shared processing and just before the side-effects.** Processing steps (OCR, extraction, matching) must be upstream of `IsTestRun` so both the test path and the live path exercise the same logic. An `IsTestRun` placed right after the trigger asserts nothing useful.\n- **Assert the processing outcome, not the raw email.** The `Assertion` on the `true` branch must check the extractor's structured output (recognised customer, extracted line items) — not `subject`, `from`, or other raw trigger fields. Asserting `subject.length > 0` proves only that an email arrived; asserting `lineItems.length > 0` proves the extraction worked.\n- **Gate EVERY side-effect on the `false` branch.** Reply, patch (mark read / categorize / move), and ERP writes all belong on the `false` branch — together. A single gated patch while the reply or ERP write runs unconditionally defeats the purpose. If you add a node that writes to an external system, it must live on the `false` branch.\n- **`draftOnly` is NOT the test mechanism.** Gate the reply behind `IsTestRun` (it lives on the `false`/live branch, so a test run never reaches it and no mail is sent). Do not reach for `outlookMessageReplyNode`'s `draftOnly: true` to make a run \"safe\": `draftOnly` saves a draft on the live account, it is not a test-mode switch.\n- **Absent test folder → `report_flag` + declare a required input, never a silent placeholder.** If the concierge did not provide the test folder, call `report_flag({ kind: \"gap\" })`. When a placeholder is unavoidable, declare it as a named constant at the top of the file (as `TEST_FOLDER` above) so the owner knows exactly what to fill in — never bury an opaque string inside a node config.\n- `OutlookFolderTestSource` yields items in the same raw `MsGraphMailItem` shape as the live `onNewMsGraphMailTrigger` — no pre-processing, no canonicalization. That's what makes the test meaningful.\n- The `caseLabel` on `OutlookFolderTestSource` defaults to the email subject so each test-case row in the Tests tab is human-readable.\n\n## Read next when needed\n\n- `workflow-dsl` — builder, triggers, flow control, the per-item contract.\n- `document-ai` — OCR an attachment's bytes once they're in `ctx.binary`.\n- `ai-agent` — summarize or triage `item.json.bodyText` with an LLM before replying.\n- `testing` — the full `IsTestRun` + `Assertion` + `TestTrigger` pattern (trigger-agnostic).\n",
249
249
  "format": "doc",
@@ -268,9 +268,9 @@
268
268
  ],
269
269
  "sourcePath": "skills/builder/odoo/SKILL.md",
270
270
  "dependencies": {
271
- "@codemation/core-nodes-odoo": "0.2.0",
272
- "@codemation/core-nodes": "0.12.0",
273
- "@codemation/core": "0.14.0"
271
+ "@codemation/core-nodes-odoo": "0.2.1",
272
+ "@codemation/core-nodes": "0.14.0",
273
+ "@codemation/core": "0.15.0"
274
274
  },
275
275
  "code": "---\nname: odoo\ndescription: Builds Odoo steps with the @codemation/core-nodes-odoo plugin — search/MATCH records (odooQueryNode), CREATE records like a sale.order (odooCreateNode), plus read/update/delete and a generic call_kw. Every node declares the \"odoo\" credential slot for you. Use whenever a workflow looks up, links, or writes Odoo records (partners, products, sale orders).\ncompatibility: Requires @codemation/core-nodes-odoo. An Odoo credential (URL + database + API key) binds on slot \"odoo\".\ntags: odoo, erp, sale-order, partner, product, match, link, crud, call_kw, credential, integration\nuses: \"@codemation/core-nodes-odoo, @codemation/core-nodes, @codemation/core\"\n---\n\n# Odoo nodes\n\nThe `@codemation/core-nodes-odoo` plugin talks to Odoo over JSON-RPC. Each node **declares the `odoo`\ncredential slot itself** (`credentials: { odoo }`) — so the moment you use one, the platform knows the\nworkflow needs an Odoo connection and prompts the operator to bind it before activation. You do **not**\nhand-roll `fetch`/JSON-RPC and you do **not** declare the slot separately.\n\n**Discipline:** author straight from this file, then run `verify_workflow` and fix only what it flags.\nUse `workflow-dsl` for the surrounding builder, trigger, and flow-control surface.\n\n## The nodes\n\n| Node | Odoo op | Use for |\n| ---------------- | ------------- | --------------------------------------------------------------- |\n| `odooQueryNode` | `search_read` | MATCH records — find a partner by email, products by code |\n| `odooCreateNode` | `create` | CREATE a record — a `sale.order` header, a partner |\n| `odooReadNode` | `read` | read one record's fields by id |\n| `odooUpdateNode` | `write` | update a record by id |\n| `odooDeleteNode` | `unlink` | delete a record by id |\n| `odooCallKwNode` | any `call_kw` | anything the CRUD nodes don't cover (e.g. `sale.order` + lines) |\n\n## MATCH → LINK → REPORT (the order-intake shape)\n\n`odooQueryNode` runs `search_read(model, domain, fields)` and **fans out one item per matched row** —\nthis is the generic node fan-out behaviour (a node returning an array emits one item per element; see\n`workflow-dsl` → **Fan-out & fan-in**). Zero rows ⇒ zero items downstream, so branch on that to REPORT\nwhat didn't match, never silently drop it. Each downstream item's `item.json` is one `OdooQueryOutputRow`.\n`odooCreateNode` writes a record (e.g. a `sale.order`) and emits its new `id`; LINK the matched partner\ninto it with `itemExpr`.\n\n**For every entity in an order-intake workflow (company, contacts, products), apply the same three-step\ndiscipline:**\n\n1. **MATCH** — run `odooQueryNode` to search for the ERP record. Fan-out: zero rows = unmatched.\n2. **LINK** — wire the matched record's `id` into the downstream create/update via `itemExpr`.\n3. **REPORT** — when a record CANNOT be matched, do NOT drop it silently and do NOT fabricate an id.\n Instead, report it: write an audit note / human-review flag / `unmatchedItems` field on `item.json`\n so the concierge can surface it and the user can act. Keep the original email + any PDF attachment\n on `item.binary` throughout so the audit trail is complete.\n\n```typescript\nimport { createWorkflowBuilder, ManualTrigger, Callback } from \"@codemation/core-nodes\";\nimport { itemExpr } from \"@codemation/core\";\nimport type { Items } from \"@codemation/core\";\nimport { odooQueryNode, odooCreateNode, type OdooQueryOutputRow } from \"@codemation/core-nodes-odoo\";\n\ntype OrderCanonical = { companyName: string; poNumber: string; buyerEmail: string };\ntype WithPartner = OrderCanonical & { partnerId: number };\ntype UnmatchedReport = OrderCanonical & { unmatchedReason: string };\n\nexport default createWorkflowBuilder({ id: \"wf.odoo.order-sync\", name: \"Odoo order sync\" })\n .trigger(\n new ManualTrigger<OrderCanonical>(\"Start\", [\n { companyName: \"ACME\", poNumber: \"PO-001\", buyerEmail: \"buyer@acme.example\" },\n ]),\n )\n // MATCH: search_read res.partner by email — fans out one item per match (none ⇒ none downstream).\n .then(\n odooQueryNode.create<OrderCanonical>(\n {\n model: \"res.partner\",\n fields: [\"id\", \"name\", \"email\"],\n query: [{ field: \"email\", operator: \"=\", value: \"buyer@acme.example\" }],\n mode: \"and\",\n pagination: { limit: 1 },\n },\n \"Match customer\",\n \"match-partner\",\n ),\n )\n // LINK + CREATE: each item is now one matched partner row — wire its id into a new sale.order.\n .then(\n odooCreateNode.create(\n {\n model: \"sale.order\",\n values: itemExpr<Record<string, unknown>, OdooQueryOutputRow>(({ item }) => ({\n partner_id: Number(item.json[\"id\"]),\n origin: \"order-intake\",\n })),\n },\n \"Create sale order\",\n \"create-order\",\n ),\n )\n .build();\n```\n\n**REPORT the unmatched (the missing stage agents often skip):** when the match query returns\nzero rows, branch on that and produce an explicit unmatched record — not silence:\n\n```typescript\nimport { Callback } from \"@codemation/core-nodes\";\nimport type { Items } from \"@codemation/core\";\n\ntype OrderCanonical = { companyName: string; poNumber: string; buyerEmail: string };\ntype UnmatchedReport = OrderCanonical & { unmatchedReason: string };\n\n// In the zero-match branch: preserve the full order payload + record WHY it didn't match.\n// Surface this as an audit note or a human-review item — never drop the unmatched entity.\nconst reportUnmatched = new Callback<OrderCanonical, UnmatchedReport>(\n \"Flag unmatched company\",\n (items: Items<OrderCanonical>) =>\n items.map((i) => ({\n json: {\n ...i.json, // ← preserve the entire canonical payload\n unmatchedReason: `No Odoo partner found for email \"${i.json.buyerEmail}\" (company: \"${i.json.companyName}\") — needs manual review`,\n },\n })),\n { id: \"flag-unmatched-company\" },\n);\nexport {};\n```\n\n## Fuzzy matching? Use an Odoo AGENT, not a deterministic chain\n\nWhen you need to match real-world text (company names, product descriptions, buyer contacts) against Odoo records, a deterministic `odooQueryNode` chain **fails**:\n\n- `odooQueryNode` **fans out** — one item per matched row. On a multi-entity canonical payload (company + contacts + line items) this collapses the original item structure, forcing unsafe casts to retrieve the order payload again.\n- On **no match** the chain emits zero items — making it trivial to accidentally silently drop the record or, worse, fall back to a fabricated `partner_id: 1`.\n- Fuzzy matching (a company name that's slightly different, a product code vs. description) requires multiple strategies and reasoning, which a deterministic query can't express.\n\n**The solution: author an `AIAgent` whose tools are the Odoo nodes.** The agent calls one search tool per entity, reasons over the candidates, links matched ids into the final create, and explicitly reports everything it could not resolve. No fan-out, no fabrication, no silent drops.\n\n### The pattern: `AgentToolFactory.asTool(odooNode, { mapInput, mapOutput })` for each entity\n\nWrap each Odoo operation as an agent tool. The key discipline:\n\n- **`mapInput`**: translate the agent's tool-call args to the node's item-level config (`{ query: [...] }`). Static parts (`model`, `fields`, `pagination`) live in the node's `.create(...)` config.\n- **`mapOutput`**: always write a custom handler that reads `outputs.main?.[0]?.json` and returns `{ found: false, id: null }` on empty — never let the default throw on a no-match.\n- **`onRejected: \"halt\"`** on `request_human_review` so the workflow halts when a human rejects a critical gap.\n\n### MATCH → LINK → REPORT system prompt\n\nThe agent's system prompt enforces three phases:\n\n1. **MATCH**: for each entity, call the corresponding search tool. Accept the result; never invent an id.\n2. **LINK**: once all searches are done, call `odoo_create_sale_order` with the matched ids. For unmatched products, pass `productId: null` and include the line description.\n3. **REPORT**: call `odoo_post_audit_comment` with a structured summary of matched and unmatched entities. If a critical entity (company, key product) could not be matched, call `request_human_review` BEFORE creating the order.\n\n### Compilable example\n\n```typescript\nimport { AIAgent, CodemationChatModelConfig, inboxApproval } from \"@codemation/core-nodes\";\nimport { AgentToolFactory } from \"@codemation/core\";\nimport { odooQueryNode, odooCreateNode, odooCallKwNode, type OdooQueryOutputRow } from \"@codemation/core-nodes-odoo\";\nimport { z } from \"zod\";\nimport type { Item } from \"@codemation/core\";\n\n// --- Tool 1: search res.partner by email (node-backed) ---\n// Static config on .create(): model + fields + pagination (the \"outer shape\").\n// mapInput supplies the dynamic per-call query from the agent's args.\n// mapOutput handles zero-match safely — never throws, never fabricates an id.\nconst odoo_search_partner = AgentToolFactory.asTool(\n odooQueryNode.create(\n { model: \"res.partner\", fields: [\"id\", \"name\", \"email\"], mode: \"and\", pagination: { limit: 1 } },\n \"Search partner\",\n \"odoo-search-partner\",\n ),\n {\n name: \"odoo_search_partner\",\n description: \"Search res.partner by email. Returns { found: false, id: null } on no match — NEVER fabricate an id.\",\n inputSchema: z.object({\n email: z.string().describe(\"Email address to search for\"),\n }),\n outputSchema: z.object({\n found: z.boolean(),\n id: z.number().nullable(),\n name: z.string().nullable(),\n }),\n mapInput: ({ input }) => ({ json: { query: [{ field: \"email\", operator: \"=\", value: input.email }] } }),\n mapOutput: ({ outputs }: { outputs: { main?: ReadonlyArray<{ json: unknown }> } }) => {\n const row = outputs.main?.[0]?.json as OdooQueryOutputRow | undefined;\n return row\n ? { found: true, id: Number(row[\"id\"]), name: String(row[\"name\"] ?? \"\") }\n : { found: false, id: null, name: null };\n },\n },\n);\n\n// --- Tool 2: search product.template by description or code (node-backed) ---\nconst odoo_search_product = AgentToolFactory.asTool(\n odooQueryNode.create(\n { model: \"product.template\", fields: [\"id\", \"name\", \"default_code\"], mode: \"or\", pagination: { limit: 3 } },\n \"Search product\",\n \"odoo-search-product\",\n ),\n {\n name: \"odoo_search_product\",\n description:\n \"Search product.template by description (ilike) or exact product code. \" +\n \"Returns the best match or { found: false } on no match.\",\n inputSchema: z.object({\n description: z.string().describe(\"Product description to fuzzy-match\"),\n code: z.string().optional().describe(\"Exact product code to try first\"),\n }),\n outputSchema: z.object({ found: z.boolean(), id: z.number().nullable(), name: z.string().nullable() }),\n mapInput: ({ input }) => ({\n json: {\n query: [\n ...(input.code ? [{ field: \"default_code\", operator: \"=\", value: input.code }] : []),\n { field: \"name\", operator: \"ilike\", value: input.description },\n ],\n },\n }),\n mapOutput: ({ outputs }: { outputs: { main?: ReadonlyArray<{ json: unknown }> } }) => {\n const row = outputs.main?.[0]?.json as OdooQueryOutputRow | undefined;\n return row\n ? { found: true, id: Number(row[\"id\"]), name: String(row[\"name\"] ?? \"\") }\n : { found: false, id: null, name: null };\n },\n },\n);\n\n// --- Tool 3: create sale.order (node-backed) ---\nconst odoo_create_sale_order = AgentToolFactory.asTool(\n odooCreateNode.create({ model: \"sale.order\" }, \"Create sale order\", \"odoo-create-sale-order\"),\n {\n name: \"odoo_create_sale_order\",\n description: \"Create a sale.order with matched partner and order lines.\",\n inputSchema: z.object({\n partnerId: z.number().describe(\"res.partner id (billing contact or company)\"),\n clientOrderRef: z.string().nullable().describe(\"Customer PO or reference number\"),\n orderLines: z.array(\n z.object({\n productId: z.number().nullable().describe(\"product.template id; null for unmatched lines\"),\n name: z.string().describe(\"Line description (required when productId is null)\"),\n productUomQty: z.number().default(1),\n }),\n ),\n }),\n outputSchema: z.object({ saleOrderId: z.number().nullable() }),\n mapInput: ({ input }) => ({\n json: {\n values: {\n partner_id: input.partnerId,\n client_order_ref: input.clientOrderRef,\n order_line: input.orderLines.map((l) => [\n 0,\n 0,\n { product_id: l.productId ?? false, name: l.name, product_uom_qty: l.productUomQty },\n ]),\n },\n },\n }),\n mapOutput: ({ outputs }: { outputs: { main?: ReadonlyArray<{ json: unknown }> } }) => {\n const row = outputs.main?.[0]?.json as Record<string, unknown> | undefined;\n return { saleOrderId: row?.id != null ? Number(row[\"id\"]) : null };\n },\n },\n);\n\n// --- Tool 4: HITL escalation (node-backed) ---\nconst request_human_review = AgentToolFactory.asTool(\n inboxApproval.create(\n {\n title: ({ item }: { item: Item }) => String((item.json as { title?: unknown }).title ?? \"Review needed\"),\n body: ({ item }: { item: Item }) => String((item.json as { body?: unknown }).body ?? \"\"),\n priority: \"high\",\n timeout: \"8h\",\n onTimeout: \"halt\",\n },\n \"Human review request\",\n ),\n {\n name: \"request_human_review\",\n description:\n \"Escalate to a human when a critical entity (company, key product) cannot be matched. \" +\n \"Call BEFORE creating the order. Halt if the reviewer rejects.\",\n onRejected: \"halt\",\n inputSchema: z.object({\n title: z.string(),\n reason: z.string(),\n missingEntities: z.array(z.string()),\n }),\n outputSchema: z.object({ approved: z.boolean(), note: z.string().optional() }),\n mapInput: ({ input, item }) => ({\n json: {\n ...((item.json as Record<string, unknown>) ?? {}),\n title: input.title,\n body: `${input.reason}\\n\\nCould not match: ${input.missingEntities.join(\", \")}`,\n },\n }),\n mapOutput: ({ outputs }: { outputs: { main?: ReadonlyArray<{ json: unknown }> } }) => {\n const first = outputs.main?.[0]?.json as { decision?: { status?: string; note?: string } } | undefined;\n return { approved: first?.decision?.status === \"approved\", note: first?.decision?.note };\n },\n },\n);\n\n// --- Tool 4b: post audit comment — node-backed via odooCallKwNode message_post ---\n// Post audit notes via a node-backed `asTool(odooCallKwNode … message_post)` tool — NOT a stubbed callableTool; the real node does the RPC.\nconst odoo_post_audit_comment = AgentToolFactory.asTool(\n odooCallKwNode.create({ model: \"sale.order\", method: \"message_post\" }, \"Post audit comment\", \"post-audit\"),\n {\n name: \"odoo_post_audit_comment\",\n description: \"Post an audit chatter note on the sale.order summarising matched/unmatched entities.\",\n inputSchema: z.object({ saleOrderId: z.number(), body: z.string() }),\n outputSchema: z.object({ posted: z.boolean() }),\n mapInput: ({ input }) => ({ json: { args: [[input.saleOrderId]], kwargs: { body: input.body } } }),\n mapOutput: () => ({ posted: true }),\n },\n);\n\n// --- Shared types ---\nconst OdooSyncOutput = z.object({\n saleOrderId: z.number().nullable(),\n matched: z.object({\n partnerId: z.number().nullable(),\n productIds: z.array(z.number()),\n }),\n unmatched: z.object({\n products: z.array(z.string()),\n contacts: z.array(z.string()),\n }),\n});\ntype OdooSyncOutputT = z.infer<typeof OdooSyncOutput>;\ntype OrderT = { company: string; buyerEmail: string; lines: Array<{ desc: string; code?: string; qty: number }> };\n\n// --- The Odoo sync agent ---\nnew AIAgent<OrderT, OdooSyncOutputT>({\n name: \"Odoo sync agent\",\n id: \"odoo-sync-agent\",\n messages: [\n {\n role: \"system\",\n content:\n \"You are an Odoo integration agent. Follow MATCH → LINK → REPORT strictly:\\n\" +\n \"\\n\" +\n \"1. MATCH each entity:\\n\" +\n \" - Company/partner: odoo_search_partner(email)\\n\" +\n \" - Each line item: odoo_search_product(description, code?) — try code first, then description\\n\" +\n \"\\n\" +\n \"2. LINK: call odoo_create_sale_order with the matched partnerId and orderLines.\\n\" +\n \" - For unmatched products, set productId: null and include the original line description.\\n\" +\n \" - NEVER invent a partnerId. If company is not found AND order has >2 lines: call request_human_review FIRST.\\n\" +\n \"\\n\" +\n \"3. REPORT: call odoo_post_audit_comment with a summary of matched ids and unmatched entities.\\n\" +\n \"\\n\" +\n \"Return {saleOrderId, matched:{partnerId, productIds:[]}, unmatched:{products:[], contacts:[]}}.\",\n },\n { role: \"user\", content: ({ item }) => JSON.stringify(item.json, null, 2) },\n ],\n chatModel: new CodemationChatModelConfig(\"Managed AI\", \"medium\"),\n tools: [\n odoo_search_partner,\n odoo_search_product,\n odoo_create_sale_order,\n odoo_post_audit_comment,\n request_human_review,\n ],\n outputSchema: OdooSyncOutput,\n guardrails: { maxTurns: 20 },\n});\nexport {};\n```\n\n### Why this beats a deterministic node chain\n\n| Concern | Deterministic chain | Odoo agent |\n| --------------------------------------- | -------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- |\n| Fan-out loses canonical payload | Yes — `odooQueryNode` emits one item per row; multi-entity payload is gone | No — agent holds the full order in context across all tool calls |\n| Zero-match → silent drop or fabrication | Yes — easy to miss the zero-items branch; agents have fabricated `partner_id: 1` | No — `mapOutput` returns `{ found: false, id: null }`; agent reads it and reports explicitly |\n| Fuzzy matching (typos, partial names) | Hard — requires multiple `odooQueryNode` branches | Natural — agent reasons over candidates, tries code then description |\n| Escalate unresolvable gaps | Requires extra HITL node wired correctly outside the chain | Built-in — agent calls `request_human_review` tool |\n\n**Use a deterministic chain only when the match is guaranteed exact** (e.g. a webhook that already includes an Odoo record id). For anything fuzzy, incomplete, or multi-entity: use the agent pattern above.\n\n## Querying — the `query` domain\n\n`query` is a list of `{ field, operator, value }` leaves combined with `mode: \"and\" | \"or\"`; it compiles\nto an Odoo domain. Common operators: `\"=\"`, `\"!=\"`, `\"ilike\"` (fuzzy, case-insensitive — use it for\nproduct-name matching), `\"in\"`. `pagination` takes `{ limit, offset, order }`. Set `includeTotalCount: true`\nto add a `totalCount` to each row when you need the full match count.\n\n```typescript\nimport { odooQueryNode } from \"@codemation/core-nodes-odoo\";\n\n// Fuzzy product match by code OR name — fans out every candidate so a Callback can rank/report.\nconst findProducts = odooQueryNode.create(\n {\n model: \"product.product\",\n fields: [\"id\", \"default_code\", \"name\"],\n query: [\n { field: \"default_code\", operator: \"=\", value: \"PUMP-100\" },\n { field: \"name\", operator: \"ilike\", value: \"centrifugal pump\" },\n ],\n mode: \"or\",\n pagination: { limit: 5 },\n includeTotalCount: true,\n },\n \"Find products\",\n \"find-products\",\n);\n```\n\n## Per-item values with `itemExpr`\n\nStatic config is the default. When a field must come from the current item (the matched id, an\nextracted value), wrap it with `itemExpr(...)` — the engine resolves it per item before `execute`.\n\n```typescript\nimport { itemExpr } from \"@codemation/core\";\nimport { odooReadNode } from \"@codemation/core-nodes-odoo\";\n\nconst readMatched = odooReadNode.create(\n {\n model: \"res.partner\",\n fields: [\"id\", \"name\", \"email\"],\n id: itemExpr<number, { id: number }>(({ item }) => item.json.id),\n },\n \"Read matched partner\",\n \"read-partner\",\n);\n```\n\n## Chaining after any upstream (humanApproval, transforms, triggers)\n\nEvery Odoo node's input brand is `any`, so `.then(odooXxxNode.create(...))` compiles after **any**\nupstream — a trigger, a transform, a `humanApproval` step, or another Odoo node. Type-safe `itemExpr`\naccess to the upstream item shape comes from the `create<TUpstreamJson>()` generic, not from the\nnode's brand.\n\n```typescript\nimport { createWorkflowBuilder, ManualTrigger, inboxApproval } from \"@codemation/core-nodes\";\nimport { itemExpr } from \"@codemation/core\";\nimport { odooCallKwNode } from \"@codemation/core-nodes-odoo\";\n\ntype OrderJson = Record<string, unknown> & { orderId: string; vendor: string };\n\nexport default createWorkflowBuilder({ id: \"wf.odoo.approval-chain\", name: \"Approval → Odoo\" })\n .trigger(new ManualTrigger<OrderJson>(\"Start\", [{ orderId: \"42\", vendor: \"ACME\" }]))\n // humanApproval emits OrderJson & { decision: HumanApprovalDecisionResult }\n .humanApproval(inboxApproval, {\n title: \"Approve order?\",\n body: \"Review the order.\",\n priority: \"normal\",\n timeout: \"24h\",\n onTimeout: \"halt\",\n })\n // create<UpstreamType>() gives typed item.json inside itemExpr; the node chains regardless of upstream shape\n .then(\n odooCallKwNode.create<OrderJson & { decision: unknown }>({\n model: \"sale.order\",\n method: \"create\",\n args: itemExpr<unknown[], OrderJson & { decision: unknown }>(({ item }) => [\n { partner_id: 1, origin: item.json.orderId },\n ]),\n }),\n )\n .build();\n```\n\n## Beyond CRUD — `odooCallKwNode`\n\nA `sale.order` with its `order_line` is one `create` with nested commands, which is cleaner via the\ngeneric node. `args`/`kwargs` map straight onto Odoo's `call_kw(model, method, args, kwargs)`.\n\n```typescript\nimport { odooCallKwNode } from \"@codemation/core-nodes-odoo\";\n\nconst createOrderWithLines = odooCallKwNode.create(\n {\n model: \"sale.order\",\n method: \"create\",\n args: [\n {\n partner_id: 1,\n order_line: [\n // Odoo command 0 = \"create a new line\"\n [0, 0, { product_id: 42, product_uom_qty: 3 }],\n ],\n },\n ],\n },\n \"Create order + lines\",\n \"create-order-lines\",\n);\n```\n\n## Credential\n\nThe `odoo` slot is declared by the nodes — bind an Odoo credential (instance URL + database + API key)\nto it in the canvas before activating. See `credential-development` to author a new Odoo credential\ntype, or bind an existing one. Nothing to declare in the workflow beyond using the nodes.\n\n## Related\n\n- `workflow-dsl` — the builder, trigger, and flow-control spine. Start here.\n- `ai-agent` — extract structured order fields (company, line items) before the Odoo match.\n- `document-ai` — OCR an order PDF into `{ markdown, fields }` upstream of the match.\n",
276
276
  "format": "doc",
@@ -308,10 +308,10 @@
308
308
  ],
309
309
  "sourcePath": "skills/builder/rest-node/SKILL.md",
310
310
  "dependencies": {
311
- "@codemation/core-nodes": "0.12.0",
312
- "@codemation/core": "0.14.0"
311
+ "@codemation/core-nodes": "0.14.0",
312
+ "@codemation/core": "0.15.0"
313
313
  },
314
- "code": "---\nname: rest-node\ndescription: Wrap a plain REST/HTTP service as a reusable workflow node with defineRestNode — declare a base URL, an endpoint + method, a credential slot, and input→request / response→output mappers, then use node.create(...) in a builder chain. Reach for this when a step talks to an external API that has no specialized node and no MCP server.\ncompatibility: Codemation core-nodes. Requires @codemation/core-nodes import.\ntags: rest, http, api, integration, defineRestNode, node, credential, connect, external\nuses: \"@codemation/core-nodes, @codemation/core, zod\"\n---\n\n# Codemation REST Node (`defineRestNode`)\n\n`defineRestNode` (from `@codemation/core-nodes`) turns one REST endpoint into a thin, reusable workflow\nnode — without a hand-rolled `Callback` + raw `fetch`. You declare a base URL, a path + method, an\noptional credential slot, and two small mappers (item input → request, HTTP response → output). It is a\ndeclarative wrapper over `defineNode`, so the result is used exactly like any built-in node:\n`node.create(config, label, nodeId)` in a `createWorkflowBuilder()` chain.\n\n**When to reach for it.** This is route 3 of the integration-routing hierarchy (see\n`connect-external-systems`): use `defineRestNode` for a plain REST/HTTP service when there is **no\nspecialized node package** (route 1 — check `search_skills`) and **no MCP server** (route 2 — check\n`list_mcp_servers`). It is the right tool the moment you would otherwise hand-roll `fetch` against a\nJSON API — and it is strictly preferred over that. Only drop to a raw `Callback` when even\n`defineRestNode` can't express the call (see Gotchas).\n\n**Discipline:** author straight from this file, then run `verify_workflow` and fix only what it flags.\nUse `workflow-dsl` for the surrounding builder, trigger, and flow-control surface.\n\n## The shape — one node per endpoint\n\n`defineRestNode({...})` takes:\n\n- **`key`** / **`title`** — stable node key (e.g. `\"widgets.create\"`) and canvas title.\n- **`api`** — `{ baseUrl, path, method? }`. `path` may carry `{name}` placeholders, substituted from\n `input` keys before the request (method defaults to `GET`).\n- **`credentials`** — a slot map, e.g. `{ auth: bearerTokenCredentialType }`. The session applies auth\n to every request (Bearer header, API-key header/query, Basic). The operator binds it via the canvas;\n you only declare the slot. Omit `credentials` for an unauthenticated API.\n- **`inputSchema`** — a Zod schema for per-item input, validated before the request runs.\n- **`request({ input })`** — returns `{ body?, query?, headers?, pathParams? }`. For a JSON body pass\n `{ kind: \"json\", data: {...} }`.\n- **`response({ json, text, status, ok, input, ... })`** — maps the HTTP result to the node's output\n JSON. Omit it to emit the raw `{ status, ok, json, text, ... }` response context.\n- **`errorPolicy`** — `\"throw\"` (default — non-2xx throws) or `\"passthrough\"` (return the result and\n branch on `ok` downstream).\n\nCredential types come from `@codemation/core-nodes`: `bearerTokenCredentialType`,\n`apiKeyCredentialType`, `basicAuthCredentialType` (or any custom `defineCredential`).\n\n## A complete REST-backed workflow\n\nA generic \"widgets API\": list widgets nightly (API-key auth), fan the array into one item per widget,\nthen create a record back in the same API (Bearer auth). Each node is defined once, then used with\n`.create(config, label, nodeId)` — the `config` is `{}` here because the per-item input flows from the\nchain, not static config.\n\n```typescript\nimport { z } from \"zod\";\nimport {\n createWorkflowBuilder,\n CronTrigger,\n Split,\n defineRestNode,\n apiKeyCredentialType,\n bearerTokenCredentialType,\n} from \"@codemation/core-nodes\";\nimport type { Item } from \"@codemation/core\";\n\n// List widgets — GET with an API-key credential, no request body.\nconst listWidgets = defineRestNode({\n key: \"widgets.list\",\n title: \"List widgets\",\n icon: \"mdi:widgets\",\n api: { baseUrl: \"https://api.widgets.example.com\", path: \"/v1/widgets\" },\n credentials: { auth: apiKeyCredentialType },\n response: ({ json }) => ({ widgets: (json as { items: { name: string; color: string }[] }).items }),\n});\n\n// Create a widget — POST with a JSON body built from the item input.\nconst createWidget = defineRestNode({\n key: \"widgets.create\",\n title: \"Create widget\",\n icon: \"mdi:widgets\",\n api: { baseUrl: \"https://api.widgets.example.com\", path: \"/v1/widgets\", method: \"POST\" },\n credentials: { auth: bearerTokenCredentialType },\n inputSchema: z.object({ name: z.string(), color: z.string() }),\n request: ({ input }) => ({\n body: { kind: \"json\", data: { name: input.name, color: input.color } },\n }),\n response: ({ json }) => ({ widgetId: (json as { id: string }).id }),\n});\n\nexport default createWorkflowBuilder({ id: \"wf.widgets-sync\", name: \"Sync widgets\" })\n .trigger(new CronTrigger(\"Nightly\", { schedule: \"0 2 * * *\", timezone: \"Europe/Amsterdam\" }))\n .then(listWidgets.create({}, \"List widgets\", \"list-widgets\"))\n .then(\n new Split<{ widgets: { name: string; color: string }[] }, { name: string; color: string }>(\n \"Per widget\",\n (item: Item<{ widgets: { name: string; color: string }[] }>) => item.json.widgets,\n \"per-widget\",\n ),\n )\n .then(createWidget.create({}, \"Create widget\", \"create-widget\"))\n .build();\n```\n\n## Path placeholders and per-item input\n\n`path` `{name}` placeholders are substituted from `input` keys (and any `pathParams` you return from\n`request`). So a per-record GET reads its id straight off the item:\n\n```typescript\nimport { z } from \"zod\";\nimport { defineRestNode, bearerTokenCredentialType } from \"@codemation/core-nodes\";\n\n// GET /v1/widgets/{widgetId} — `widgetId` is filled from the item input.\nexport const getWidget = defineRestNode({\n key: \"widgets.get\",\n title: \"Get widget\",\n api: { baseUrl: \"https://api.widgets.example.com\", path: \"/v1/widgets/{widgetId}\" },\n credentials: { auth: bearerTokenCredentialType },\n inputSchema: z.object({ widgetId: z.string() }),\n response: ({ json, status }) => ({ widget: json, status }),\n});\n```\n\n## Gotchas\n\n- **`.create({}, label, id)` — config is empty; input flows from the chain.** `defineRestNode` puts\n per-call data on the **item input** (validated by `inputSchema`), not static config — so the first\n `.create` arg is `{}`. The node reads each item's `json` as its `input`. Give every node an explicit\n stable `nodeId` (third arg), same as the rest of the DSL.\n- **The output is the `response` mapper's return — not the input item.** Like `HttpRequest`, a REST node\n replaces the item payload. Carry forward anything you still need before the call, or return it from\n `response`.\n- **Non-2xx throws by default.** Set `errorPolicy: \"passthrough\"` and branch on `item.json.ok` with an\n `If` when you want to handle failures inline instead of failing the run.\n- **Bind the credential slot before activation.** Declaring `credentials: { auth: ... }` surfaces a\n bindable slot; the operator binds an instance via the canvas dropdown (the concierge handles this —\n separate skill). An unauthenticated API: omit `credentials`.\n- **Last resort is a raw `Callback`.** Only hand-roll `fetch`/JSON-RPC inside a `Callback` when the call\n genuinely can't be expressed as one endpoint + auth + mappers (e.g. multi-step JSON-RPC, streaming).\n See `connect-external-systems` for the full hierarchy.\n\n## Read next when needed\n\n- `connect-external-systems` — the full routing hierarchy; when `defineRestNode` is (and isn't) the right route.\n- `workflow-dsl` — builder, triggers, flow control, the per-item contract.\n- `custom-node-development` — `defineNode` for logic richer than one REST call (own execute body, ports).\n- `credential-development` — author a custom credential type when Bearer/API-key/Basic don't fit.\n",
314
+ "code": "---\nname: rest-node\ndescription: Wrap a plain REST/HTTP service as a reusable workflow node with defineRestNode — declare a base URL, an endpoint + method, a credential slot, and input→request / response→output mappers, then use node.create(...) in a builder chain. Reach for this when a step talks to an external API that has no specialized node and no MCP server.\ncompatibility: Codemation core-nodes. Requires @codemation/core-nodes import.\ntags: rest, http, api, integration, defineRestNode, node, credential, connect, external\nuses: \"@codemation/core-nodes, @codemation/core, zod\"\n---\n\n# Codemation REST Node (`defineRestNode`)\n\n`defineRestNode` (from `@codemation/core-nodes`) turns one REST endpoint into a thin, reusable workflow\nnode — without a hand-rolled `Callback` + raw `fetch`. You declare a base URL, a path + method, an\noptional credential slot, and two small mappers (item input → request, HTTP response → output). It is a\ndeclarative wrapper over `defineNode`, so the result is used exactly like any built-in node:\n`node.create(config, label, nodeId)` in a `createWorkflowBuilder()` chain.\n\n**When to reach for it.** This is route 3 of the integration-routing hierarchy (see\n`connect-external-systems`): use `defineRestNode` for a plain REST/HTTP service when there is **no\nspecialized node package** (route 1 — check `search_skills`) and **no MCP server** (route 2 — check\n`list_mcp_servers`). It is the right tool the moment you would otherwise hand-roll `fetch` against a\nJSON API — and it is strictly preferred over that. Only drop to a raw `Callback` when even\n`defineRestNode` can't express the call (see Gotchas).\n\n**Discipline:** author straight from this file, then run `verify_workflow` and fix only what it flags.\nUse `workflow-dsl` for the surrounding builder, trigger, and flow-control surface.\n\n## The shape — one node per endpoint\n\n`defineRestNode({...})` takes:\n\n- **`key`** / **`title`** — stable node key (e.g. `\"widgets.create\"`) and canvas title.\n- **`api`** — `{ baseUrl, path, method? }`. `path` may carry `{name}` placeholders, substituted from\n `input` keys before the request (method defaults to `GET`).\n- **`credentials`** — a slot map, e.g. `{ auth: bearerTokenCredentialType }`. The session applies auth\n to every request (Bearer header, API-key header/query, Basic). The operator binds it via the canvas;\n you only declare the slot. Omit `credentials` for an unauthenticated API.\n- **`inputSchema`** — a Zod schema for per-item input, validated before the request runs.\n- **`request({ input })`** — returns `{ body?, query?, headers?, pathParams? }`. For a JSON body pass\n `{ kind: \"json\", data: {...} }`.\n- **`response({ json, text, status, ok, input, ... })`** — maps the HTTP result to the node's output\n JSON. Omit it to emit the raw `{ status, ok, json, text, ... }` response context.\n- **`errorPolicy`** — `\"throw\"` (default — non-2xx throws) or `\"passthrough\"` (return the result and\n branch on `ok` downstream).\n\nCredential types come from `@codemation/core-nodes`: `bearerTokenCredentialType`,\n`apiKeyCredentialType`, `basicAuthCredentialType` (or any custom `defineCredential`).\n\n## A complete REST-backed workflow\n\nA generic \"widgets API\": list widgets on a schedule (API-key auth), fan the array into one item per widget,\nthen create a record back in the same API (Bearer auth). Each node is defined once, then used with\n`.create(config, label, nodeId)` — the `config` is `{}` here because the per-item input flows from the\nchain, not static config.\n\n```typescript\nimport { z } from \"zod\";\nimport {\n createWorkflowBuilder,\n schedulePollingTrigger,\n Split,\n defineRestNode,\n apiKeyCredentialType,\n bearerTokenCredentialType,\n} from \"@codemation/core-nodes\";\nimport type { Item } from \"@codemation/core\";\n\n// List widgets — GET with an API-key credential, no request body.\nconst listWidgets = defineRestNode({\n key: \"widgets.list\",\n title: \"List widgets\",\n icon: \"mdi:widgets\",\n api: { baseUrl: \"https://api.widgets.example.com\", path: \"/v1/widgets\" },\n credentials: { auth: apiKeyCredentialType },\n response: ({ json }) => ({ widgets: (json as { items: { name: string; color: string }[] }).items }),\n});\n\n// Create a widget — POST with a JSON body built from the item input.\nconst createWidget = defineRestNode({\n key: \"widgets.create\",\n title: \"Create widget\",\n icon: \"mdi:widgets\",\n api: { baseUrl: \"https://api.widgets.example.com\", path: \"/v1/widgets\", method: \"POST\" },\n credentials: { auth: bearerTokenCredentialType },\n inputSchema: z.object({ name: z.string(), color: z.string() }),\n request: ({ input }) => ({\n body: { kind: \"json\", data: { name: input.name, color: input.color } },\n }),\n response: ({ json }) => ({ widgetId: (json as { id: string }).id }),\n});\n\nexport default createWorkflowBuilder({ id: \"wf.widgets-sync\", name: \"Sync widgets\" })\n .trigger(schedulePollingTrigger.create({}, \"Run on schedule\", \"widgets-schedule\"))\n .then(listWidgets.create({}, \"List widgets\", \"list-widgets\"))\n .then(\n new Split<{ widgets: { name: string; color: string }[] }, { name: string; color: string }>(\n \"Per widget\",\n (item: Item<{ widgets: { name: string; color: string }[] }>) => item.json.widgets,\n \"per-widget\",\n ),\n )\n .then(createWidget.create({}, \"Create widget\", \"create-widget\"))\n .build();\n```\n\n## Path placeholders and per-item input\n\n`path` `{name}` placeholders are substituted from `input` keys (and any `pathParams` you return from\n`request`). So a per-record GET reads its id straight off the item:\n\n```typescript\nimport { z } from \"zod\";\nimport { defineRestNode, bearerTokenCredentialType } from \"@codemation/core-nodes\";\n\n// GET /v1/widgets/{widgetId} — `widgetId` is filled from the item input.\nexport const getWidget = defineRestNode({\n key: \"widgets.get\",\n title: \"Get widget\",\n api: { baseUrl: \"https://api.widgets.example.com\", path: \"/v1/widgets/{widgetId}\" },\n credentials: { auth: bearerTokenCredentialType },\n inputSchema: z.object({ widgetId: z.string() }),\n response: ({ json, status }) => ({ widget: json, status }),\n});\n```\n\n## Gotchas\n\n- **`.create({}, label, id)` — config is empty; input flows from the chain.** `defineRestNode` puts\n per-call data on the **item input** (validated by `inputSchema`), not static config — so the first\n `.create` arg is `{}`. The node reads each item's `json` as its `input`. Give every node an explicit\n stable `nodeId` (third arg), same as the rest of the DSL.\n- **The output is the `response` mapper's return — not the input item.** Like `HttpRequest`, a REST node\n replaces the item payload. Carry forward anything you still need before the call, or return it from\n `response`.\n- **Non-2xx throws by default.** Set `errorPolicy: \"passthrough\"` and branch on `item.json.ok` with an\n `If` when you want to handle failures inline instead of failing the run.\n- **Bind the credential slot before activation.** Declaring `credentials: { auth: ... }` surfaces a\n bindable slot; the operator binds an instance via the canvas dropdown (the concierge handles this —\n separate skill). An unauthenticated API: omit `credentials`.\n- **Last resort is a raw `Callback`.** Only hand-roll `fetch`/JSON-RPC inside a `Callback` when the call\n genuinely can't be expressed as one endpoint + auth + mappers (e.g. multi-step JSON-RPC, streaming).\n See `connect-external-systems` for the full hierarchy.\n\n## Read next when needed\n\n- `connect-external-systems` — the full routing hierarchy; when `defineRestNode` is (and isn't) the right route.\n- `workflow-dsl` — builder, triggers, flow control, the per-item contract.\n- `custom-node-development` — `defineNode` for logic richer than one REST call (own execute body, ports).\n- `credential-development` — author a custom credential type when Bearer/API-key/Basic don't fit.\n",
315
315
  "format": "doc",
316
316
  "verified": false,
317
317
  "l2": "- Codemation REST Node (`defineRestNode`)\n - The shape — one node per endpoint\n - A complete REST-backed workflow\n - Path placeholders and per-item input\n - Gotchas\n - Read next when needed"
@@ -329,10 +329,10 @@
329
329
  ],
330
330
  "sourcePath": "skills/builder/testing/SKILL.md",
331
331
  "dependencies": {
332
- "@codemation/core-nodes": "0.12.0",
333
- "@codemation/core": "0.14.0"
332
+ "@codemation/core-nodes": "0.14.0",
333
+ "@codemation/core": "0.15.0"
334
334
  },
335
- "code": "---\nname: testing\ndescription: Make a workflow safe to dry-run on real inputs — drive it from a TestTrigger fixture source, guard side effects with IsTestRun, and record pass/fail with Assertion. The dry-run path asserts while the live path performs the real write. Read this to make any side-effecting workflow testable before it goes live.\ncompatibility: Designed for Codemation workflows authored with @codemation/core-nodes.\ntags: testing, test, assertion, dry-run, istestrun, testtrigger\nuses: \"@codemation/core-nodes, @codemation/core\"\n---\n\n# Codemation Testing\n\nA workflow isn't done until it's testable: a way to run it on real inputs with the side effects guarded, plus assertions on the result. Three primitives do this, and you bake them in from the start:\n\n- **`TestTrigger`** — a fixture source that feeds items through the _same_ pipeline. The Tests tab runs one workflow per yielded item, with the test context set. It's a **second, separate trigger export** — the live trigger stays the only `.trigger(...)` on the chain.\n- **`IsTestRun`** — a per-item router on ports `\"true\"` / `\"false\"`. It's `true` when the run carries a test context (a TestTrigger or the Tests tab), `false` for every live/manual/cron/webhook activation. Branch the real write off it so a dry run never sends a real email or charges a card.\n- **`Assertion`** — records one or more pass/fail results per item (`score: 1` = pass, `0` = fail) as `TestAssertion` rows the Tests tab aggregates.\n\nThe pattern: **dry-run asserts, live performs.** Put the real side effect on the `false` branch and the `Assertion` on the `true` branch. See `workflow-dsl` for the surrounding chain.\n\n## The nodes — one-liners\n\n`IsTestRun` and `TestTrigger` take positional / options args; `Assertion` takes an options object. `IsTestRun` routes on ports, so branch it with `.when`, never a bare `.then`.\n\n```typescript\nimport { IsTestRun, Assertion, TestTrigger } from \"@codemation/core-nodes\";\n\ntype Draft = { reply: string };\n\n// Router — ports \"true\" (test run) / \"false\" (live run). Payload is unchanged.\nconst guard = new IsTestRun<Draft>(\"Is this a test run?\");\n\n// Assertion — one row per returned result; score 1 = pass, 0 = fail.\nconst check = new Assertion<Draft>({\n name: \"Reply quality\",\n assertions: (item) => [\n { name: \"non-empty reply\", score: item.json.reply.trim().length > 0 ? 1 : 0, actual: item.json.reply },\n ],\n});\n\n// TestTrigger — a separate fixture source; generateItems is an async generator yielding one item per case.\nconst fixtures = new TestTrigger<Draft>({\n name: \"Reply fixtures\",\n id: \"reply-fixtures\",\n async *generateItems() {\n yield { json: { reply: \"Thanks for your order!\" } };\n },\n caseLabel: (item) => item.json.reply, // human label in the Tests tab\n});\n```\n\n## One realistic complete example\n\nWebhook receives an order → `IsTestRun` splits dry-run from live → the `true` branch asserts the payload, the `false` branch performs the real notification. A companion `TestTrigger` is exported separately to feed fixtures through the same pipeline. Keep the fixture json shape identical to the live payload so the pipeline stays coherent. This folds in the former `istestrun` / `testtrigger-assertion` examples.\n\n```typescript\nimport {\n createWorkflowBuilder,\n WebhookTrigger,\n TestTrigger,\n IsTestRun,\n Assertion,\n HttpRequest,\n} from \"@codemation/core-nodes\";\n\ntype Order = { orderId: string; customerEmail: string; total: number };\n\n// Companion fixture source — a SEPARATE export, not a second .trigger(). Same json shape as the live payload.\nexport const orderFixtures = new TestTrigger<Order>({\n name: \"Order fixtures\",\n id: \"order-fixtures\",\n async *generateItems() {\n yield { json: { orderId: \"fixture-1\", customerEmail: \"a@example.com\", total: 42 } };\n yield { json: { orderId: \"fixture-2\", customerEmail: \"b@example.com\", total: 0 } };\n },\n caseLabel: (item) => item.json.orderId,\n});\n\nexport default createWorkflowBuilder({ id: \"wf.order-confirm\", name: \"Order confirmation (testable)\" })\n .trigger(new WebhookTrigger(\"Order placed\", { endpointKey: \"order-placed\", methods: [\"POST\"] }))\n // Guard the side effect: true = dry run (assert only), false = live run (real notification).\n .then(new IsTestRun<Order>(\"Is this a test run?\", \"is-test-run\"))\n .when({\n true: [\n new Assertion<Order>({\n name: \"Order payload\",\n id: \"assert-order-payload\",\n assertions: (item) => [\n { name: \"has email\", score: item.json.customerEmail.includes(\"@\") ? 1 : 0, actual: item.json.customerEmail },\n { name: \"positive total\", score: item.json.total > 0 ? 1 : 0, actual: item.json.total },\n ],\n }),\n ],\n false: [\n new HttpRequest<Order>(\"Send confirmation\", {\n method: \"POST\",\n url: \"https://api.example.com/notify/order-confirmation\",\n body: { kind: \"json\", data: { event: \"order.confirmed\", orderId: \"${item.json.orderId}\" } },\n id: \"send-confirmation\",\n }),\n ],\n })\n .build();\n```\n\n## Design principle: test on REAL data, not fabricated fixtures\n\nFor IO/trigger-bound workflows the right test source is **real data in the same shape the live trigger emits** — not something fabricated or pre-processed. The rule: pick the test source by trigger type.\n\n- **Mailbox / inbox workflow** — read from a **designated test folder or label** (e.g. `orders-test`). The owner drops ~5 real order emails there; the `TestTrigger` reads them via the integration's read capability. Fabricating email fixtures here is an **anti-pattern**: it proves the fabricated item, not the actual input the workflow will see.\n- **Webhook workflow** — replay real captured payloads (record one live hit, feed it back).\n- **Pure-logic / transform step with no external IO** — hard-coded fixtures are acceptable; there is nothing real to sample.\n\n**\"Test items raw, processing shared.\"** The `TestTrigger` must yield items in the same raw shape the live trigger emits — unprocessed, uncanonicalized. All parsing, OCR, recognition, and matching must live _downstream_ of the trigger node and therefore run in both the test path and the live path. If the test source pre-processes or returns an already-canonical shape, the test asserts nothing meaningful.\n\n**Place `IsTestRun` after the processing, not before it.** The correct topology is:\n\n```\ntrigger → [OCR / extraction / matching] → IsTestRun → {\n true (test): assert the PROCESSING OUTCOME\n false (live): all side-effects\n}\n```\n\nA workflow that places `IsTestRun` right after the trigger and forks before the extraction step is wrong in two ways: the test path never exercises the processing logic, and the only thing left to assert is the raw trigger payload — which proves nothing about whether the workflow actually works. Put `IsTestRun` as late as possible: after all processing, just before the first external write.\n\n**Assert the processing outcome, not the raw trigger payload.** The `Assertion` on the `true` branch must check what the processing steps produced — e.g. that an extraction recognised a customer name (non-empty) or pulled at least one line item (length > 0). Asserting that an email has a subject or that a webhook body is non-null proves only that the trigger fired, not that the workflow does anything useful.\n\n**`IsTestRun` gates only side-effects — and ALL of them.** Both the live and test paths must flow through the same downstream nodes. The `IsTestRun` branch guards external writes (email replies, label changes, ERP mutations) — it must never be used to skip data or processing steps. Gate every side-effect on the `false` branch together: a single gated label change while the reply or ERP write runs unconditionally defeats the purpose.\n\n**If the designated test folder/label was not provided in the build task** → call `report_flag({ kind: \"gap\" })` and build the rest of what you can. Never fabricate a label id or silently fall back to hard-coded fixtures for an IO-bound workflow.\n\nFor the concrete mechanism of reading from a Gmail label as a test source, see the `gmail` integration skill.\n\n## Gotchas\n\n- **One `.trigger()`, the live one.** A `TestTrigger` is a separate top-level `export const`, never a second `.trigger(...)`. The Tests tab discovers it; the live trigger stays in the chain.\n- **`IsTestRun` routes on ports.** Branch its `\"true\"`/`\"false\"` ports with `.when`, not a bare `.then`. Put the real write on `false`, the `Assertion` on `true`.\n- **Fixture shape == live payload shape.** If the fixture json doesn't match what the live trigger emits, the shared pipeline won't typecheck or behave the same.\n- **`score` is 1 or 0.** `Assertion` rows are pass (`1`) / fail (`0`); return an array so you can record several checks per item.\n- **Every node must be connected — including TestTrigger.** A node or TestTrigger that is constructed but\n never wired is a defect: normal nodes connect via `.then()`/`.when()`; a `TestTrigger` is \"wired\" by\n being a top-level `export const` (the Tests tab discovers exports, not orphaned `new TestTrigger(…)`\n calls). If you construct a node and don't connect it, the graph silently ignores it — it will never run.\n",
335
+ "code": "---\nname: testing\ndescription: Make a workflow safe to dry-run on real inputs — drive it from a TestTrigger fixture source, guard side effects with IsTestRun, and record pass/fail with Assertion. The dry-run path asserts while the live path performs the real write. Read this to make any side-effecting workflow testable before it goes live.\ncompatibility: Designed for Codemation workflows authored with @codemation/core-nodes.\ntags: testing, test, assertion, dry-run, istestrun, testtrigger\nuses: \"@codemation/core-nodes, @codemation/core\"\n---\n\n# Codemation Testing\n\nA workflow isn't done until it's testable: a way to run it on real inputs with the side effects guarded, plus assertions on the result. Three primitives do this, and you bake them in from the start:\n\n- **`TestTrigger`** — a fixture source that feeds items through the _same_ pipeline. The Tests tab runs one workflow per yielded item, with the test context set. It's a **second, separate trigger export** — the live trigger stays the only `.trigger(...)` on the chain.\n- **`IsTestRun`** — a per-item router on ports `\"true\"` / `\"false\"`. It's `true` when the run carries a test context (a TestTrigger or the Tests tab), `false` for every live/manual/schedule/webhook activation. Branch the real write off it so a dry run never sends a real email or charges a card.\n- **`Assertion`** — records one or more pass/fail results per item (`score: 1` = pass, `0` = fail) as `TestAssertion` rows the Tests tab aggregates.\n\nThe pattern: **dry-run asserts, live performs.** Put the real side effect on the `false` branch and the `Assertion` on the `true` branch. See `workflow-dsl` for the surrounding chain.\n\n## The nodes — one-liners\n\n`IsTestRun` and `TestTrigger` take positional / options args; `Assertion` takes an options object. `IsTestRun` routes on ports, so branch it with `.when`, never a bare `.then`.\n\n```typescript\nimport { IsTestRun, Assertion, TestTrigger } from \"@codemation/core-nodes\";\n\ntype Draft = { reply: string };\n\n// Router — ports \"true\" (test run) / \"false\" (live run). Payload is unchanged.\nconst guard = new IsTestRun<Draft>(\"Is this a test run?\");\n\n// Assertion — one row per returned result; score 1 = pass, 0 = fail.\nconst check = new Assertion<Draft>({\n name: \"Reply quality\",\n assertions: (item) => [\n { name: \"non-empty reply\", score: item.json.reply.trim().length > 0 ? 1 : 0, actual: item.json.reply },\n ],\n});\n\n// TestTrigger — a separate fixture source; generateItems is an async generator yielding one item per case.\nconst fixtures = new TestTrigger<Draft>({\n name: \"Reply fixtures\",\n id: \"reply-fixtures\",\n async *generateItems() {\n yield { json: { reply: \"Thanks for your order!\" } };\n },\n caseLabel: (item) => item.json.reply, // human label in the Tests tab\n});\n```\n\n## One realistic complete example\n\nWebhook receives an order → `IsTestRun` splits dry-run from live → the `true` branch asserts the payload, the `false` branch performs the real notification. A companion `TestTrigger` is exported separately to feed fixtures through the same pipeline. Keep the fixture json shape identical to the live payload so the pipeline stays coherent. This folds in the former `istestrun` / `testtrigger-assertion` examples.\n\n```typescript\nimport {\n createWorkflowBuilder,\n WebhookTrigger,\n TestTrigger,\n IsTestRun,\n Assertion,\n HttpRequest,\n} from \"@codemation/core-nodes\";\n\ntype Order = { orderId: string; customerEmail: string; total: number };\n\n// Companion fixture source — a SEPARATE export, not a second .trigger(). Same json shape as the live payload.\nexport const orderFixtures = new TestTrigger<Order>({\n name: \"Order fixtures\",\n id: \"order-fixtures\",\n async *generateItems() {\n yield { json: { orderId: \"fixture-1\", customerEmail: \"a@example.com\", total: 42 } };\n yield { json: { orderId: \"fixture-2\", customerEmail: \"b@example.com\", total: 0 } };\n },\n caseLabel: (item) => item.json.orderId,\n});\n\nexport default createWorkflowBuilder({ id: \"wf.order-confirm\", name: \"Order confirmation (testable)\" })\n .trigger(new WebhookTrigger(\"Order placed\", { endpointKey: \"order-placed\", methods: [\"POST\"] }))\n // Guard the side effect: true = dry run (assert only), false = live run (real notification).\n .then(new IsTestRun<Order>(\"Is this a test run?\", \"is-test-run\"))\n .when({\n true: [\n new Assertion<Order>({\n name: \"Order payload\",\n id: \"assert-order-payload\",\n assertions: (item) => [\n { name: \"has email\", score: item.json.customerEmail.includes(\"@\") ? 1 : 0, actual: item.json.customerEmail },\n { name: \"positive total\", score: item.json.total > 0 ? 1 : 0, actual: item.json.total },\n ],\n }),\n ],\n false: [\n new HttpRequest<Order>(\"Send confirmation\", {\n method: \"POST\",\n url: \"https://api.example.com/notify/order-confirmation\",\n body: { kind: \"json\", data: { event: \"order.confirmed\", orderId: \"${item.json.orderId}\" } },\n id: \"send-confirmation\",\n }),\n ],\n })\n .build();\n```\n\n## Design principle: test on REAL data, not fabricated fixtures\n\nFor IO/trigger-bound workflows the right test source is **real data in the same shape the live trigger emits** — not something fabricated or pre-processed. The rule: pick the test source by trigger type.\n\n- **Mailbox / inbox workflow** — read from a **designated test folder or label** (e.g. `orders-test`). The owner drops ~5 real order emails there; the `TestTrigger` reads them via the integration's read capability. Fabricating email fixtures here is an **anti-pattern**: it proves the fabricated item, not the actual input the workflow will see.\n- **Webhook workflow** — replay real captured payloads (record one live hit, feed it back).\n- **Pure-logic / transform step with no external IO** — hard-coded fixtures are acceptable; there is nothing real to sample.\n\n**\"Test items raw, processing shared.\"** The `TestTrigger` must yield items in the same raw shape the live trigger emits — unprocessed, uncanonicalized. All parsing, OCR, recognition, and matching must live _downstream_ of the trigger node and therefore run in both the test path and the live path. If the test source pre-processes or returns an already-canonical shape, the test asserts nothing meaningful.\n\n**Place `IsTestRun` after the processing, not before it.** The correct topology is:\n\n```\ntrigger → [OCR / extraction / matching] → IsTestRun → {\n true (test): assert the PROCESSING OUTCOME\n false (live): all side-effects\n}\n```\n\nA workflow that places `IsTestRun` right after the trigger and forks before the extraction step is wrong in two ways: the test path never exercises the processing logic, and the only thing left to assert is the raw trigger payload — which proves nothing about whether the workflow actually works. Put `IsTestRun` as late as possible: after all processing, just before the first external write.\n\n**Assert the processing outcome, not the raw trigger payload.** The `Assertion` on the `true` branch must check what the processing steps produced — e.g. that an extraction recognised a customer name (non-empty) or pulled at least one line item (length > 0). Asserting that an email has a subject or that a webhook body is non-null proves only that the trigger fired, not that the workflow does anything useful.\n\n**`IsTestRun` gates only side-effects — and ALL of them.** Both the live and test paths must flow through the same downstream nodes. The `IsTestRun` branch guards external writes (email replies, label changes, ERP mutations) — it must never be used to skip data or processing steps. Gate every side-effect on the `false` branch together: a single gated label change while the reply or ERP write runs unconditionally defeats the purpose.\n\n**If the designated test folder/label was not provided in the build task** → call `report_flag({ kind: \"gap\" })` and build the rest of what you can. Never fabricate a label id or silently fall back to hard-coded fixtures for an IO-bound workflow.\n\nFor the concrete mechanism of reading from a Gmail label as a test source, see the `gmail` integration skill.\n\n## Gotchas\n\n- **One `.trigger()`, the live one.** A `TestTrigger` is a separate top-level `export const`, never a second `.trigger(...)`. The Tests tab discovers it; the live trigger stays in the chain.\n- **`IsTestRun` routes on ports.** Branch its `\"true\"`/`\"false\"` ports with `.when`, not a bare `.then`. Put the real write on `false`, the `Assertion` on `true`.\n- **Fixture shape == live payload shape.** If the fixture json doesn't match what the live trigger emits, the shared pipeline won't typecheck or behave the same.\n- **`score` is 1 or 0.** `Assertion` rows are pass (`1`) / fail (`0`); return an array so you can record several checks per item.\n- **Every node must be connected — including TestTrigger.** A node or TestTrigger that is constructed but\n never wired is a defect: normal nodes connect via `.then()`/`.when()`; a `TestTrigger` is \"wired\" by\n being a top-level `export const` (the Tests tab discovers exports, not orphaned `new TestTrigger(…)`\n calls). If you construct a node and don't connect it, the graph silently ignores it — it will never run.\n",
336
336
  "format": "doc",
337
337
  "verified": false,
338
338
  "l2": "- Codemation Testing\n - The nodes — one-liners\n - One realistic complete example\n - Design principle: test on REAL data, not fabricated fixtures\n - Gotchas"
@@ -347,10 +347,10 @@
347
347
  ],
348
348
  "sourcePath": "skills/builder/workflow-dsl/SKILL.md",
349
349
  "dependencies": {
350
- "@codemation/core-nodes": "0.12.0",
351
- "@codemation/core": "0.14.0"
350
+ "@codemation/core-nodes": "0.14.0",
351
+ "@codemation/core": "0.15.0"
352
352
  },
353
- "code": "---\nname: workflow-dsl\ndescription: Write a Codemation workflow definition with the fluent builder (createWorkflowBuilder().trigger().then().when().build()) and the built-in node classes from @codemation/core-nodes. Use this first whenever authoring or editing any workflow under src/workflows.\ncompatibility: Designed for Codemation apps and plugins that author workflows.\ntags: workflow, dsl, authoring\nuses: \"@codemation/core-nodes, @codemation/core\"\n---\n\n# Codemation Workflow DSL\n\nA workflow is a `default`-exported definition: a **trigger** that emits items, then **node steps** that transform them. Items are `{ json, binary? }`; each node runs **per item** in a batch. You build with the fluent chain `createWorkflowBuilder({ id, name }).trigger(...).then(...).build()` and the node classes below — that is the whole surface. The per-item type flows through `.then(...)` via generics, so annotate input/output types where inference needs help.\n\n> **Passing data between nodes?** Read the **`execution-context`** skill FIRST — it is the one rule for\n> reading `item.json`, carrying data through an OCR step, and the canonical-JSON pattern. Every workflow\n> that passes structured data between nodes depends on it.\n\n> **This is the general spine.** For a specific integration (`gmail`, `odoo`, …) or topic (reaching an\n> external system → `connect-external-systems`; OCR → `document-ai`), that skill is **authoritative** —\n> follow it over any general pattern here. This skill teaches triggers, flow control, and the builder API.\n\n**Discipline:** author straight from the one-liners in this file, then run `verify_workflow` and fix only what it flags. Don't open other skills speculatively — open one only when `verify_workflow` names a node or concept this file doesn't cover.\n\n## Name and describe every node for a non-technical reader\n\nTwo authoring rules make the workflow legible to the business user who reviews it on the canvas:\n\n- **Title = the business action, not the node type.** The first constructor argument is a human label.\n Write what the step _does_ — `\"Receive new order\"`, `\"Tag order as received\"`, `\"Route high-value orders\"` —\n never `\"Callback\"`, `\"If\"`, `\"MapData\"`. The reader should understand the flow from titles alone.\n- **`description` = one plain-language sentence** in the node's **options** (alongside `id`). It is a\n first-class option on EVERY node — `{ id: \"...\", description: \"...\" }` — and renders in the node\n sidebar. One non-technical sentence: what this step does and why. **Every node gets one — no\n exceptions.** That means the **trigger** AND every step, including the ones easy to forget under a\n big build: the trigger / test-trigger, document / PDF-scan (OCR) nodes, AI-agent nodes, and any\n send / notify / test-notification step. A node with no `description` is a build defect — **before\n you call the build done, re-read the whole workflow and confirm every single node has both a\n friendly title and a `description`.**\n\n```ts no-check\nnew Callback(\"Tag order as received\", markReceived, {\n id: \"mark-received\",\n description: \"Flags the order as received so the warehouse can pick it.\",\n});\n```\n\n`description` lives in the same options object as `id`. For bare-id nodes (`If`, `Filter`, `Split`,\n`Switch`, `Merge`, `Wait`, `CronTrigger`, …) the last argument accepts either a bare `\"id\"` string OR\nan options object `{ id, description }` — pass the object so you can describe the node.\n\n## A minimal complete workflow\n\n```typescript\nimport { createWorkflowBuilder, WebhookTrigger, Callback } from \"@codemation/core-nodes\";\nimport type { Items } from \"@codemation/core\";\n\ntype Order = { orderId: string; total: number };\n\nexport default createWorkflowBuilder({ id: \"wf.order-webhook\", name: \"Order webhook\" })\n .trigger(\n new WebhookTrigger(\n \"Receive new order\",\n { endpointKey: \"orders\", methods: [\"POST\"] },\n undefined, // default handler\n { id: \"receive-order\", description: \"Accepts an order POSTed by the shop and starts the flow.\" },\n ),\n )\n .then(\n new Callback(\n \"Tag order as received\",\n (items: Items<Order>) => {\n return items.map((item) => ({ json: { ...item.json, received: true } }));\n },\n { id: \"log-order\", description: \"Marks each incoming order as received.\" },\n ),\n )\n .build();\n```\n\n## The fluent DSL\n\n- `createWorkflowBuilder({ id, name })` → builder. `id` must be a `WorkflowId` (a `wf.*` string is fine).\n- `.trigger(triggerConfig)` → cursor. Exactly one trigger, first.\n- `.then(nodeConfig)` → appends a step; the chain's item type becomes that node's output.\n- `.when(true, [steps]).when(false, [steps])` (boolean form) → branches off an `If` node's `\"true\"`/`\"false\"` ports. **It auto-merges when you continue the chain:** a following `.then(...)` / `.humanApproval(...)` connects EVERY branch tail into the next node (the same fan-in the object form produces). End it with `.build()` when the branch is the end of the flow.\n- The **object form** `.when({ true: [...], false: [...] })` is the inline alternative → it merges both branch tails into one chainable cursor with a precise merged item type. Reach for it when you want the continuation typed exactly. (Keep only one branch alive — e.g. \"drop non-orders, continue orders\"? Use `.route({ true: (b) => b.then(...), false: () => undefined })`: a factory returning `undefined` is excluded from the merge.)\n- `.build()` → validates node ids (throws `WorkflowDefinitionError` on empty/duplicate) and returns the definition.\n\n**Filter vs If — choose this up front, it's the most common rewrite.** To KEEP ONLY the items that match a condition and DROP the rest — \"only real orders\", \"only paid invoices\", \"skip anything that isn't an order\" — use a **`Filter`** node (see \"Per-item transforms\" below): it's ONE node, the dropped items simply don't continue, and the chain stays a single trunk. Use **`If` + `.when`** ONLY when the true and false items do DIFFERENT downstream work (true → send for approval, false → auto-approve). Rule of thumb: if one side would just drop the item and the other continues, that is a `Filter`, not an `If`. Reaching for `If` to express \"keep only X\" and then continuing the trunk is the classic shape that has to be rewritten into a `Filter` — start with the `Filter`.\n\nBranch off an `If` with `.when`. Each step's predicate/mapper sees an `Item<T>` — read `item.json`:\n\n```typescript\nimport { createWorkflowBuilder, ManualTrigger, If, Callback } from \"@codemation/core-nodes\";\nimport type { Item, Items } from \"@codemation/core\";\n\ntype Invoice = { amount: number };\n\nexport default createWorkflowBuilder({ id: \"wf.approve\", name: \"Approve invoice\" })\n .trigger(new ManualTrigger<Invoice>(\"Start\", undefined, { id: \"start\", description: \"Run this by hand.\" }))\n .then(\n new If<Invoice>(\"Does it need approval?\", (item: Item<Invoice>) => item.json.amount > 1000, {\n id: \"needs-approval\",\n description: \"Sends invoices over €1,000 for manual sign-off.\",\n }),\n )\n .when(true, [\n new Callback(\"Send for approval\", (items: Items<Invoice>) => items, {\n id: \"send-approval\",\n description: \"Routes the invoice to a reviewer.\",\n }),\n ])\n .when(false, [\n new Callback(\"Auto-approve\", (items: Items<Invoice>) => items, {\n id: \"auto-approve\",\n description: \"Approves small invoices automatically.\",\n }),\n ])\n .build();\n```\n\n**Continuing AFTER a branch** (the common shape: branch, then a shared tail like a human-approval gate).\nJust keep chaining — a `.then(...)` / `.humanApproval(...)` after the boolean `.when(...).when(...)` chain\nauto-merges EVERY branch tail into the next node. Here a HITL gate sits on the merged trunk:\n\n```typescript\nimport { createWorkflowBuilder, ManualTrigger, If, MapData, inboxApproval } from \"@codemation/core-nodes\";\nimport type { Item } from \"@codemation/core\";\n\ntype Order = { amount: number; priority: string };\n\nexport default createWorkflowBuilder({ id: \"wf.order\", name: \"Order intake\" })\n .trigger(new ManualTrigger<Order>(\"Start\", undefined, { id: \"start\", description: \"Run this by hand.\" }))\n .then(\n new If<Order>(\"Is this a big order?\", (item: Item<Order>) => item.json.amount > 1000, {\n id: \"big-gate\",\n description: \"Splits large orders from normal ones.\",\n }),\n )\n // Boolean branches auto-merge on continuation: BOTH tails feed the next step.\n // (The object form `.when({ true: [...], false: [...] })` is the inline alternative\n // when you want the merged item type typed exactly.)\n .when(true, [\n new MapData<Order, Order>(\"Mark as high priority\", (item: Item<Order>) => ({ ...item.json, priority: \"high\" }), {\n id: \"mark-high\",\n description: \"Tags big orders as high priority.\",\n }),\n ])\n .when(false, [\n new MapData<Order, Order>(\n \"Mark as normal priority\",\n (item: Item<Order>) => ({ ...item.json, priority: \"normal\" }),\n {\n id: \"mark-normal\",\n description: \"Tags regular orders as normal priority.\",\n },\n ),\n ])\n // The merged trunk keeps flowing — `.humanApproval(node, config)` is sugar for `.then(node.create(config))`.\n // Pass every config field (Zod defaults don't relax the TS type).\n .humanApproval(inboxApproval, {\n title: \"Approve order?\",\n body: \"Review before processing.\",\n priority: \"normal\",\n timeout: \"48h\",\n onTimeout: \"halt\",\n })\n .build();\n```\n\n## Core nodes — one-liners\n\nImport every node from `@codemation/core-nodes`. Each construction below is complete.\n\n### Triggers (exactly one, first)\n\n```typescript\nimport { CronTrigger, ManualTrigger, WebhookTrigger, TestTrigger } from \"@codemation/core-nodes\";\n\n// Cron — timezone defaults to UTC; always set it. Emits { firedAt, scheduledFor }.\nconst daily = new CronTrigger(\n \"Every weekday morning\",\n { schedule: \"0 9 * * *\", timezone: \"Europe/Amsterdam\" },\n { id: \"daily\", description: \"Runs at 9am Amsterdam time on a schedule.\" },\n);\n\n// Manual — for runs you start by hand; optionally seed default items (object/array in arg 2),\n// then put { id, description } in the trailing slot (use arg 2 = undefined when seeding nothing).\nconst manual = new ManualTrigger<{ q: string }>(\"Start by hand\", [{ q: \"hello\" }], {\n id: \"manual\",\n description: \"Lets an operator run the flow on demand.\",\n});\n\n// Webhook — endpointKey is the URL path segment; methods are the accepted verbs.\n// Pass a Zod schema as `inputSchema` to validate the request body. The default handler is fine,\n// so options go in the trailing (4th) argument.\nconst hook = new WebhookTrigger(\"Receive inbound request\", { endpointKey: \"inbound\", methods: [\"POST\"] }, undefined, {\n id: \"hook\",\n description: \"Starts the flow when an external system calls in.\",\n});\n\n// Test — one item per test case; drives the Tests tab. generateItems is an async generator.\n// TestTrigger takes `description` directly in its single options object.\nconst testCases = new TestTrigger<{ subject: string }>({\n name: \"Replay sample mails\",\n description: \"Feeds saved sample emails through the flow for the Tests tab.\",\n async *generateItems() {\n yield { json: { subject: \"RFQ #14\" } };\n },\n});\n```\n\n### Flow control\n\n`If` and `IsTestRun` route on ports `\"true\"` / `\"false\"` — branch them with `.when`, never a bare `.then`. `Switch` exposes one port per case key.\n\n```typescript\nimport { If, Switch, Merge, NoOp, IsTestRun } from \"@codemation/core-nodes\";\nimport type { Item } from \"@codemation/core\";\n\ntype Doc = { kind: string; urgent: boolean };\n\nconst gate = new If<Doc>(\"Is it urgent?\", (item: Item<Doc>) => item.json.urgent, {\n id: \"urgent-gate\",\n description: \"Sends urgent documents down the fast path.\",\n});\n\nconst route = new Switch<Doc>(\n \"Route by document type\",\n {\n cases: [\"invoice\", \"order\"],\n defaultCase: \"other\",\n resolveCaseKey: (item: Item<Doc>) => item.json.kind,\n },\n { id: \"route-by-kind\", description: \"Sends each document to the handler for its type.\" },\n);\n\nconst merge = new Merge<Doc>(\n \"Rejoin branches\",\n { mode: \"passThrough\" },\n {\n id: \"rejoin\",\n description: \"Brings the branches back into one stream.\",\n },\n);\nconst passthrough = new NoOp<Doc>(\"Checkpoint\", { id: \"marker\", description: \"A no-op marker step.\" });\nconst testGate = new IsTestRun<Doc>(\"Is this a test run?\", {\n id: \"test-gate\",\n description: \"Routes test runs away from live side effects.\",\n}); // ports \"true\"/\"false\"\n```\n\n### Data shaping\n\nPredicates and mappers receive an `Item<T>` (read `item.json`); `Aggregate`/`Split` see the whole batch / one item.\n\n```typescript\nimport { MapData, Filter, Aggregate, Split } from \"@codemation/core-nodes\";\nimport type { Item, Items } from \"@codemation/core\";\n\ntype Row = { price: number; tags: string[] };\n\nconst enrich = new MapData<Row, Row & { withVat: number }>(\n \"Add VAT to each line\",\n (item: Item<Row>) => ({ ...item.json, withVat: item.json.price * 1.21 }),\n { id: \"add-vat\", description: \"Adds 21% VAT to every row.\" },\n);\n\nconst keep = new Filter<Row>(\"Keep cheap items only\", (item: Item<Row>) => item.json.price < 100, {\n id: \"cheap-only\",\n description: \"Drops anything priced €100 or more.\",\n});\n\nconst total = new Aggregate<Row, { sum: number }>(\n \"Total the prices\",\n (items: Items<Row>) => ({ sum: items.reduce((acc, i) => acc + i.json.price, 0) }),\n { id: \"sum-prices\", description: \"Adds all the row prices into one total.\" },\n);\n\nconst fanout = new Split<Row, string>(\"Split into one item per tag\", (item: Item<Row>) => item.json.tags, {\n id: \"per-tag\",\n description: \"Emits one item for each tag on a row.\",\n});\n```\n\n### Fan-out & fan-in (important)\n\nA node **fans out** when its `execute` returns an **array**: the engine emits **one item per element**, so\nevery later `.then(...)` runs **once per element** (zero elements ⇒ zero items downstream — branch on\n\"no matches\"). This is automatic — you do **not** wrap the result yourself. Because of this, a fan-out\nnode must declare its output type as the **per-item element**, not the array: `.then()` carries that\nelement type forward as the next node's `item.json`. (e.g. a search node that returns rows declares its\noutput as one row; downstream `item.json` is one row.)\n\n- **`Split<T, E>`** — fan out an array that lives **inside** one item's json (`item.json.tags`), vs a node\n whose `execute` returns the array directly. Both produce one item per element.\n- **`Aggregate<T, O>`** — **fan in**: collapse the whole batch back to a single item (sum, group, build one payload).\n- `.then(...)` does **not** auto-unwrap — the chain's item type is whatever the upstream node _declares_ as its\n per-item output. If a node declares the array as its output, nothing chains cleanly after it; declare the element.\n\n### HTTP\n\n`HttpRequest` calls an external API. For `body.kind: \"json\"` pass the object in `body.data` (encoded once). Declare auth with `credentialSlot` — that auto-wires the bindable slot (see Credentials). Output is response metadata (`{ status, ok, json, text, ... }`), not the input item.\n\n```typescript\nimport { HttpRequest } from \"@codemation/core-nodes\";\n\nconst post = new HttpRequest(\"Create the record in the API\", {\n method: \"POST\",\n url: \"https://api.example.com/v1/records\",\n headers: { \"content-type\": \"application/json\" },\n body: { kind: \"json\", data: { name: \"ACME\" } },\n credentialSlot: \"example\",\n responseFormat: \"json\",\n id: \"create-record\",\n description: \"Sends the record to the external API.\",\n});\n```\n\n### Glue\n\n`Callback` is the escape hatch for arbitrary per-batch logic; `Wait` pauses.\n\n```typescript\nimport { Callback, Wait } from \"@codemation/core-nodes\";\nimport type { Items } from \"@codemation/core\";\n\ntype Msg = { text: string };\n\nconst shout = new Callback<Msg>(\n \"Shout the message\",\n (items: Items<Msg>) => items.map((i) => ({ json: { text: i.json.text.toUpperCase() } })),\n { id: \"uppercase\", description: \"Uppercases every message.\" },\n);\n\nconst pause = new Wait(\"Cool down before retry\", 5000, {\n id: \"cooldown\",\n description: \"Waits 5 seconds before continuing.\",\n});\n```\n\n## Binary payloads in a Callback\n\n`item.binary[key]` is **metadata** (`BinaryAttachment`: id, storageKey, mimeType, size — there is **no `.data`**). Bytes come only from `ctx.binary` methods. **Never return `{ binary: { key: { data: Buffer } } }` — use `ctx.binary.attach` + `ctx.binary.withAttachment`.**\n\nReading bytes: `ctx.binary.getJson<T>(attachment)` (also `getBytes` / `getText`) — pass the `BinaryAttachment` object, not the key string.\n\nAttaching bytes to an item: `ctx.binary.attach({ name, body, mimeType })` returns a `BinaryAttachment`; then `ctx.binary.withAttachment(item, name, att)` slots it onto the item.\n\n```typescript\nimport { Callback } from \"@codemation/core-nodes\";\nimport type { Items, NodeExecutionContext, BinaryAttachment } from \"@codemation/core\";\n\ntype Doc = { filename: string };\ntype Parsed = { wordCount: number };\n\n// Reading bytes from an upstream binary slot:\nconst readBinary = new Callback<Doc, Parsed>(\n \"Count words in the file\",\n async (items: Items<Doc>, ctx: NodeExecutionContext) => {\n return await Promise.all(\n items.map(async (item) => {\n const att: BinaryAttachment | undefined = item.binary?.[\"data\"];\n if (!att) return { json: { wordCount: 0 } };\n const text = await ctx.binary.getText(att); // also: getBytes / getJson<T>\n return { json: { wordCount: text.split(/\\s+/).length } };\n }),\n );\n },\n { id: \"count-words\", description: \"Counts the words in each attached file.\" },\n);\n\n// Attaching bytes to an item:\nconst attachBinary = new Callback<Doc, Doc>(\n \"Attach the processed file\",\n async (items: Items<Doc>, ctx: NodeExecutionContext) => {\n return await Promise.all(\n items.map(async (item) => {\n const body = Buffer.from(\"processed content\");\n const att = await ctx.binary.attach({ name: \"result\", body, mimeType: \"text/plain\" });\n return ctx.binary.withAttachment(item, \"result\", att);\n // item.binary[\"result\"] is now a BinaryAttachment — pass it to downstream nodes\n }),\n );\n },\n { id: \"attach-binary\", description: \"Saves the processed file onto the item.\" },\n);\n```\n\n## Credentials in a workflow (builder)\n\nA binding is keyed by `(workflowId, nodeId, slotKey)`, so the node that **declares** the slot is the node the credential binds to. You declare + use; the concierge binds (separate skill).\n\n**Default — `HttpRequest.credentialSlot`.** The string declares a bindable slot and authenticates the request; nothing else needed:\n\n```typescript\nimport { HttpRequest } from \"@codemation/core-nodes\";\n\nconst fetch = new HttpRequest(\"List contacts from the ERP\", {\n url: \"https://erp.example.com/api/contacts\",\n credentialSlot: \"erp\", // bindable slot \"erp\" on this node\n responseFormat: \"json\",\n id: \"list-contacts\",\n description: \"Reads the contact list from the ERP.\",\n});\n```\n\n**Never `fetch` in a `Callback`.** Raw `fetch`/HTTP/JSON-RPC inside a `Callback` is FORBIDDEN and the\nbuild gate rejects it. To call an external system, in priority order: a specialized integration node\n(e.g. the `odoo` / `gmail` nodes — search for it), an MCP-backed agent, `defineRestNode` (`rest-node`\nskill), or the `HttpRequest` node above. These declare the credential slot for you. `ctx.getCredential`\nin a `Callback` is only for non-HTTP uses (handing a token to a node SDK, signing a payload) — not for\nmaking the request yourself. See `connect-external-systems`.\n\nA plain `Callback` slot is **not** discoverable for binding. For credentialed custom logic that must surface a bindable slot, author a `defineNode` node with a `credentials` map (see the custom-node-development skill) — that node both declares and resolves the slot.\n\n## Error handling\n\nPass a `retryPolicy` to retry transient failures; an unhandled `throw` fails the run. `verify_workflow` is the build gate — write, verify, fix what it names, repeat.\n\n```typescript\nimport { HttpRequest } from \"@codemation/core-nodes\";\nimport type { RetryPolicySpec } from \"@codemation/core\";\n\nconst retry: RetryPolicySpec = { kind: \"fixed\", maxAttempts: 3, delayMs: 1000 };\n\nconst flaky = new HttpRequest(\n \"Ping the flaky API\",\n { url: \"https://api.example.com/ping\", id: \"flaky-api\", description: \"Pings an API that sometimes fails.\" },\n retry,\n);\n```\n\n## One realistic complete example\n\nCron → authenticated GET via the **HttpRequest node** (never `fetch`) → fan the response array into one\nitem per lead → shape each. To WRITE each lead back, add another `HttpRequest` (or a `defineRestNode`) —\nnot a hand-rolled `fetch`. Each node has a stable explicit `id`, a business-action title, and a\nplain-language `description`.\n\n```typescript\nimport { createWorkflowBuilder, CronTrigger, HttpRequest, Split, MapData } from \"@codemation/core-nodes\";\nimport type { HttpRequestOutputJson } from \"@codemation/core-nodes\";\nimport type { Item } from \"@codemation/core\";\n\ntype Lead = { id: string; email: string };\n\nexport default createWorkflowBuilder({ id: \"wf.sync-leads\", name: \"Sync leads nightly\" })\n .trigger(\n new CronTrigger(\n \"Every night at 2am\",\n { schedule: \"0 2 * * *\", timezone: \"Europe/Amsterdam\" },\n { id: \"nightly\", description: \"Runs the lead sync once a night.\" },\n ),\n )\n // Authenticated request via the HttpRequest node — it declares the \"crm\" credential slot for you.\n .then(\n new HttpRequest(\"Fetch new leads from the CRM\", {\n url: \"https://crm.example.com/api/leads\",\n credentialSlot: \"crm\",\n responseFormat: \"json\",\n id: \"fetch-leads\",\n description: \"Pulls the latest leads from the CRM.\",\n }),\n )\n // Fan out the response array into one item per lead.\n .then(\n new Split<HttpRequestOutputJson, Lead>(\n \"Split into one item per lead\",\n (item: Item<HttpRequestOutputJson>) => (item.json.json as Lead[]) ?? [],\n { id: \"per-lead\", description: \"Processes each lead on its own.\" },\n ),\n )\n // Shape each lead. To upsert it, add another HttpRequest (method: \"PUT\") or a defineRestNode — not fetch.\n .then(\n new MapData<Lead, Lead & { queued: boolean }>(\n \"Mark each lead as queued\",\n (item: Item<Lead>) => ({ ...item.json, queued: true }),\n { id: \"queue-upsert\", description: \"Flags each lead ready to write back.\" },\n ),\n )\n .build();\n```\n\n## Gotchas\n\n- **Unique, stable node ids.** Set an explicit `id` on every node. Without one, the engine slugifies the `name`, so two same-named nodes collide and `.build()` throws — and renaming a label re-keys credential bindings.\n- **Friendly title + `description` on every node.** Title = the business action a non-tech reader recognizes; `description` = one plain sentence in the node's options (`{ id, description }`). Both render in the canvas / sidebar.\n- **Per-item execution.** Nodes run once per item in the batch. `MapData`/`Filter`/`If`/`Split` see one `Item<T>`; `Callback`/`Aggregate` see the whole `Items<T>`.\n- **`If` / `IsTestRun` route on ports.** Branch their `\"true\"`/`\"false\"` ports with `.when`, not a bare `.then`. `Switch` ports are its case keys.\n- **Binary payloads never go on `item.json`.** Store bytes via `ctx.binary.attach(...)` and pass the `Item.binary` slot through — never base64 on JSON.\n- **Boolean `.when` branches auto-merge on continuation.** `.when(true,[...]).when(false,[...]).then(next)` connects EVERY branch tail into `next` (the same fan-in the object form gives). End on `.build()` when the branch is the flow's end. The continued cursor is typed as the pre-branch item type — use the **object form** `.when({ true:[...], false:[...] })` when you need the merged type typed exactly, or `.route({...})` to drop a branch (return `undefined`).\n- **Branches must rejoin compatible types.** When two `.when` branches feed a shared downstream node, both must emit the type that node accepts.\n- **Talking to an external system?** Follow the routing hierarchy in `connect-external-systems` — prefer a specialized node, MCP server, or `defineRestNode` over a hand-rolled `fetch` in a `Callback`.\n",
353
+ "code": "---\nname: workflow-dsl\ndescription: Write a Codemation workflow definition with the fluent builder (createWorkflowBuilder().trigger().then().when().build()) and the built-in node classes from @codemation/core-nodes. Use this first whenever authoring or editing any workflow under src/workflows.\ncompatibility: Designed for Codemation apps and plugins that author workflows.\ntags: workflow, dsl, authoring\nuses: \"@codemation/core-nodes, @codemation/core\"\n---\n\n# Codemation Workflow DSL\n\nA workflow is a `default`-exported definition: a **trigger** that emits items, then **node steps** that transform them. Items are `{ json, binary? }`; each node runs **per item** in a batch. You build with the fluent chain `createWorkflowBuilder({ id, name }).trigger(...).then(...).build()` and the node classes below — that is the whole surface. The per-item type flows through `.then(...)` via generics, so annotate input/output types where inference needs help.\n\n> **Passing data between nodes?** Read the **`execution-context`** skill FIRST — it is the one rule for\n> reading `item.json`, carrying data through an OCR step, and the canonical-JSON pattern. Every workflow\n> that passes structured data between nodes depends on it.\n\n> **This is the general spine.** For a specific integration (`gmail`, `odoo`, …) or topic (reaching an\n> external system → `connect-external-systems`; OCR → `document-ai`), that skill is **authoritative** —\n> follow it over any general pattern here. This skill teaches triggers, flow control, and the builder API.\n\n**Discipline:** author straight from the one-liners in this file, then run `verify_workflow` and fix only what it flags. Don't open other skills speculatively — open one only when `verify_workflow` names a node or concept this file doesn't cover.\n\n## Name and describe every node for a non-technical reader\n\nTwo authoring rules make the workflow legible to the business user who reviews it on the canvas:\n\n- **Title = the business action, not the node type.** The first constructor argument is a human label.\n Write what the step _does_ — `\"Receive new order\"`, `\"Tag order as received\"`, `\"Route high-value orders\"` —\n never `\"Callback\"`, `\"If\"`, `\"MapData\"`. The reader should understand the flow from titles alone.\n- **`description` = one plain-language sentence** in the node's **options** (alongside `id`). It is a\n first-class option on EVERY node — `{ id: \"...\", description: \"...\" }` — and renders in the node\n sidebar. One non-technical sentence: what this step does and why. **Every node gets one — no\n exceptions.** That means the **trigger** AND every step, including the ones easy to forget under a\n big build: the trigger / test-trigger, document / PDF-scan (OCR) nodes, AI-agent nodes, and any\n send / notify / test-notification step. A node with no `description` is a build defect — **before\n you call the build done, re-read the whole workflow and confirm every single node has both a\n friendly title and a `description`.**\n\n```ts no-check\nnew Callback(\"Tag order as received\", markReceived, {\n id: \"mark-received\",\n description: \"Flags the order as received so the warehouse can pick it.\",\n});\n```\n\n`description` lives in the same options object as `id`. For bare-id nodes (`If`, `Filter`, `Split`,\n`Switch`, `Merge`, `Wait`, …) the last argument accepts either a bare `\"id\"` string OR\nan options object `{ id, description }` — pass the object so you can describe the node.\n\n## A minimal complete workflow\n\n```typescript\nimport { createWorkflowBuilder, WebhookTrigger, Callback } from \"@codemation/core-nodes\";\nimport type { Items } from \"@codemation/core\";\n\ntype Order = { orderId: string; total: number };\n\nexport default createWorkflowBuilder({ id: \"wf.order-webhook\", name: \"Order webhook\" })\n .trigger(\n new WebhookTrigger(\n \"Receive new order\",\n { endpointKey: \"orders\", methods: [\"POST\"] },\n undefined, // default handler\n { id: \"receive-order\", description: \"Accepts an order POSTed by the shop and starts the flow.\" },\n ),\n )\n .then(\n new Callback(\n \"Tag order as received\",\n (items: Items<Order>) => {\n return items.map((item) => ({ json: { ...item.json, received: true } }));\n },\n { id: \"log-order\", description: \"Marks each incoming order as received.\" },\n ),\n )\n .build();\n```\n\n## The fluent DSL\n\n- `createWorkflowBuilder({ id, name })` → builder. `id` must be a `WorkflowId` (a `wf.*` string is fine).\n- `.trigger(triggerConfig)` → cursor. Exactly one trigger, first.\n- `.then(nodeConfig)` → appends a step; the chain's item type becomes that node's output.\n- `.when(true, [steps]).when(false, [steps])` (boolean form) → branches off an `If` node's `\"true\"`/`\"false\"` ports. **It auto-merges when you continue the chain:** a following `.then(...)` / `.humanApproval(...)` connects EVERY branch tail into the next node (the same fan-in the object form produces). End it with `.build()` when the branch is the end of the flow.\n- The **object form** `.when({ true: [...], false: [...] })` is the inline alternative → it merges both branch tails into one chainable cursor with a precise merged item type. Reach for it when you want the continuation typed exactly. (Keep only one branch alive — e.g. \"drop non-orders, continue orders\"? Use `.route({ true: (b) => b.then(...), false: () => undefined })`: a factory returning `undefined` is excluded from the merge.)\n- `.build()` → validates node ids (throws `WorkflowDefinitionError` on empty/duplicate) and returns the definition.\n\n**Filter vs If — choose this up front, it's the most common rewrite.** To KEEP ONLY the items that match a condition and DROP the rest — \"only real orders\", \"only paid invoices\", \"skip anything that isn't an order\" — use a **`Filter`** node (see \"Per-item transforms\" below): it's ONE node, the dropped items simply don't continue, and the chain stays a single trunk. Use **`If` + `.when`** ONLY when the true and false items do DIFFERENT downstream work (true → send for approval, false → auto-approve). Rule of thumb: if one side would just drop the item and the other continues, that is a `Filter`, not an `If`. Reaching for `If` to express \"keep only X\" and then continuing the trunk is the classic shape that has to be rewritten into a `Filter` — start with the `Filter`.\n\nBranch off an `If` with `.when`. Each step's predicate/mapper sees an `Item<T>` — read `item.json`:\n\n```typescript\nimport { createWorkflowBuilder, ManualTrigger, If, Callback } from \"@codemation/core-nodes\";\nimport type { Item, Items } from \"@codemation/core\";\n\ntype Invoice = { amount: number };\n\nexport default createWorkflowBuilder({ id: \"wf.approve\", name: \"Approve invoice\" })\n .trigger(new ManualTrigger<Invoice>(\"Start\", undefined, { id: \"start\", description: \"Run this by hand.\" }))\n .then(\n new If<Invoice>(\"Does it need approval?\", (item: Item<Invoice>) => item.json.amount > 1000, {\n id: \"needs-approval\",\n description: \"Sends invoices over €1,000 for manual sign-off.\",\n }),\n )\n .when(true, [\n new Callback(\"Send for approval\", (items: Items<Invoice>) => items, {\n id: \"send-approval\",\n description: \"Routes the invoice to a reviewer.\",\n }),\n ])\n .when(false, [\n new Callback(\"Auto-approve\", (items: Items<Invoice>) => items, {\n id: \"auto-approve\",\n description: \"Approves small invoices automatically.\",\n }),\n ])\n .build();\n```\n\n**Continuing AFTER a branch** (the common shape: branch, then a shared tail like a human-approval gate).\nJust keep chaining — a `.then(...)` / `.humanApproval(...)` after the boolean `.when(...).when(...)` chain\nauto-merges EVERY branch tail into the next node. Here a HITL gate sits on the merged trunk:\n\n```typescript\nimport { createWorkflowBuilder, ManualTrigger, If, MapData, inboxApproval } from \"@codemation/core-nodes\";\nimport type { Item } from \"@codemation/core\";\n\ntype Order = { amount: number; priority: string };\n\nexport default createWorkflowBuilder({ id: \"wf.order\", name: \"Order intake\" })\n .trigger(new ManualTrigger<Order>(\"Start\", undefined, { id: \"start\", description: \"Run this by hand.\" }))\n .then(\n new If<Order>(\"Is this a big order?\", (item: Item<Order>) => item.json.amount > 1000, {\n id: \"big-gate\",\n description: \"Splits large orders from normal ones.\",\n }),\n )\n // Boolean branches auto-merge on continuation: BOTH tails feed the next step.\n // (The object form `.when({ true: [...], false: [...] })` is the inline alternative\n // when you want the merged item type typed exactly.)\n .when(true, [\n new MapData<Order, Order>(\"Mark as high priority\", (item: Item<Order>) => ({ ...item.json, priority: \"high\" }), {\n id: \"mark-high\",\n description: \"Tags big orders as high priority.\",\n }),\n ])\n .when(false, [\n new MapData<Order, Order>(\n \"Mark as normal priority\",\n (item: Item<Order>) => ({ ...item.json, priority: \"normal\" }),\n {\n id: \"mark-normal\",\n description: \"Tags regular orders as normal priority.\",\n },\n ),\n ])\n // The merged trunk keeps flowing — `.humanApproval(node, config)` is sugar for `.then(node.create(config))`.\n // Pass every config field (Zod defaults don't relax the TS type).\n .humanApproval(inboxApproval, {\n title: \"Approve order?\",\n body: \"Review before processing.\",\n priority: \"normal\",\n timeout: \"48h\",\n onTimeout: \"halt\",\n })\n .build();\n```\n\n## Core nodes — one-liners\n\nImport every node from `@codemation/core-nodes`. Each construction below is complete.\n\n### Triggers (exactly one, first)\n\n```typescript\nimport { ManualTrigger, WebhookTrigger, TestTrigger, schedulePollingTrigger } from \"@codemation/core-nodes\";\n\n// Schedule — fires one tick item on every poll cycle (defaults to every 60s). Emits { firedAt, tick }.\n// It's a defined node: build it with `.create(config, label, { id, description })`, not `new`.\nconst onSchedule = schedulePollingTrigger.create({}, \"Run on schedule\", {\n id: \"daily\",\n description: \"Runs the flow on a recurring schedule.\",\n});\n\n// Manual — for runs you start by hand; optionally seed default items (object/array in arg 2),\n// then put { id, description } in the trailing slot (use arg 2 = undefined when seeding nothing).\nconst manual = new ManualTrigger<{ q: string }>(\"Start by hand\", [{ q: \"hello\" }], {\n id: \"manual\",\n description: \"Lets an operator run the flow on demand.\",\n});\n\n// Webhook — endpointKey is the URL path segment; methods are the accepted verbs.\n// Pass a Zod schema as `inputSchema` to validate the request body. The default handler is fine,\n// so options go in the trailing (4th) argument.\nconst hook = new WebhookTrigger(\"Receive inbound request\", { endpointKey: \"inbound\", methods: [\"POST\"] }, undefined, {\n id: \"hook\",\n description: \"Starts the flow when an external system calls in.\",\n});\n\n// Test — one item per test case; drives the Tests tab. generateItems is an async generator.\n// TestTrigger takes `description` directly in its single options object.\nconst testCases = new TestTrigger<{ subject: string }>({\n name: \"Replay sample mails\",\n description: \"Feeds saved sample emails through the flow for the Tests tab.\",\n async *generateItems() {\n yield { json: { subject: \"RFQ #14\" } };\n },\n});\n```\n\n### Flow control\n\n`If` and `IsTestRun` route on ports `\"true\"` / `\"false\"` — branch them with `.when`, never a bare `.then`. `Switch` exposes one port per case key.\n\n```typescript\nimport { If, Switch, Merge, NoOp, IsTestRun } from \"@codemation/core-nodes\";\nimport type { Item } from \"@codemation/core\";\n\ntype Doc = { kind: string; urgent: boolean };\n\nconst gate = new If<Doc>(\"Is it urgent?\", (item: Item<Doc>) => item.json.urgent, {\n id: \"urgent-gate\",\n description: \"Sends urgent documents down the fast path.\",\n});\n\nconst route = new Switch<Doc>(\n \"Route by document type\",\n {\n cases: [\"invoice\", \"order\"],\n defaultCase: \"other\",\n resolveCaseKey: (item: Item<Doc>) => item.json.kind,\n },\n { id: \"route-by-kind\", description: \"Sends each document to the handler for its type.\" },\n);\n\nconst merge = new Merge<Doc>(\n \"Rejoin branches\",\n { mode: \"passThrough\" },\n {\n id: \"rejoin\",\n description: \"Brings the branches back into one stream.\",\n },\n);\nconst passthrough = new NoOp<Doc>(\"Checkpoint\", { id: \"marker\", description: \"A no-op marker step.\" });\nconst testGate = new IsTestRun<Doc>(\"Is this a test run?\", {\n id: \"test-gate\",\n description: \"Routes test runs away from live side effects.\",\n}); // ports \"true\"/\"false\"\n```\n\n### Data shaping\n\nPredicates and mappers receive an `Item<T>` (read `item.json`); `Aggregate`/`Split` see the whole batch / one item.\n\n```typescript\nimport { MapData, Filter, Aggregate, Split } from \"@codemation/core-nodes\";\nimport type { Item, Items } from \"@codemation/core\";\n\ntype Row = { price: number; tags: string[] };\n\nconst enrich = new MapData<Row, Row & { withVat: number }>(\n \"Add VAT to each line\",\n (item: Item<Row>) => ({ ...item.json, withVat: item.json.price * 1.21 }),\n { id: \"add-vat\", description: \"Adds 21% VAT to every row.\" },\n);\n\nconst keep = new Filter<Row>(\"Keep cheap items only\", (item: Item<Row>) => item.json.price < 100, {\n id: \"cheap-only\",\n description: \"Drops anything priced €100 or more.\",\n});\n\nconst total = new Aggregate<Row, { sum: number }>(\n \"Total the prices\",\n (items: Items<Row>) => ({ sum: items.reduce((acc, i) => acc + i.json.price, 0) }),\n { id: \"sum-prices\", description: \"Adds all the row prices into one total.\" },\n);\n\nconst fanout = new Split<Row, string>(\"Split into one item per tag\", (item: Item<Row>) => item.json.tags, {\n id: \"per-tag\",\n description: \"Emits one item for each tag on a row.\",\n});\n```\n\n### Fan-out & fan-in (important)\n\nA node **fans out** when its `execute` returns an **array**: the engine emits **one item per element**, so\nevery later `.then(...)` runs **once per element** (zero elements ⇒ zero items downstream — branch on\n\"no matches\"). This is automatic — you do **not** wrap the result yourself. Because of this, a fan-out\nnode must declare its output type as the **per-item element**, not the array: `.then()` carries that\nelement type forward as the next node's `item.json`. (e.g. a search node that returns rows declares its\noutput as one row; downstream `item.json` is one row.)\n\n- **`Split<T, E>`** — fan out an array that lives **inside** one item's json (`item.json.tags`), vs a node\n whose `execute` returns the array directly. Both produce one item per element.\n- **`Aggregate<T, O>`** — **fan in**: collapse the whole batch back to a single item (sum, group, build one payload).\n- `.then(...)` does **not** auto-unwrap — the chain's item type is whatever the upstream node _declares_ as its\n per-item output. If a node declares the array as its output, nothing chains cleanly after it; declare the element.\n\n### HTTP\n\n`HttpRequest` calls an external API. For `body.kind: \"json\"` pass the object in `body.data` (encoded once). Declare auth with `credentialSlot` — that auto-wires the bindable slot (see Credentials). Output is response metadata (`{ status, ok, json, text, ... }`), not the input item.\n\n```typescript\nimport { HttpRequest } from \"@codemation/core-nodes\";\n\nconst post = new HttpRequest(\"Create the record in the API\", {\n method: \"POST\",\n url: \"https://api.example.com/v1/records\",\n headers: { \"content-type\": \"application/json\" },\n body: { kind: \"json\", data: { name: \"ACME\" } },\n credentialSlot: \"example\",\n responseFormat: \"json\",\n id: \"create-record\",\n description: \"Sends the record to the external API.\",\n});\n```\n\n### Glue\n\n`Callback` is the escape hatch for arbitrary per-batch logic; `Wait` pauses.\n\n```typescript\nimport { Callback, Wait } from \"@codemation/core-nodes\";\nimport type { Items } from \"@codemation/core\";\n\ntype Msg = { text: string };\n\nconst shout = new Callback<Msg>(\n \"Shout the message\",\n (items: Items<Msg>) => items.map((i) => ({ json: { text: i.json.text.toUpperCase() } })),\n { id: \"uppercase\", description: \"Uppercases every message.\" },\n);\n\nconst pause = new Wait(\"Cool down before retry\", 5000, {\n id: \"cooldown\",\n description: \"Waits 5 seconds before continuing.\",\n});\n```\n\n## Binary payloads in a Callback\n\n`item.binary[key]` is **metadata** (`BinaryAttachment`: id, storageKey, mimeType, size — there is **no `.data`**). Bytes come only from `ctx.binary` methods. **Never return `{ binary: { key: { data: Buffer } } }` — use `ctx.binary.attach` + `ctx.binary.withAttachment`.**\n\nReading bytes: `ctx.binary.getJson<T>(attachment)` (also `getBytes` / `getText`) — pass the `BinaryAttachment` object, not the key string.\n\nAttaching bytes to an item: `ctx.binary.attach({ name, body, mimeType })` returns a `BinaryAttachment`; then `ctx.binary.withAttachment(item, name, att)` slots it onto the item.\n\n```typescript\nimport { Callback } from \"@codemation/core-nodes\";\nimport type { Items, NodeExecutionContext, BinaryAttachment } from \"@codemation/core\";\n\ntype Doc = { filename: string };\ntype Parsed = { wordCount: number };\n\n// Reading bytes from an upstream binary slot:\nconst readBinary = new Callback<Doc, Parsed>(\n \"Count words in the file\",\n async (items: Items<Doc>, ctx: NodeExecutionContext) => {\n return await Promise.all(\n items.map(async (item) => {\n const att: BinaryAttachment | undefined = item.binary?.[\"data\"];\n if (!att) return { json: { wordCount: 0 } };\n const text = await ctx.binary.getText(att); // also: getBytes / getJson<T>\n return { json: { wordCount: text.split(/\\s+/).length } };\n }),\n );\n },\n { id: \"count-words\", description: \"Counts the words in each attached file.\" },\n);\n\n// Attaching bytes to an item:\nconst attachBinary = new Callback<Doc, Doc>(\n \"Attach the processed file\",\n async (items: Items<Doc>, ctx: NodeExecutionContext) => {\n return await Promise.all(\n items.map(async (item) => {\n const body = Buffer.from(\"processed content\");\n const att = await ctx.binary.attach({ name: \"result\", body, mimeType: \"text/plain\" });\n return ctx.binary.withAttachment(item, \"result\", att);\n // item.binary[\"result\"] is now a BinaryAttachment — pass it to downstream nodes\n }),\n );\n },\n { id: \"attach-binary\", description: \"Saves the processed file onto the item.\" },\n);\n```\n\n## Credentials in a workflow (builder)\n\nA binding is keyed by `(workflowId, nodeId, slotKey)`, so the node that **declares** the slot is the node the credential binds to. You declare + use; the concierge binds (separate skill).\n\n**Default — `HttpRequest.credentialSlot`.** The string declares a bindable slot and authenticates the request; nothing else needed:\n\n```typescript\nimport { HttpRequest } from \"@codemation/core-nodes\";\n\nconst fetch = new HttpRequest(\"List contacts from the ERP\", {\n url: \"https://erp.example.com/api/contacts\",\n credentialSlot: \"erp\", // bindable slot \"erp\" on this node\n responseFormat: \"json\",\n id: \"list-contacts\",\n description: \"Reads the contact list from the ERP.\",\n});\n```\n\n**Never `fetch` in a `Callback`.** Raw `fetch`/HTTP/JSON-RPC inside a `Callback` is FORBIDDEN and the\nbuild gate rejects it. To call an external system, in priority order: a specialized integration node\n(e.g. the `odoo` / `gmail` nodes — search for it), an MCP-backed agent, `defineRestNode` (`rest-node`\nskill), or the `HttpRequest` node above. These declare the credential slot for you. `ctx.getCredential`\nin a `Callback` is only for non-HTTP uses (handing a token to a node SDK, signing a payload) — not for\nmaking the request yourself. See `connect-external-systems`.\n\nA plain `Callback` slot is **not** discoverable for binding. For credentialed custom logic that must surface a bindable slot, author a `defineNode` node with a `credentials` map (see the custom-node-development skill) — that node both declares and resolves the slot.\n\n## Error handling\n\nPass a `retryPolicy` to retry transient failures; an unhandled `throw` fails the run. `verify_workflow` is the build gate — write, verify, fix what it names, repeat.\n\n```typescript\nimport { HttpRequest } from \"@codemation/core-nodes\";\nimport type { RetryPolicySpec } from \"@codemation/core\";\n\nconst retry: RetryPolicySpec = { kind: \"fixed\", maxAttempts: 3, delayMs: 1000 };\n\nconst flaky = new HttpRequest(\n \"Ping the flaky API\",\n { url: \"https://api.example.com/ping\", id: \"flaky-api\", description: \"Pings an API that sometimes fails.\" },\n retry,\n);\n```\n\n## One realistic complete example\n\nSchedule → authenticated GET via the **HttpRequest node** (never `fetch`) → fan the response array into one\nitem per lead → shape each. To WRITE each lead back, add another `HttpRequest` (or a `defineRestNode`) —\nnot a hand-rolled `fetch`. Each node has a stable explicit `id`, a business-action title, and a\nplain-language `description`.\n\n```typescript\nimport { createWorkflowBuilder, schedulePollingTrigger, HttpRequest, Split, MapData } from \"@codemation/core-nodes\";\nimport type { HttpRequestOutputJson } from \"@codemation/core-nodes\";\nimport type { Item } from \"@codemation/core\";\n\ntype Lead = { id: string; email: string };\n\nexport default createWorkflowBuilder({ id: \"wf.sync-leads\", name: \"Sync leads on a schedule\" })\n .trigger(\n schedulePollingTrigger.create({}, \"Run on schedule\", {\n id: \"nightly\",\n description: \"Runs the lead sync on a recurring schedule.\",\n }),\n )\n // Authenticated request via the HttpRequest node — it declares the \"crm\" credential slot for you.\n .then(\n new HttpRequest(\"Fetch new leads from the CRM\", {\n url: \"https://crm.example.com/api/leads\",\n credentialSlot: \"crm\",\n responseFormat: \"json\",\n id: \"fetch-leads\",\n description: \"Pulls the latest leads from the CRM.\",\n }),\n )\n // Fan out the response array into one item per lead.\n .then(\n new Split<HttpRequestOutputJson, Lead>(\n \"Split into one item per lead\",\n (item: Item<HttpRequestOutputJson>) => (item.json.json as Lead[]) ?? [],\n { id: \"per-lead\", description: \"Processes each lead on its own.\" },\n ),\n )\n // Shape each lead. To upsert it, add another HttpRequest (method: \"PUT\") or a defineRestNode — not fetch.\n .then(\n new MapData<Lead, Lead & { queued: boolean }>(\n \"Mark each lead as queued\",\n (item: Item<Lead>) => ({ ...item.json, queued: true }),\n { id: \"queue-upsert\", description: \"Flags each lead ready to write back.\" },\n ),\n )\n .build();\n```\n\n## Gotchas\n\n- **Unique, stable node ids.** Set an explicit `id` on every node. Without one, the engine slugifies the `name`, so two same-named nodes collide and `.build()` throws — and renaming a label re-keys credential bindings.\n- **Friendly title + `description` on every node.** Title = the business action a non-tech reader recognizes; `description` = one plain sentence in the node's options (`{ id, description }`). Both render in the canvas / sidebar.\n- **Per-item execution.** Nodes run once per item in the batch. `MapData`/`Filter`/`If`/`Split` see one `Item<T>`; `Callback`/`Aggregate` see the whole `Items<T>`.\n- **`If` / `IsTestRun` route on ports.** Branch their `\"true\"`/`\"false\"` ports with `.when`, not a bare `.then`. `Switch` ports are its case keys.\n- **Binary payloads never go on `item.json`.** Store bytes via `ctx.binary.attach(...)` and pass the `Item.binary` slot through — never base64 on JSON.\n- **Boolean `.when` branches auto-merge on continuation.** `.when(true,[...]).when(false,[...]).then(next)` connects EVERY branch tail into `next` (the same fan-in the object form gives). End on `.build()` when the branch is the flow's end. The continued cursor is typed as the pre-branch item type — use the **object form** `.when({ true:[...], false:[...] })` when you need the merged type typed exactly, or `.route({...})` to drop a branch (return `undefined`).\n- **Branches must rejoin compatible types.** When two `.when` branches feed a shared downstream node, both must emit the type that node accepts.\n- **Talking to an external system?** Follow the routing hierarchy in `connect-external-systems` — prefer a specialized node, MCP server, or `defineRestNode` over a hand-rolled `fetch` in a `Callback`.\n",
354
354
  "format": "doc",
355
355
  "verified": false,
356
356
  "l2": "- Codemation Workflow DSL\n - Name and describe every node for a non-technical reader\n - A minimal complete workflow\n - The fluent DSL\n - Core nodes — one-liners\n - Triggers (exactly one, first)\n - Flow control\n - Data shaping\n - Fan-out & fan-in (important)\n - HTTP\n - Glue\n - Binary payloads in a Callback\n - Credentials in a workflow (builder)\n - Error handling\n - One realistic complete example\n - Gotchas"
@@ -373,7 +373,7 @@
373
373
  ],
374
374
  "sourcePath": "skills/builder/workspace-files/SKILL.md",
375
375
  "dependencies": {
376
- "@codemation/core-nodes-workspace-files": "0.3.0"
376
+ "@codemation/core-nodes-workspace-files": "0.3.1"
377
377
  },
378
378
  "code": "---\nname: workspace-files\ndescription: Reads and writes files in the platform's shared workspace pool from inside a running workflow — listWorkspaceFilesNode, readWorkspaceFileNode, writeWorkspaceFileNode. Use this whenever an automation needs a file the platform manages (a lookup table, a config sheet, a concierge-derived JSON) or produces a file back into the pool.\ncompatibility: Codemation core-nodes-workspace-files. Requires WORKSPACE_ID and BLOB_STORAGE_* env vars.\ntags: workspace, files, pool, binary, storage, read, write, list, csv, json, lookup\nuses: \"@codemation/core-nodes-workspace-files\"\n---\n\n# Codemation Workspace Files\n\n## The lifecycle — how an automation gets at a platform-managed file\n\nThe workspace **file pool** is shared storage (S3-backed) that the platform manages. A user does not\nhand a file to a workflow directly. The real flow is:\n\n1. A user **uploads a heavy file** (e.g. an Excel product catalogue) through the platform.\n2. The **concierge** has the coding agent **transform** it into a workflow-friendly shape (e.g. a flat\n JSON keyed by SKU) and **stores the result** in the pool under a known filename.\n3. A **running automation reads that stored file** by filename (or pinned id) via the nodes below — and\n does its work with the data.\n\nThe automation is **decoupled** from the upload: it reads whatever the current version of that filename\nis, every run. Two concrete use cases:\n\n- **Product database for lookups.** A nightly order workflow reads `products.json` from the pool to\n enrich each line item with price and description — no external API call, the sheet is the source.\n- **Leads router.** An inbound-lead webhook reads a `zipcode-regions.json` table from the pool, maps the\n lead's zipcode to a region, and routes it to the right regional manager.\n\nWorkflows can also **write** files back into the pool (`writeWorkspaceFileNode`) when a step produces a\nderived artifact other runs or the concierge should pick up — but **read is the headline pattern**.\n\n**Bytes always flow through `item.binary`**, never as base64 on `item.json`. Read nodes stream a file's\nbytes into a binary slot; you parse them in the next step. Use `workflow-dsl` for the\nsurrounding builder.\n\n## A complete read-and-use workflow\n\nThe concierge stored `products.json` in the pool. This webhook-triggered workflow reads it (latest-wins),\nparses the JSON straight off the binary slot with `ctx.binary.getJson`, and looks a SKU up.\n\n```typescript\nimport { createWorkflowBuilder, WebhookTrigger, Callback } from \"@codemation/core-nodes\";\nimport type { NodeExecutionContext } from \"@codemation/core\";\nimport { readWorkspaceFileNode } from \"@codemation/core-nodes-workspace-files\";\n\ntype FileMeta = {\n fileId: string;\n filename: string;\n contentType: string;\n size: number;\n lastModified: string;\n binarySlot: string;\n};\ntype Product = { sku: string; name: string; price: number };\n\nexport default createWorkflowBuilder({ id: \"wf.product-lookup\", name: \"Look up product from pool\" })\n .trigger(new WebhookTrigger(\"Order in\", { endpointKey: \"order\", methods: [\"POST\"] }))\n // Read the newest \"products.json\" — a fresher upload is picked up on the next run.\n .then(\n readWorkspaceFileNode.create(\n { filename: \"products.json\", binarySlot: \"data\" },\n \"Read product DB\",\n \"read-product-db\",\n ),\n )\n // Parse the bytes from the binary slot — never base64 on item.json.\n .then(\n new Callback<FileMeta, { count: number; first?: Product }>(\n \"Parse products\",\n async (items, ctx: NodeExecutionContext) => {\n const results: Array<{ json: { count: number; first?: Product } }> = [];\n for (const item of items) {\n const attachment = item.binary?.[\"data\"];\n if (!attachment) throw new Error('No binary at slot \"data\"');\n const products = await ctx.binary.getJson<Product[]>(attachment);\n results.push({ json: { count: products.length, first: products[0] } });\n }\n return results;\n },\n { id: \"parse-products\" },\n ),\n )\n .build();\n```\n\n## Resolution modes (read)\n\n| Mode | Config | Behaviour |\n| ------------------------- | --------------------------- | -------------------------------------------------------------------------------------------------------------- |\n| **latest-wins** (default) | `filename: \"products.json\"` | Reads the **newest** file with that name. Next upload of the same name is what the next run reads. |\n| **pinned fileId** | `fileId: \"abc123...\"` | Reads that exact, immutable version forever — a new upload never changes it. Takes precedence over `filename`. |\n\nUse **latest-wins** for \"always use the current sheet\". Use a **pinned fileId** for reproducible /\nauditable runs. Discover ids with `listWorkspaceFilesNode`.\n\n## Binary slot handoff\n\n`readWorkspaceFileNode` streams the file's bytes into `item.binary[binarySlot]` (default `\"data\"`) and\nemits this metadata on `item.json`:\n\n```text\n{\n fileId: string;\n filename: string;\n contentType: string;\n size: number; // bytes\n lastModified: string; // ISO 8601\n binarySlot: string; // e.g. \"data\"\n}\n```\n\nRead the bytes downstream via `ctx.binary.getJson<T>(attachment)` (bounded read + decode + parse, the\nclean default for JSON), `ctx.binary.getBytes(attachment)` (raw `Uint8Array`), or\n`ctx.binary.openReadStream(attachment)` for streaming. The bytes are never on `item.json`.\n\n## Node reference\n\nImport every node from `@codemation/core-nodes-workspace-files`.\n\n### `listWorkspaceFilesNode` — discover what's in the pool\n\n```typescript\nimport { listWorkspaceFilesNode } from \"@codemation/core-nodes-workspace-files\";\n\nconst list = listWorkspaceFilesNode.create(\n { filenameFilter: \"products\" }, // optional case-insensitive substring; omit to list all\n \"List product files\",\n \"list-product-files\",\n);\n```\n\nEmits one item per file (newest-first): `{ fileId, filename, contentType, size, lastModified }`. Useful\nto drive a fan-out (one item per file) or to resolve a fileId for pinned reads.\n\n### `readWorkspaceFileNode` — read a file's bytes\n\n```typescript\nimport { readWorkspaceFileNode } from \"@codemation/core-nodes-workspace-files\";\n\nconst read = readWorkspaceFileNode.create(\n {\n filename: \"products.json\", // latest-wins; or use fileId for a pinned version\n binarySlot: \"data\", // where the bytes land on item.binary — default \"data\"\n maxBytes: 5 * 1024 * 1024, // cap before streaming — default 100 MiB\n },\n \"Read products\",\n \"read-products\",\n);\n```\n\nEither `filename` or `fileId` must be set (`fileId` wins). Output: the metadata JSON above + bytes in\n`item.binary[binarySlot]`.\n\n### `writeWorkspaceFileNode` — produce a file back into the pool\n\n```typescript\nimport { writeWorkspaceFileNode } from \"@codemation/core-nodes-workspace-files\";\n\nconst write = writeWorkspaceFileNode.create(\n {\n filename: \"daily-summary.json\", // required — the name in the pool / concierge view\n binarySlot: \"data\", // slot holding the bytes to write — default \"data\"\n contentType: \"application/json\", // stamped on the stored object — default application/octet-stream\n },\n \"Write daily summary\",\n \"write-daily-summary\",\n);\n```\n\nReads bytes from `item.binary[binarySlot]` and writes them under a new fileId. Output:\n`{ fileId, filename, contentType, size, key, registered }`. In managed (CP-paired) mode it also registers\nthe file so the concierge can list/read it; until the CP register endpoint ships, `registered` is `false`\nwhile the bytes are always written.\n\n## Gotchas\n\n- **Bytes go through `item.binary`, never base64 on `item.json`.** Read nodes attach to a slot; write\n nodes read from a slot. Attach bytes with `ctx.binary` (or an upstream node) before writing.\n- **Read the derived file, not the raw upload.** Point workflows at the concierge-produced JSON/CSV in\n the pool — not at a user's raw PDF/Excel. The transform is the concierge's job.\n- **`WORKSPACE_ID` / `BLOB_STORAGE_*` must be set.** Outside a configured workspace (e.g. plain local dev\n without CP integration) the storage adapter is unavailable and the node throws — guard if a workflow\n may run in that mode.\n- **Stable node ids.** Set an explicit `nodeId`; the engine slugifies the label otherwise, so renaming\n re-keys the node.\n\n## Read next when needed\n\n- `workflow-dsl` — builder, triggers, flow control, the per-item contract.\n- `document-ai` — scan a file's bytes (e.g. read a PDF, then scan it).\n",
379
379
  "format": "doc",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@codemation/agent-skills",
3
- "version": "0.5.1",
3
+ "version": "0.5.2",
4
4
  "description": "Reusable agent skills for Codemation projects and plugin development.",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -28,6 +28,14 @@ Codemation is a workflow engine with a layered package structure. `@codemation/c
28
28
  | Calling an MCP server from a workflow | `mcp-capabilities` |
29
29
  | CLI commands / dev loop | `cli` |
30
30
 
31
+ ## Code conventions (enforced by ESLint — these will fail your PR)
32
+
33
+ - **No comments.** The `codemation/no-comments` rule blocks all human-written comments at error level. Name identifiers, narrow types, and write tests instead. Allowed exceptions: `// @ts-ignore`, `// @ts-expect-error`, and `/// <reference ...>` triple-slash directives.
34
+ - **No `console.log`** under `packages/*/src` — inject `LoggerFactory`/`Logger`.
35
+ - **One class per file.** The `codemation/single-class-per-file` rule enforces it. Split rather than add.
36
+ - **No `new PascalCase()`** outside composition-root files (`AppContainerFactory.ts`, `bootstrap/`). Everything goes through DI.
37
+ - **No `static` methods** on non-Factory/Builder/Registry classes.
38
+
31
39
  ## Read next when needed
32
40
 
33
41
  - Read `references/architecture-map.md` for package ownership and runtime-mode guidance.
@@ -19,7 +19,7 @@ GET /api/registry/capabilities?query=gmail
19
19
 
20
20
  Response contains objects with `{ kind, id, displayName, description, acceptedCredentialTypes }`. Use `id` in the workflow's `mcpServers` array. An empty `query` string returns all registered servers.
21
21
 
22
- For a full wired example — cron workflow + AIAgent + mcpServers — use your harness's example-discovery tool: `find_examples({ query: "AIAgent gmail mcpServers" })` or `find_examples({ query: "mcp server" })`.
22
+ For a full wired example — scheduled workflow + AIAgent + mcpServers — use your harness's example-discovery tool: `find_examples({ query: "AIAgent gmail mcpServers" })` or `find_examples({ query: "mcp server" })`.
23
23
 
24
24
  ## Non-managed: plugin-declared MCP servers
25
25
 
@@ -1,30 +1,10 @@
1
- /**
2
- * Reference: using an MCP server in a workflow agent node.
3
- *
4
- * Before writing this, call GET /api/registry/capabilities?query=<name> to confirm
5
- * the server id and credential type. Then list the server id under `mcpServers`.
6
- *
7
- * Cron / webhook workflows use createWorkflowBuilder({id, name}).trigger(new XxxTrigger(...))
8
- * and chain with .then(new SomeNodeConfig(...)). The fluent .map/.if/.agent helpers are
9
- * only available via workflow("id").manualTrigger(...). See workflow-dsl skill.
10
- *
11
- * `mcpServers` is a plain array of server ids. Each declared server surfaces a credential
12
- * slot on the materialized MCP connection node (same shape as ChatModel/Tool connection
13
- * nodes). The user binds a credential instance via the canvas credential dropdown before
14
- * activation — same flow as trigger credentials.
15
- */
16
-
17
- import { AIAgent, CronTrigger, createWorkflowBuilder } from "@codemation/core-nodes";
18
-
19
- // Example: cron-triggered agent that uses the Gmail MCP server.
20
- // The "gmail" id comes from the registry (acceptedCredentialTypes: ["oauth.google.gmail"]).
21
- // The user must have connected their Google account and bound the credential before this runs.
1
+ import { AIAgent, createWorkflowBuilder, schedulePollingTrigger } from "@codemation/core-nodes";
22
2
 
23
3
  export const summariseEmailsWorkflow = createWorkflowBuilder({
24
4
  id: "wf.summarise-emails",
25
5
  name: "Summarise unread emails",
26
6
  })
27
- .trigger(new CronTrigger("Weekdays at 09:00", { schedule: "0 9 * * 1-5", timezone: "UTC" }))
7
+ .trigger(schedulePollingTrigger.create({}, "Run on schedule", "summarise-schedule"))
28
8
  .then(
29
9
  new AIAgent({
30
10
  name: "Summarise",
@@ -47,7 +47,7 @@ Credential types come from `@codemation/core-nodes`: `bearerTokenCredentialType`
47
47
 
48
48
  ## A complete REST-backed workflow
49
49
 
50
- A generic "widgets API": list widgets nightly (API-key auth), fan the array into one item per widget,
50
+ A generic "widgets API": list widgets on a schedule (API-key auth), fan the array into one item per widget,
51
51
  then create a record back in the same API (Bearer auth). Each node is defined once, then used with
52
52
  `.create(config, label, nodeId)` — the `config` is `{}` here because the per-item input flows from the
53
53
  chain, not static config.
@@ -56,7 +56,7 @@ chain, not static config.
56
56
  import { z } from "zod";
57
57
  import {
58
58
  createWorkflowBuilder,
59
- CronTrigger,
59
+ schedulePollingTrigger,
60
60
  Split,
61
61
  defineRestNode,
62
62
  apiKeyCredentialType,
@@ -89,7 +89,7 @@ const createWidget = defineRestNode({
89
89
  });
90
90
 
91
91
  export default createWorkflowBuilder({ id: "wf.widgets-sync", name: "Sync widgets" })
92
- .trigger(new CronTrigger("Nightly", { schedule: "0 2 * * *", timezone: "Europe/Amsterdam" }))
92
+ .trigger(schedulePollingTrigger.create({}, "Run on schedule", "widgets-schedule"))
93
93
  .then(listWidgets.create({}, "List widgets", "list-widgets"))
94
94
  .then(
95
95
  new Split<{ widgets: { name: string; color: string }[] }, { name: string; color: string }>(
@@ -11,7 +11,7 @@ uses: "@codemation/core-nodes, @codemation/core"
11
11
  A workflow isn't done until it's testable: a way to run it on real inputs with the side effects guarded, plus assertions on the result. Three primitives do this, and you bake them in from the start:
12
12
 
13
13
  - **`TestTrigger`** — a fixture source that feeds items through the _same_ pipeline. The Tests tab runs one workflow per yielded item, with the test context set. It's a **second, separate trigger export** — the live trigger stays the only `.trigger(...)` on the chain.
14
- - **`IsTestRun`** — a per-item router on ports `"true"` / `"false"`. It's `true` when the run carries a test context (a TestTrigger or the Tests tab), `false` for every live/manual/cron/webhook activation. Branch the real write off it so a dry run never sends a real email or charges a card.
14
+ - **`IsTestRun`** — a per-item router on ports `"true"` / `"false"`. It's `true` when the run carries a test context (a TestTrigger or the Tests tab), `false` for every live/manual/schedule/webhook activation. Branch the real write off it so a dry run never sends a real email or charges a card.
15
15
  - **`Assertion`** — records one or more pass/fail results per item (`score: 1` = pass, `0` = fail) as `TestAssertion` rows the Tests tab aggregates.
16
16
 
17
17
  The pattern: **dry-run asserts, live performs.** Put the real side effect on the `false` branch and the `Assertion` on the `true` branch. See `workflow-dsl` for the surrounding chain.
@@ -44,7 +44,7 @@ new Callback("Tag order as received", markReceived, {
44
44
  ```
45
45
 
46
46
  `description` lives in the same options object as `id`. For bare-id nodes (`If`, `Filter`, `Split`,
47
- `Switch`, `Merge`, `Wait`, `CronTrigger`, …) the last argument accepts either a bare `"id"` string OR
47
+ `Switch`, `Merge`, `Wait`, …) the last argument accepts either a bare `"id"` string OR
48
48
  an options object `{ id, description }` — pass the object so you can describe the node.
49
49
 
50
50
  ## A minimal complete workflow
@@ -174,14 +174,14 @@ Import every node from `@codemation/core-nodes`. Each construction below is comp
174
174
  ### Triggers (exactly one, first)
175
175
 
176
176
  ```typescript
177
- import { CronTrigger, ManualTrigger, WebhookTrigger, TestTrigger } from "@codemation/core-nodes";
177
+ import { ManualTrigger, WebhookTrigger, TestTrigger, schedulePollingTrigger } from "@codemation/core-nodes";
178
178
 
179
- // Crontimezone defaults to UTC; always set it. Emits { firedAt, scheduledFor }.
180
- const daily = new CronTrigger(
181
- "Every weekday morning",
182
- { schedule: "0 9 * * *", timezone: "Europe/Amsterdam" },
183
- { id: "daily", description: "Runs at 9am Amsterdam time on a schedule." },
184
- );
179
+ // Schedulefires one tick item on every poll cycle (defaults to every 60s). Emits { firedAt, tick }.
180
+ // It's a defined node: build it with `.create(config, label, { id, description })`, not `new`.
181
+ const onSchedule = schedulePollingTrigger.create({}, "Run on schedule", {
182
+ id: "daily",
183
+ description: "Runs the flow on a recurring schedule.",
184
+ });
185
185
 
186
186
  // Manual — for runs you start by hand; optionally seed default items (object/array in arg 2),
187
187
  // then put { id, description } in the trailing slot (use arg 2 = undefined when seeding nothing).
@@ -432,25 +432,24 @@ const flaky = new HttpRequest(
432
432
 
433
433
  ## One realistic complete example
434
434
 
435
- Cron → authenticated GET via the **HttpRequest node** (never `fetch`) → fan the response array into one
435
+ Schedule → authenticated GET via the **HttpRequest node** (never `fetch`) → fan the response array into one
436
436
  item per lead → shape each. To WRITE each lead back, add another `HttpRequest` (or a `defineRestNode`) —
437
437
  not a hand-rolled `fetch`. Each node has a stable explicit `id`, a business-action title, and a
438
438
  plain-language `description`.
439
439
 
440
440
  ```typescript
441
- import { createWorkflowBuilder, CronTrigger, HttpRequest, Split, MapData } from "@codemation/core-nodes";
441
+ import { createWorkflowBuilder, schedulePollingTrigger, HttpRequest, Split, MapData } from "@codemation/core-nodes";
442
442
  import type { HttpRequestOutputJson } from "@codemation/core-nodes";
443
443
  import type { Item } from "@codemation/core";
444
444
 
445
445
  type Lead = { id: string; email: string };
446
446
 
447
- export default createWorkflowBuilder({ id: "wf.sync-leads", name: "Sync leads nightly" })
447
+ export default createWorkflowBuilder({ id: "wf.sync-leads", name: "Sync leads on a schedule" })
448
448
  .trigger(
449
- new CronTrigger(
450
- "Every night at 2am",
451
- { schedule: "0 2 * * *", timezone: "Europe/Amsterdam" },
452
- { id: "nightly", description: "Runs the lead sync once a night." },
453
- ),
449
+ schedulePollingTrigger.create({}, "Run on schedule", {
450
+ id: "nightly",
451
+ description: "Runs the lead sync on a recurring schedule.",
452
+ }),
454
453
  )
455
454
  // Authenticated request via the HttpRequest node — it declares the "crm" credential slot for you.
456
455
  .then(