@codemation/agent-skills 0.4.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/CHANGELOG.md +165 -0
  2. package/dist/metadata.json +358 -48
  3. package/package.json +3 -1
  4. package/skills/builder/ai-agent/SKILL.md +314 -0
  5. package/skills/builder/ai-agent/references/anti-patterns.md +24 -0
  6. package/skills/{codemation-cli → builder/cli}/SKILL.md +1 -8
  7. package/skills/builder/connect-external-systems/SKILL.md +191 -0
  8. package/skills/builder/credential-development/SKILL.md +86 -0
  9. package/skills/{codemation-credential-development → builder/credential-development}/references/credential-patterns.md +3 -3
  10. package/skills/builder/custom-node-development/SKILL.md +61 -0
  11. package/skills/builder/custom-node-development/references/credential-aware-nodes.md +52 -0
  12. package/skills/builder/custom-node-development/references/define-batch-node.md +54 -0
  13. package/skills/{codemation-custom-node-development → builder/custom-node-development}/references/define-node-per-item.md +14 -14
  14. package/skills/{codemation-custom-node-development → builder/custom-node-development}/references/node-patterns.md +33 -49
  15. package/skills/builder/document-ai/SKILL.md +167 -0
  16. package/skills/builder/execution-context/SKILL.md +436 -0
  17. package/skills/{codemation-framework-concepts → builder/framework-concepts}/SKILL.md +10 -18
  18. package/skills/builder/gmail/SKILL.md +327 -0
  19. package/skills/builder/human-in-the-loop/SKILL.md +82 -0
  20. package/skills/{codemation-mcp-capabilities → builder/mcp-capabilities}/SKILL.md +4 -11
  21. package/skills/{codemation-mcp-capabilities → builder/mcp-capabilities}/references/agent-with-mcp.ts +1 -1
  22. package/skills/builder/msgraph/SKILL.md +338 -0
  23. package/skills/builder/odoo/SKILL.md +498 -0
  24. package/skills/{codemation-plugin-development → builder/plugin-development}/SKILL.md +4 -7
  25. package/skills/{codemation-plugin-development → builder/plugin-development}/references/plugin-anatomy.md +36 -15
  26. package/skills/{codemation-plugin-development → builder/plugin-development}/references/plugin-structure.md +2 -2
  27. package/skills/builder/rest-node/SKILL.md +148 -0
  28. package/skills/builder/testing/SKILL.md +142 -0
  29. package/skills/builder/workflow-dsl/SKILL.md +493 -0
  30. package/skills/builder/workspace-files/SKILL.md +191 -0
  31. package/skills/concierge/credentials/SKILL.md +91 -0
  32. package/skills/concierge/intake-automation-playbook/SKILL.md +78 -0
  33. package/skills/concierge/scenario-invoice-to-accounting/SKILL.md +48 -0
  34. package/skills/concierge/scenario-procurement-intake/SKILL.md +58 -0
  35. package/skills/codemation-ai-agent-node/SKILL.md +0 -66
  36. package/skills/codemation-ai-agent-node/references/anti-patterns.md +0 -11
  37. package/skills/codemation-credential-development/SKILL.md +0 -57
  38. package/skills/codemation-custom-node-development/SKILL.md +0 -61
  39. package/skills/codemation-custom-node-development/references/credential-aware-nodes.md +0 -38
  40. package/skills/codemation-custom-node-development/references/define-batch-node.md +0 -38
  41. package/skills/codemation-document-scanner/SKILL.md +0 -136
  42. package/skills/codemation-workflow-dsl/SKILL.md +0 -78
  43. package/skills/codemation-workflow-dsl/references/builder-patterns.md +0 -120
  44. package/skills/codemation-workflow-dsl/references/complete-example.md +0 -263
  45. package/skills/codemation-workflow-dsl/references/workflow-testing.md +0 -194
  46. package/skills/codemation-workspace-files/SKILL.md +0 -142
  47. /package/skills/{codemation-cli → builder/cli}/references/command-map.md +0 -0
  48. /package/skills/{codemation-framework-concepts → builder/framework-concepts}/references/architecture-map.md +0 -0
@@ -1,64 +1,106 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
3
  "packageName": "@codemation/agent-skills",
4
- "packageVersion": "0.4.0",
4
+ "packageVersion": "0.5.1",
5
5
  "description": "Reusable agent skills for Codemation projects and plugin development.",
6
6
  "kind": "skills",
7
7
  "skills": [
8
8
  {
9
- "name": "codemation-ai-agent-node",
10
- "description": "AIAgent constructor, message shape, managed and BYOK chatModel configs, outputSchema, mcpServers. Read before writing any workflow step that calls an LLM.",
9
+ "name": "ai-agent",
10
+ "description": "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).",
11
11
  "tags": [
12
12
  "agent",
13
13
  "llm",
14
- "ai"
14
+ "ai",
15
+ "aiagent",
16
+ "classification",
17
+ "extraction"
15
18
  ],
16
- "sourcePath": "skills/codemation-ai-agent-node/SKILL.md",
19
+ "sourcePath": "skills/builder/ai-agent/SKILL.md",
17
20
  "dependencies": {
18
- "@codemation/core-nodes": "0.10.0"
21
+ "@codemation/core-nodes": "0.12.0",
22
+ "@codemation/core": "0.14.0"
19
23
  },
20
- "code": "---\nname: codemation-ai-agent-node\ndescription: AIAgent constructor, message shape, managed and BYOK chatModel configs, outputSchema, mcpServers. Read before writing any workflow step that calls an LLM.\ncompatibility: Codemation core-nodes. Requires @codemation/core-nodes import.\ntags: agent, llm, ai\nuses: \"@codemation/core-nodes\"\n---\n\n# Codemation AI Agent Node\n\n## Mental model\n\n`AIAgent` is the single building block for any LLM step in a workflow. It receives items, runs a chat completion per item using the configured model and messages, and emits `{ output: string }` (or a parsed object when `outputSchema` is set) on its `main` port. The `chatModel` field determines whether the run consumes Codemation-managed quota (no credential needed) or a BYOK key the operator supplies. Every AIAgent emits exactly one output item per input item — it never fans out or filters.\n\n## When to use / when NOT\n\nUse `AIAgent` when a workflow step needs an LLM call: classification, extraction, summarisation, drafting, or decision.\nUse a plain `Callback` instead when the logic is deterministic code — no LLM needed.\nUse `mcpServers` (see `codemation-mcp-capabilities`) when the agent needs tool access to external services.\nRead `codemation-workflow-dsl` for the surrounding workflow structure.\n\n## Quickstart\n\n```ts\nimport { AIAgent, CodemationChatModelConfig } from \"@codemation/core-nodes\";\n\nnew AIAgent({\n name: \"Classify email\",\n messages: [\n { role: \"system\", content: \"Classify the email as spam or not-spam.\" },\n { role: \"user\", content: (args) => args.item.json.body as string },\n ],\n chatModel: new CodemationChatModelConfig(\"Claude Haiku\", \"anthropic/claude-haiku-4-5-20251001\"),\n});\n```\n\nFor full patterns — BYOK (`OpenAIChatModelConfig`), `outputSchema`, tools, multi-step pipelines, and gmail classification — use your harness's example-discovery tool: `find_examples({ query: \"AIAgent\" })`.\n\n## Decision branches & gotchas\n\n**Managed mode (default — no API key needed):** use `CodemationChatModelConfig(label, modelId)`. In managed mode the LLM broker **auto-authenticates via the workspace HMAC pairing** — no API key, no credential slot, no user setup required. This is the correct default for all managed-mode workflows. Do NOT tell managed users to \"get an API key\" — the broker handles authentication transparently.\n\n```ts\nchatModel: new CodemationChatModelConfig(\"Claude Haiku\", \"anthropic/claude-haiku-4-5-20251001\");\n// No credential slot created. Discover live model ids:\n// GET <CONTROL_PLANE_URL>/api/llm/managed-models\n```\n\n**BYOK (self-hosted / non-managed only):** use `OpenAIChatModelConfig(label, modelId, slotKey)` — it creates a credential slot the operator must bind with an API key. Only use this in self-hosted deployments where no managed broker is available.\n\n**Messages:** `content` is a plain string or a function `(args: { item, itemIndex, items, ctx }) => string`. Put instructions in the `system` message, per-item data in the `user` message. Use `\"assistant\"` role only for few-shot examples.\n\n**Structured output:** add `outputSchema: z.object({...})` to validate and parse the response. Without it, `item.json.output` is always a plain string.\n\n**Stable node id:** if the node has a credential binding (BYOK), set an explicit `id:` on the constructor. Without it the id derives from the `name` label — renaming the label orphans the binding. See `codemation-workflow-dsl` for the full id-stability rule.\n\n**Downstream access:** the next node sees `item.json.output` as the agent's text response. Cast it via a typed `Callback<{ output: string }>`.\n\n## Anti-patterns\n\n- Do not tell managed users to get an API key — use `CodemationChatModelConfig`; the broker authenticates automatically.\n- Do not use `OpenAIChatModelConfig` in managed mode — it creates an unnecessary credential slot and will prompt the user to bind a key they don't need.\n- Do not use `AIAgent` for deterministic logic; use `Callback` instead (cheaper, faster, no LLM billing).\n- Do not attempt to return multiple items from a single `AIAgent` step — it emits exactly one output per input.\n\nSee `references/anti-patterns.md` for version-specific gotchas (managed model id churn, chatModel string shorthand trap).\n"
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
+ "format": "doc",
26
+ "verified": false,
27
+ "l2": "- Codemation AI Agent Node\n - The node — one-liner\n - chatModel — managed is the default\n - Structured output and the downstream shape\n - Tools and MCP servers\n - (a) Node-backed tools — `AgentToolFactory.asTool` (preferred for Codemation integrations)\n - (b) Inline callable tools — `callableTool`\n - (c) MCP servers — `mcpServers`\n - One realistic complete example\n - Choosing the model\n - Gotchas"
21
28
  },
22
29
  {
23
- "name": "codemation-cli",
30
+ "name": "cli",
24
31
  "description": "Guides Codemation CLI work for consumer apps and framework-author development. Use when the user asks about `codemation dev`, `build`, `serve web`, `serve worker`, `user create`, `user list`, `--consumer-root`, `.codemation/output`, or consumer versus framework-author mode.",
25
32
  "tags": [
26
33
  "cli",
27
34
  "dev"
28
35
  ],
29
- "sourcePath": "skills/codemation-cli/SKILL.md",
36
+ "sourcePath": "skills/builder/cli/SKILL.md",
30
37
  "dependencies": {},
31
- "code": "---\nname: codemation-cli\ndescription: Guides Codemation CLI work for consumer apps and framework-author development. Use when the user asks about `codemation dev`, `build`, `serve web`, `serve worker`, `user create`, `user list`, `--consumer-root`, `.codemation/output`, or consumer versus framework-author mode.\ncompatibility: Designed for Codemation repositories and projects that use the Codemation CLI.\ntags: cli, dev\n---\n\n# Codemation CLI\n\n## Mental model\n\nThe CLI is a thin orchestrator: it loads `codemation.config.ts`, delegates to `@codemation/host` (web server) and worker packages, and manages build artifacts in `.codemation/output/`. It owns no workflow logic. There are two modes: **consumer mode** (`codemation dev`) runs a stable packaged UI against the consumer's workflows; **framework-author mode** (`codemation dev --watch-framework`) enables `next-host` HMR for monorepo development.\n\n## When to use / when NOT\n\nUse this skill for command selection, local development flow, and CLI troubleshooting.\nDo not use for workflow graph design, custom node implementation, or credential modeling unless the CLI command is the core question.\n\n## Quickstart\n\n```\ncodemation dev # consumer development (default)\ncodemation dev --watch-framework # framework-author / UI HMR (monorepo)\ncodemation build # emit .codemation/output/build\ncodemation serve web # run packaged web host\ncodemation serve worker # start queue-backed worker\ncodemation user create # bootstrap local-auth user\ncodemation user list # inspect auth users\n```\n\nUse `codemation --help` or `codemation <command> --help` before guessing flags.\n\n## Decision branches & gotchas\n\n**Standalone consumer vs monorepo:** confirm which context the user is in before suggesting commands. In the monorepo, distinguish framework-author mode from consumer mode explicitly.\n\n**Plugin loading:** in consumer mode, plugins are loaded from the built JavaScript path declared in `package.json#codemation.plugin` — not from TypeScript source under `node_modules`. In plugin dev mode, the CLI TypeScript-loads only the current plugin repo through the generated `.codemation/plugin-dev/codemation.config.ts`.\n\n**Redis-backed execution:** when Redis-backed execution is involved, mention the shared PostgreSQL requirement — local SQLite no longer fits.\n\n**Skills sync:** after `@codemation/cli` or `@codemation/agent-skills` package upgrades in monorepo work, run `codemation skills sync` to refresh extracted packaged skills in `.agents/skills/extracted`. Automatic refresh is intentionally disabled in the monorepo worktree to keep it clean.\n\n## Anti-patterns\n\n- Do not guess CLI flags — use `codemation <command> --help`.\n- Do not assume SQLite fits when Redis-backed workers are in play — check for the PostgreSQL requirement.\n\n## Read next when needed\n\n- Read `references/command-map.md` for command responsibilities and development-mode guidance.\n"
38
+ "code": "---\nname: cli\ndescription: Guides Codemation CLI work for consumer apps and framework-author development. Use when the user asks about `codemation dev`, `build`, `serve web`, `serve worker`, `user create`, `user list`, `--consumer-root`, `.codemation/output`, or consumer versus framework-author mode.\ncompatibility: Designed for Codemation repositories and projects that use the Codemation CLI.\ntags: cli, dev\n---\n\n# Codemation CLI\n\nThe CLI is a thin orchestrator: it loads `codemation.config.ts`, delegates to `@codemation/host` (web server) and worker packages, and manages build artifacts in `.codemation/output/`. It owns no workflow logic. There are two modes: **consumer mode** (`codemation dev`) runs a stable packaged UI against the consumer's workflows; **framework-author mode** (`codemation dev --watch-framework`) enables `next-host` HMR for monorepo development.\n\n## Quickstart\n\n```\ncodemation dev # consumer development (default)\ncodemation dev --watch-framework # framework-author / UI HMR (monorepo)\ncodemation build # emit .codemation/output/build\ncodemation serve web # run packaged web host\ncodemation serve worker # start queue-backed worker\ncodemation user create # bootstrap local-auth user\ncodemation user list # inspect auth users\n```\n\nUse `codemation --help` or `codemation <command> --help` before guessing flags.\n\n## Decision branches & gotchas\n\n**Standalone consumer vs monorepo:** confirm which context the user is in before suggesting commands. In the monorepo, distinguish framework-author mode from consumer mode explicitly.\n\n**Plugin loading:** in consumer mode, plugins are loaded from the built JavaScript path declared in `package.json#codemation.plugin` — not from TypeScript source under `node_modules`. In plugin dev mode, the CLI TypeScript-loads only the current plugin repo through the generated `.codemation/plugin-dev/codemation.config.ts`.\n\n**Redis-backed execution:** when Redis-backed execution is involved, mention the shared PostgreSQL requirement — local SQLite no longer fits.\n\n**Skills sync:** after `@codemation/cli` or `@codemation/agent-skills` package upgrades in monorepo work, run `codemation skills sync` to refresh extracted packaged skills in `.agents/skills/extracted`. Automatic refresh is intentionally disabled in the monorepo worktree to keep it clean.\n\n## Anti-patterns\n\n- Do not guess CLI flags — use `codemation <command> --help`.\n- Do not assume SQLite fits when Redis-backed workers are in play — check for the PostgreSQL requirement.\n\n## Read next when needed\n\n- Read `references/command-map.md` for command responsibilities and development-mode guidance.\n",
39
+ "format": "doc",
40
+ "verified": false,
41
+ "l2": "- Codemation CLI\n - Quickstart\n - Decision branches & gotchas\n - Anti-patterns\n - Read next when needed"
32
42
  },
33
43
  {
34
- "name": "codemation-credential-development",
35
- "description": "Guides Codemation custom credential development with `defineCredential(...)`, typed sessions, credential testing, and node credential slots. Use when creating or updating custom credentials, credential registrations, or credential-aware custom nodes.",
44
+ "name": "connect-external-systems",
45
+ "description": "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.",
46
+ "tags": [
47
+ "integration",
48
+ "external",
49
+ "api",
50
+ "http",
51
+ "rest",
52
+ "mcp",
53
+ "connect",
54
+ "erp",
55
+ "crm",
56
+ "credential",
57
+ "routing"
58
+ ],
59
+ "sourcePath": "skills/builder/connect-external-systems/SKILL.md",
60
+ "dependencies": {
61
+ "@codemation/core-nodes": "0.12.0",
62
+ "@codemation/core": "0.14.0"
63
+ },
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
+ "format": "doc",
66
+ "verified": false,
67
+ "l2": "- Connecting to external systems — the routing hierarchy\n - The decision tree\n - Route 1 — specialized node (preferred)\n - Route 2 — MCP server on the control plane\n - Route 3 — `defineRestNode` for a plain REST API\n - Route 4 — hand-rolled `Callback` (last resort)\n - Iterative / fuzzy matching: prefer an AIAgent with that integration's nodes as tools\n - Anti-patterns\n - Build what works; report what's missing — and never claim what you didn't wire\n - Read next when needed"
68
+ },
69
+ {
70
+ "name": "credential-development",
71
+ "description": "Authors a Codemation credential type with defineCredential(...) — public config, secret material, a typed createSession(...), and a test(...) probe — plus how nodes request it via named slots. Use when creating or updating custom credentials or credential-aware nodes.",
36
72
  "tags": [
37
73
  "credential",
38
74
  "oauth",
39
75
  "plugin"
40
76
  ],
41
- "sourcePath": "skills/codemation-credential-development/SKILL.md",
77
+ "sourcePath": "skills/builder/credential-development/SKILL.md",
42
78
  "dependencies": {},
43
- "code": "---\nname: codemation-credential-development\ndescription: Guides Codemation custom credential development with `defineCredential(...)`, typed sessions, credential testing, and node credential slots. Use when creating or updating custom credentials, credential registrations, or credential-aware custom nodes.\ncompatibility: Designed for Codemation apps and plugins that register typed credentials.\ntags: credential, oauth, plugin\n---\n\n# Codemation Credential Development\n\n## Mental model\n\nA credential type is a schema + runtime adapter: it declares `public` config (e.g. OAuth client id), `secret` material (e.g. tokens), a `createSession(...)` factory that returns the typed object nodes consume, and a `test(...)` function for pre-activation validation. Nodes declare named credential slots; operators bind concrete instances to those slots in the UI. The binding key is `(workflowId, nodeId, slotKey)`.\n\n## When to use / when NOT\n\nUse this skill for defining new credential types, wiring them into apps or plugins, and teaching nodes to request typed credential sessions.\nDo not use for general workflow authoring unless credential slots or runtime sessions are the core problem.\n\n## Quickstart\n\nNo standalone snippet the full `defineCredential(...)` shape is in `references/credential-patterns.md`. Use your harness's example-discovery tool for runnable examples: `find_examples({ query: \"defineCredential api-key test\" })` or `find_examples({ query: \"credential slot\" })`.\n\n## What `test()` does and why it matters\n\nEvery credential type must implement `test(args)`. It is called:\n\n- When the operator clicks **Connect** in the credential dialog (validates before saving).\n- Before a workflow activates (blocks activation on failing credentials).\n\n`test()` receives the same `{ publicConfig, material }` args as `createSession()`. It must return `{ status: \"healthy\" | \"failing\", message, testedAt }`. A \"failing\" result blocks workflow activation and surfaces the `message` to the operator — use it to give actionable guidance (\"API key is empty\", \"Endpoint returned 401 — key is invalid\").\n\nImplement `test()` as a cheap probe against the real service when possible (e.g. a `/health` or `/me` call). At minimum, validate that required secret fields are non-empty. Do NOT call `createSession()` from inside `test()` — test independently so credential issues are caught before runtime.\n\nSee the `define-credential-api-key` example for a concrete `test()` implementation: `find_examples({ query: \"defineCredential api-key test\" })`.\n\n## Authoring rules\n\n1. Start with `defineCredential(...)`.\n2. Keep `public` versus `secret` fields intentional.\n3. Make `createSession(...)` return the typed runtime object the node actually needs.\n4. Implement `test(...)` so failure states are explicit before workflow activation.\n5. Register credential types at the app or plugin boundary, not inside random workflow files.\n\n## Decision branches & gotchas\n\n**Node integration:** helper-defined nodes declare credentials directly in the `credentials` field; class-based nodes use lower-level credential requirement APIs when needed.\n\n**Binding stability:** the `nodeId` defaults to a slug of the node's `name` label. Renaming a credential-using node's label silently changes its id and orphans the binding in the UI. To prevent this, set an explicit `id:` on credential-using node configs so the id is decoupled from the label.\n\n## Anti-patterns\n\n- Do not hard-code secrets in node implementation — use credential slots.\n- Do not register credential types inside workflow files — use the app or plugin composition root.\n\n## Read next when needed\n\n- Read `references/credential-patterns.md` for schema, registration, and slot guidance.\n"
79
+ "code": "---\nname: credential-development\ndescription: Authors a Codemation credential type with defineCredential(...) public config, secret material, a typed createSession(...), and a test(...) probe — plus how nodes request it via named slots. Use when creating or updating custom credentials or credential-aware nodes.\ntags: credential, oauth, plugin\n---\n\n# Codemation Credential Development\n\nA credential type is a schema + runtime adapter: it declares `public` config (e.g. base URL), `secret` material (e.g. tokens), a `createSession(...)` factory that returns the typed object nodes consume, and a `test(...)` function for pre-activation validation. Nodes declare named credential slots; operators bind concrete instances to those slots in the UI. The binding key is `(workflowId, nodeId, slotKey)`.\n\n## Quickstart\n\n`defineCredential` takes a `public`/`secret` field map (or a Zod object), `createSession(args)` returning the runtime session, and `test(args)` returning a `CredentialHealth`. `args` is `{ publicConfig, material }`. This API-key type injects the key as a header and probes the endpoint in `test`:\n\n```ts\nimport { defineCredential } from \"@codemation/core\";\nimport type { CredentialSession, HttpCredentialDelta } from \"@codemation/core-nodes\";\n\nexport const weatherApiKeyCredentialType = defineCredential({\n key: \"example.weather-api-key\",\n label: \"Weather API Key\",\n public: {\n keyHeader: { label: \"Header name\", type: \"string\" as const, placeholder: \"X-Api-Key\" },\n },\n secret: {\n apiKey: { label: \"API Key\", type: \"password\" as const, required: true },\n },\n createSession(args): CredentialSession {\n const apiKey = String(args.material.apiKey ?? \"\");\n const headerName = String(args.publicConfig.keyHeader ?? \"X-Api-Key\").trim() || \"X-Api-Key\";\n return {\n applyToRequest: (): HttpCredentialDelta => ({ headers: { [headerName]: apiKey } }),\n };\n },\n async test(args) {\n const apiKey = String(args.material.apiKey ?? \"\").trim();\n if (!apiKey) {\n return { status: \"failing\", message: \"API key is empty.\", testedAt: new Date().toISOString() };\n }\n const resp = await fetch(\"https://api.example.com/me\", { headers: { \"X-Api-Key\": apiKey } });\n return resp.status === 401 || resp.status === 403\n ? {\n status: \"failing\",\n message: `API returned ${resp.status} — key is invalid.`,\n testedAt: new Date().toISOString(),\n }\n : { status: \"healthy\", message: \"API key accepted.\", testedAt: new Date().toISOString() };\n },\n});\n```\n\nRegister it with `defineCodemationApp({ credentials: [...] })` or `definePlugin({ credentials: [...] })`, then wire it onto a node — for `HttpRequest`, `credentialSlot: { name: \"apiKey\", acceptedTypes: [weatherApiKeyCredentialType] }`.\n\n## What `test()` does and why it matters\n\nEvery credential type must implement `test(args)`. It is called:\n\n- When the operator clicks **Connect** in the credential dialog (validates before saving).\n- Before a workflow activates (blocks activation on failing credentials).\n\n`test()` receives the same `{ publicConfig, material }` args as `createSession()`. It must return `{ status: \"healthy\" | \"failing\", message, testedAt }`. A \"failing\" result blocks workflow activation and surfaces the `message` to the operator — use it to give actionable guidance (\"API key is empty\", \"Endpoint returned 401 — key is invalid\").\n\nImplement `test()` as a cheap probe against the real service when possible (e.g. a `/health` or `/me` call). At minimum, validate that required secret fields are non-empty. Do NOT call `createSession()` from inside `test()` — test independently so credential issues are caught before runtime.\n\n## Authoring rules\n\n1. Start with `defineCredential(...)`.\n2. Keep `public` versus `secret` fields intentional.\n3. Make `createSession(...)` return the typed runtime object the node actually needs.\n4. Implement `test(...)` so failure states are explicit before workflow activation.\n5. Register credential types at the app or plugin boundary, not inside random workflow files.\n\n## Decision branches & gotchas\n\n**Node integration:** helper-defined nodes declare credentials directly in the `credentials` field; class-based nodes use lower-level credential requirement APIs when needed.\n\n**Binding stability:** the `nodeId` defaults to a slug of the node's `name` label. Renaming a credential-using node's label silently changes its id and orphans the binding in the UI. To prevent this, set an explicit `id:` on credential-using node configs so the id is decoupled from the label.\n\n## Anti-patterns\n\n- Do not hard-code secrets in node implementation — use credential slots.\n- Do not register credential types inside workflow files — use the app or plugin composition root.\n\n## Read next when needed\n\n- Read `references/credential-patterns.md` for schema, registration, and slot guidance.\n",
80
+ "format": "doc",
81
+ "verified": false,
82
+ "l2": "- Codemation Credential Development\n - Quickstart\n - What `test()` does and why it matters\n - Authoring rules\n - Decision branches & gotchas\n - Anti-patterns\n - Read next when needed"
44
83
  },
45
84
  {
46
- "name": "codemation-custom-node-development",
47
- "description": "Guides Codemation custom node development with `defineNode(...)` (`execute` per item), `defineBatchNode(...)` (batch `run`), reusable node modules, credential-aware nodes, and the class-based node fallback for advanced cases. Use when creating or updating custom nodes for apps or plugin packages.",
85
+ "name": "custom-node-development",
86
+ "description": "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.",
48
87
  "tags": [
49
88
  "node",
50
89
  "custom",
51
90
  "plugin"
52
91
  ],
53
- "sourcePath": "skills/codemation-custom-node-development/SKILL.md",
92
+ "sourcePath": "skills/builder/custom-node-development/SKILL.md",
54
93
  "dependencies": {
55
- "@codemation/core": "0.13.0"
94
+ "@codemation/core": "0.14.0"
56
95
  },
57
- "code": "---\nname: codemation-custom-node-development\ndescription: Guides Codemation custom node development with `defineNode(...)` (`execute` per item), `defineBatchNode(...)` (batch `run`), reusable node modules, credential-aware nodes, and the class-based node fallback for advanced cases. Use when creating or updating custom nodes for apps or plugin packages.\ncompatibility: Designed for Codemation apps and plugin packages that define reusable nodes.\ntags: node, custom, plugin\nuses: \"@codemation/core\"\n---\n\n# Codemation Custom Node Development\n\n## Mental model\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 (input schema, 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. Nodes compose into workflows via config class instances — the node definition is separate from the config class used to wire it into a workflow.\n\n## Use this skill when\n\nUse this skill for reusable custom node work, whether the node lives inside an app or a published plugin package.\n\nDo not use this skill for pure workflow chaining questions unless the node implementation itself is changing.\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 **config**; put **per-item** behavior in **inputs** / wire JSON and optional `itemExpr` on config fields.\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```ts\nimport { defineNode } from \"@codemation/core\";\nimport { z } from \"zod\";\n\nexport const uppercaseNode = defineNode({\n key: \"example.uppercase\",\n title: \"Uppercase field\",\n icon: \"lucide:languages\",\n inputSchema: z.object({ field: z.string() }),\n async execute({ input }) {\n return { ...input, field: input.field.toUpperCase() };\n },\n});\n```\n\nFor full patterns credential-slotted nodes, batch nodes, fan-out, binary payloads, and test kit usage use your harness's example-discovery tool: `find_examples({ query: \"defineNode\" })` or `find_examples({ query: \"defineBatchNode\" })`.\n\n## Read next\n\n- `references/define-node-per-item.md` — full `defineNode(...)` contract, `inputSchema`, `itemExpr`, fan-out, assertion nodes, and `WorkflowTestKit` usage. Load this when writing or debugging a per-item node.\n- `references/define-batch-node.md` — `defineBatchNode(...)` contract and when to choose batch over per-item. Load this when the node must see the entire batch at once.\n- `references/credential-aware-nodes.md` — credential slots, typed sessions, and how to test credential-aware nodes. Load this when your node needs a credential.\n- `references/node-patterns.md` — binary payloads (`ctx.binary`, `attach`, `withAttachment`), fan-out return shapes, polling-trigger binary patterns, MS Graph attachment download, and HTTP binary round-trips. Load this when working with file data or HTTP binaries.\n"
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
+ "format": "doc",
98
+ "verified": false,
99
+ "l2": "- Codemation Custom Node Development\n - Per-item vs batch\n - Node rules\n - Minimal `defineNode` example\n - Read next"
58
100
  },
59
101
  {
60
- "name": "codemation-document-scanner",
61
- "description": "CodemationDocumentScanner node managed document/invoice/image extraction via the Codemation doc-scanner service. No Azure credentials required. Read before writing any workflow that scans documents, invoices, or images.",
102
+ "name": "document-ai",
103
+ "description": "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.",
62
104
  "tags": [
63
105
  "ocr",
64
106
  "document",
@@ -66,82 +108,350 @@
66
108
  "image",
67
109
  "scan",
68
110
  "extract",
111
+ "markdown",
112
+ "fields",
113
+ "confidence",
69
114
  "managed",
70
- "confidence"
115
+ "binary"
71
116
  ],
72
- "sourcePath": "skills/codemation-document-scanner/SKILL.md",
117
+ "sourcePath": "skills/builder/document-ai/SKILL.md",
73
118
  "dependencies": {
74
- "@codemation/core-nodes": "0.10.0"
119
+ "@codemation/core-nodes": "0.12.0"
75
120
  },
76
- "code": "---\nname: codemation-document-scanner\ndescription: CodemationDocumentScanner node managed document/invoice/image extraction via the Codemation doc-scanner service. No Azure credentials required. Read before writing any workflow that scans documents, invoices, or images.\ncompatibility: Codemation core-nodes. Requires @codemation/core-nodes import.\ntags: ocr, document, invoice, image, scan, extract, managed, confidence\nuses: \"@codemation/core-nodes\"\n---\n\n# Codemation Document Scanner Node\n\n> **Start here: call `find_examples` before reading further.**\n>\n> - `find_examples({ query: \"CodemationDocumentScanner\" })` node-level usage and all analyzerType variants\n> - `find_examples({ query: \"invoice scan post accounting\" })`end-to-end invoice extraction scenario\n> - `find_examples({ query: \"document scanner confidence fields\" })` how to enable per-field confidence scores\n\n## Use this skill when\n\nWriting a workflow that extracts text and/or structured fields from documents, invoices, or images\nusing the Codemation managed scanning service. No Azure credentials are required the service is\npre-wired to the platform.\n\nUse `codemation-workflow-dsl` for surrounding workflow structure.\nUse `codemation-ai-agent-node` if you need to pass the extracted markdown to an LLM for further processing.\n\n## When to use CodemationDocumentScanner vs the standalone OCR nodes\n\n| Situation | Use |\n| ------------------------------------------------ | --------------------------------------------------------------------------------------------------- |\n| Managed platform deployment, no Azure credential | `codemationDocumentScannerNode` (this skill) |\n| Self-hosted / BYOK Azure Content Understanding | `analyzeDocumentNode` / `analyzeInvoiceNode` / `analyzeImageNode` from `@codemation/core-nodes-ocr` |\n\n`codemationDocumentScannerNode` calls the internal `doc-scanner` service via HMAC the workspace holds\nno Azure key. The standalone OCR nodes call Azure directly using a per-workspace credential.\n\n## Choosing `analyzerType`\n\n| `analyzerType` | When to use | Azure analyzer | Field extraction | Confidence opt-in supported |\n| -------------- | ----------------------------------------------- | ----------------------------------------------------------------------- | ---------------------- | --------------------------------------- |\n| `\"document\"` | General PDFs, Word docs, HTML, text-heavy files | `prebuilt-document` | Yes | Yes |\n| `\"invoice\"` | Invoices, receipts — always prebuilt-invoice | `prebuilt-invoice` | Yes | Yes |\n| `\"image\"` | Photos, screenshots, diagrams | `prebuilt-imageAnalyzer` | No (markdown only) | No — image carries no extraction charge |\n| `\"auto\"` | Unknown mime type at author time | Routes on `Content-Type`: `image/*` → image, everything else document | Depends on routed type | Depends on routed type |\n\n**Default is `\"auto\"`.** Set an explicit type whenever you know the content class it avoids\nunnecessary re-routing and makes the workflow self-documenting.\n\n## Output shape\n\n```ts\n{\n markdown: string; // full text content\n fields: Record<\n string,\n {\n value: unknown; // extracted scalar, date ISO string, nested object, or array\n confidence: number | null; // 0–1 when includeConfidence:true; null otherwise\n }\n >;\n}\n```\n\n`item.json.markdown` is the Markdown rendering of the document.\n`item.json.fields` is a flat-or-nested map of structured fields found by the analyzer.\nFor `analyzerType: \"image\"`, `fields` is always `{}`.\nFields may be sparse or absent for generic documents — extraction is best-effort.\n\n## WARNING: How to enable per-field confidence scores (LD6)\n\n**By default, `confidence` is `null` on every field.** This keeps token cost low for the majority\nof workflows that only need `value`.\n\nTo get a populated `confidence` (0–1 float) on each field, set `includeConfidence: true`:\n\n```ts\ncodemationDocumentScannerNode.create(\n {\n binaryField: \"data\",\n analyzerType: \"invoice\",\n includeConfidence: true, // opt-in: fields carry confidence 0–1\n },\n \"Scan invoice\",\n \"scan-invoice\",\n);\n```\n\n**Cost implication:** enabling confidence routes the request to a confidence-enabled analyzer variant,\nwhich roughly doubles the contextualization token count for document/invoice analyzers.\nOnly enable it when your downstream logic actually reads `field.confidence`.\n\nImages (`analyzerType: \"image\"`) and auto-routed-to-image requests carry no field-extraction charge\nregardless of this flag — they silently ignore `includeConfidence` (confidence stays `null`, never a 400).\n\n## API usage\n\n```ts\nimport { codemationDocumentScannerNode } from \"@codemation/core-nodes\";\n\ncodemationDocumentScannerNode.create(\n {\n binaryField?: string; // key on item.binary — default \"data\"\n analyzerType?: \"document\" | \"invoice\" | \"image\" | \"auto\"; // default \"auto\"\n contentType?: string; // MIME type override — falls back to attachment.mimeType\n includeConfidence?: boolean; // default false — see cost note above\n maxBytes?: number; // size cap before reading; default 50 MiB (LD10)\n },\n label?: string, // node label on the canvas\n nodeId?: string, // explicit stable id — set when output is used downstream\n)\n```\n\nSet an explicit `nodeId` whenever downstream nodes reference this node's output by id, or when\nthe node may be renamed later (avoids credential-binding orphaning).\n\n## Consuming fields downstream\n\n```ts\nimport type { DocScannerOutput, DocScannerField } from \"@codemation/core-nodes\";\n\n// item.json after codemationDocumentScannerNode:\n// DocScannerOutput = { markdown: string; fields: Record<string, DocScannerField> }\n// DocScannerField = { value: unknown; confidence: number | null }\n\nnew Callback<DocScannerOutput>(\"Use fields\", (items, _ctx) =>\n items.map((item) => {\n const vendorName = item.json.fields[\"VendorName\"]?.value as string | undefined;\n const vendorConf = item.json.fields[\"VendorName\"]?.confidence; // null unless includeConfidence:true\n return { ...item, json: { ...item.json, vendorName, vendorConf } };\n }),\n);\n```\n\n## Read next when needed\n\n- `codemation-workflow-dsl` — workflow builder, trigger types, fluent vs low-level API.\n- `codemation-ai-agent-node` — pass `item.json.markdown` to an LLM for summarisation or extraction.\n"
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 HMACthe 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
+ "format": "doc",
123
+ "verified": false,
124
+ "l2": "- Codemation Document Scanner\n - A complete scanning workflow\n - Choosing `analyzerType`\n - Output shape\n - Per-field confidence (opt-in)\n - Consuming fields downstream\n - Config reference\n - Gotchas\n - Read next when needed"
77
125
  },
78
126
  {
79
- "name": "codemation-framework-concepts",
80
- "description": "Explains Codemation package boundaries, runtime concepts, observability shape, and the normal consumer mental model. Use when the user asks where code belongs across `@codemation/core`, `@codemation/host`, `@codemation/next-host`, `@codemation/cli`, workflows, plugins, credentials, activation, telemetry, or runtime modes. Read this first when starting any Codemation task it points at the right skill for the work.",
127
+ "name": "execution-context",
128
+ "description": "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.",
129
+ "tags": [
130
+ "execution",
131
+ "context",
132
+ "item",
133
+ "json",
134
+ "binary",
135
+ "mapdata",
136
+ "data-passing",
137
+ "anti-pattern",
138
+ "ocr",
139
+ "workflow",
140
+ "authoring",
141
+ "nodes",
142
+ "between-steps",
143
+ "getoutputitem",
144
+ "cross-step-read",
145
+ "order-intake",
146
+ "document-processing"
147
+ ],
148
+ "sourcePath": "skills/builder/execution-context/SKILL.md",
149
+ "dependencies": {
150
+ "@codemation/core-nodes": "0.12.0",
151
+ "@codemation/core": "0.14.0"
152
+ },
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
+ "format": "doc",
155
+ "verified": false,
156
+ "l2": "- Execution context — how data moves between nodes\n - Reading an earlier node's output — `ctx.data.getOutputItem`\n - Recover a clobbered field from its source node\n - The same read inside a `Callback` (item-index aligned)\n - Anti-patterns vs. good patterns\n - (a) A recovery cast for a field that was clobbered — read it from the source instead\n - (b) Re-deriving the same shape repeatedly instead of reading the source\n - (c) JSON stashed inside a fabricated binary instead of on item.json\n - MapData — narrow/shape an extraction output (not a cross-step carry)\n - ctx.binary — when to use it\n - (d) Enrichment Callback REPLACES item.json — always spread the existing payload\n - Summary\n - Read next when needed"
157
+ },
158
+ {
159
+ "name": "framework-concepts",
160
+ "description": "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.",
81
161
  "tags": [
82
162
  "concepts",
83
163
  "architecture"
84
164
  ],
85
- "sourcePath": "skills/codemation-framework-concepts/SKILL.md",
165
+ "sourcePath": "skills/builder/framework-concepts/SKILL.md",
86
166
  "dependencies": {},
87
- "code": "---\nname: codemation-framework-concepts\ndescription: Explains Codemation package boundaries, runtime concepts, observability shape, and the normal consumer mental model. Use when the user asks where code belongs across `@codemation/core`, `@codemation/host`, `@codemation/next-host`, `@codemation/cli`, workflows, plugins, credentials, activation, telemetry, or runtime modes. Read this first when starting any Codemation task it points at the right skill for the work.\ncompatibility: Designed for Codemation apps, plugins, and framework contributors.\ntags: concepts, architecture\n---\n\n# Codemation Framework Concepts\n\n## Mental model\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## When to use / when NOT\n\nUse this skill to orient on package ownership, runtime shape, observability boundaries, and the consumer/framework divide.\nDo not use as a substitute for detailed CLI, workflow DSL, or plugin implementation guidance when you already know which skill you need.\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 | `codemation-workflow-dsl` |\n| Building a reusable node | `codemation-custom-node-development` |\n| Building a credential type | `codemation-credential-development` |\n| Packaging as a plugin | `codemation-plugin-development` |\n| Calling an MCP server from a workflow | `codemation-mcp-capabilities` |\n| CLI commands / dev loop | `codemation-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## Read next when needed\n\n- Read `references/architecture-map.md` for package ownership and runtime-mode guidance.\n",
168
+ "format": "doc",
169
+ "verified": false,
170
+ "l2": "- Codemation Framework Concepts\n - Core concepts\n - Where to go next\n - Read next when needed"
88
171
  },
89
172
  {
90
- "name": "codemation-mcp-capabilities",
173
+ "name": "gmail",
174
+ "description": "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.",
175
+ "tags": [
176
+ "gmail",
177
+ "email",
178
+ "label",
179
+ "reply",
180
+ "attachment",
181
+ "trigger",
182
+ "integration"
183
+ ],
184
+ "sourcePath": "skills/builder/gmail/SKILL.md",
185
+ "dependencies": {
186
+ "@codemation/core-nodes-gmail": "0.5.0",
187
+ "@codemation/core-nodes": "0.12.0",
188
+ "@codemation/core": "0.14.0"
189
+ },
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
+ "format": "doc",
192
+ "verified": false,
193
+ "l2": "- Codemation Gmail\n - The default flow: trigger on a label → reply → mark processed\n - Trigger item shape\n - Attachments → binary → document scanner (the important one)\n - Gotchas\n - Testing a Gmail workflow on real mail (not fabricated fixtures)\n - Read next when needed"
194
+ },
195
+ {
196
+ "name": "human-in-the-loop",
197
+ "description": "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.",
198
+ "tags": [
199
+ "hitl",
200
+ "approval",
201
+ "inbox",
202
+ "review",
203
+ "gate"
204
+ ],
205
+ "sourcePath": "skills/builder/human-in-the-loop/SKILL.md",
206
+ "dependencies": {
207
+ "@codemation/core-nodes": "0.12.0",
208
+ "@codemation/core": "0.14.0"
209
+ },
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
+ "format": "doc",
212
+ "verified": false,
213
+ "l2": "- Codemation Human-in-the-loop\n - The gate — one step on the chain\n - One realistic complete example\n - Gate inside an agent (one line)\n - Gotchas"
214
+ },
215
+ {
216
+ "name": "mcp-capabilities",
91
217
  "description": "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.",
92
218
  "tags": [
93
219
  "mcp",
94
220
  "agent",
95
221
  "tool"
96
222
  ],
97
- "sourcePath": "skills/codemation-mcp-capabilities/SKILL.md",
223
+ "sourcePath": "skills/builder/mcp-capabilities/SKILL.md",
98
224
  "dependencies": {},
99
- "code": "---\nname: codemation-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 (Sprint 2+).\ntags: mcp, agent, tool\n---\n\n# Codemation MCP Capabilities\n\n## Mental model\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.\n\n## When to use / when NOT\n\nUse this skill before writing `agent({ mcpServers: [\"...\"] })` to discover available server ids and their credential types.\nDo not use for general AIAgent authoring — read `codemation-ai-agent-node` for that.\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 `codemation-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 — 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",
226
+ "format": "doc",
227
+ "verified": false,
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"
229
+ },
230
+ {
231
+ "name": "msgraph",
232
+ "description": "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.",
233
+ "tags": [
234
+ "msgraph",
235
+ "microsoft",
236
+ "outlook",
237
+ "email",
238
+ "onedrive",
239
+ "excel",
240
+ "integration"
241
+ ],
242
+ "sourcePath": "skills/builder/msgraph/SKILL.md",
243
+ "dependencies": {
244
+ "@codemation/core-nodes-msgraph": "0.4.1",
245
+ "@codemation/core-nodes": "0.12.0",
246
+ "@codemation/core": "0.14.0"
247
+ },
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
+ "format": "doc",
250
+ "verified": false,
251
+ "l2": "- Codemation Microsoft Graph\n - The default flow: trigger on new mail → reply\n - Mail item shape\n - Patch a message (mark read, categorize, move)\n - Attachments → binary\n - Node catalog\n - Gotchas\n - Testing an Outlook workflow on real mail (not fabricated fixtures)\n - Read next when needed"
252
+ },
253
+ {
254
+ "name": "odoo",
255
+ "description": "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).",
256
+ "tags": [
257
+ "odoo",
258
+ "erp",
259
+ "sale-order",
260
+ "partner",
261
+ "product",
262
+ "match",
263
+ "link",
264
+ "crud",
265
+ "call_kw",
266
+ "credential",
267
+ "integration"
268
+ ],
269
+ "sourcePath": "skills/builder/odoo/SKILL.md",
270
+ "dependencies": {
271
+ "@codemation/core-nodes-odoo": "0.2.0",
272
+ "@codemation/core-nodes": "0.12.0",
273
+ "@codemation/core": "0.14.0"
274
+ },
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
+ "format": "doc",
277
+ "verified": false,
278
+ "l2": "- Odoo nodes\n - The nodes\n - MATCH → LINK → REPORT (the order-intake shape)\n - Fuzzy matching? Use an Odoo AGENT, not a deterministic chain\n - The pattern: `AgentToolFactory.asTool(odooNode, { mapInput, mapOutput })` for each entity\n - MATCH → LINK → REPORT system prompt\n - Compilable example\n - Why this beats a deterministic node chain\n - Querying — the `query` domain\n - Per-item values with `itemExpr`\n - Chaining after any upstream (humanApproval, transforms, triggers)\n - Beyond CRUD — `odooCallKwNode`\n - Credential\n - Related"
100
279
  },
101
280
  {
102
- "name": "codemation-plugin-development",
103
- "description": "Guides Codemation plugin package development, including `definePlugin(...)`, plugin sandboxes, custom nodes, custom credentials, and publishable plugin package structure. Use when building or updating a Codemation plugin package or the plugin starter template.",
281
+ "name": "plugin-development",
282
+ "description": "Packages reusable Codemation nodes and credential types as a publishable plugin with definePlugin(...), including the composition root, sandbox app, and optional MCP server declarations. Use when building or updating a Codemation plugin package.",
104
283
  "tags": [
105
284
  "plugin",
106
285
  "node",
107
286
  "package"
108
287
  ],
109
- "sourcePath": "skills/codemation-plugin-development/SKILL.md",
288
+ "sourcePath": "skills/builder/plugin-development/SKILL.md",
110
289
  "dependencies": {},
111
- "code": "---\nname: codemation-plugin-development\ndescription: Guides Codemation plugin package development, including `definePlugin(...)`, plugin sandboxes, custom nodes, custom credentials, and publishable plugin package structure. Use when building or updating a Codemation plugin package or the plugin starter template.\ncompatibility: Designed for Codemation plugin packages and the Codemation plugin starter template.\ntags: plugin, node, package\n---\n\n# Codemation Plugin Development\n\n## Mental model\n\nA Codemation plugin is an npm package with a `codemation.plugin.ts` composition root that calls `definePlugin(...)`. It registers custom nodes and credential types, optionally declares MCP servers, and ships a sandbox app so the plugin is immediately testable. Consumers load the built JavaScript entry (`package.json#codemation.plugin`) — not TypeScript source. Plugin code follows the same `defineNode` / `defineCredential` patterns as app-level code; the plugin boundary is purely about packaging and distribution.\n\n## When to use / when NOT\n\n**Plugin authoring is a framework-author / non-managed task.** Managed-mode agents work with credential slots and workflow DSL — they do not author or modify plugin packages.\n\nUse this skill for published plugin packages, plugin starter work, and sandbox-driven plugin development. Do not use for ordinary consumer workflow-only changes.\n\n## Decision branches & gotchas\n\n**MCP servers in plugins:** Plugin-declared `mcpServers` is a non-managed pattern for self-hosted / framework-author scenarios. In managed mode, MCP servers are loaded from the control plane — see `codemation-mcp-capabilities` for the managed path.\n\n**Publishing guardrail:** `package.json#codemation.plugin` must point at runnable JavaScript (`./dist/codemation.plugin.js`). Do not rely on consumers TypeScript-loading plugin files from `node_modules`.\n\n## Read next when needed\n\n- Read `references/plugin-anatomy.md` for the full `definePlugin(...)` code, package layout, sandbox setup, MCP server declaration, binary payload rules, and publishing guidance.\n- Read `references/plugin-structure.md` for a concise package layout reference.\n"
290
+ "code": "---\nname: plugin-development\ndescription: Packages reusable Codemation nodes and credential types as a publishable plugin with definePlugin(...), including the composition root, sandbox app, and optional MCP server declarations. Use when building or updating a Codemation plugin package.\ntags: plugin, node, package\n---\n\n# Codemation Plugin Development\n\nA Codemation plugin is an npm package with a `codemation.plugin.ts` composition root that calls `definePlugin(...)` (from `@codemation/host/authoring`). It registers custom nodes and credential types, optionally declares MCP servers, and ships a sandbox app so the plugin is immediately testable. Consumers load the built JavaScript entry (`package.json#codemation.plugin`) — not TypeScript source. Plugin code follows the same `defineNode` / `defineCredential` patterns as app-level code; the plugin boundary is purely about packaging and distribution.\n\n## When to use / when NOT\n\n**Plugin authoring is a framework-author / non-managed task.** Managed-mode agents work with credential slots and workflow DSL — they do not author or modify plugin packages.\n\nUse this skill for published plugin packages, plugin starter work, and sandbox-driven plugin development. Do not use for ordinary consumer workflow-only changes.\n\n## Decision branches & gotchas\n\n**MCP servers in plugins:** Plugin-declared `mcpServers` is a non-managed pattern for self-hosted / framework-author scenarios. In managed mode, MCP servers are loaded from the control plane — see `mcp-capabilities` for the managed path.\n\n**Publishing guardrail:** `package.json#codemation.plugin` must point at runnable JavaScript (`./dist/codemation.plugin.js`). Do not rely on consumers TypeScript-loading plugin files from `node_modules`.\n\n## Read next when needed\n\n- Read `references/plugin-anatomy.md` for the full `definePlugin(...)` code, package layout, sandbox setup, MCP server declaration, binary payload rules, and publishing guidance.\n- Read `references/plugin-structure.md` for a concise package layout reference.\n",
291
+ "format": "doc",
292
+ "verified": false,
293
+ "l2": "- Codemation Plugin Development\n - When to use / when NOT\n - Decision branches & gotchas\n - Read next when needed"
112
294
  },
113
295
  {
114
- "name": "codemation-workflow-dsl",
115
- "description": "Guides Codemation workflow authoring. Use when creating or updating workflow definitions in `src/workflows` manual-trigger flows via `workflow(\"...\").manualTrigger(...)`, or cron/webhook/other triggers via `createWorkflowBuilder({id, name}).trigger(...)`.",
296
+ "name": "rest-node",
297
+ "description": "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.",
298
+ "tags": [
299
+ "rest",
300
+ "http",
301
+ "api",
302
+ "integration",
303
+ "defineRestNode",
304
+ "node",
305
+ "credential",
306
+ "connect",
307
+ "external"
308
+ ],
309
+ "sourcePath": "skills/builder/rest-node/SKILL.md",
310
+ "dependencies": {
311
+ "@codemation/core-nodes": "0.12.0",
312
+ "@codemation/core": "0.14.0"
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",
315
+ "format": "doc",
316
+ "verified": false,
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"
318
+ },
319
+ {
320
+ "name": "testing",
321
+ "description": "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.",
322
+ "tags": [
323
+ "testing",
324
+ "test",
325
+ "assertion",
326
+ "dry-run",
327
+ "istestrun",
328
+ "testtrigger"
329
+ ],
330
+ "sourcePath": "skills/builder/testing/SKILL.md",
331
+ "dependencies": {
332
+ "@codemation/core-nodes": "0.12.0",
333
+ "@codemation/core": "0.14.0"
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",
336
+ "format": "doc",
337
+ "verified": false,
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"
339
+ },
340
+ {
341
+ "name": "workflow-dsl",
342
+ "description": "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.",
116
343
  "tags": [
117
344
  "workflow",
118
345
  "dsl",
119
346
  "authoring"
120
347
  ],
121
- "sourcePath": "skills/codemation-workflow-dsl/SKILL.md",
348
+ "sourcePath": "skills/builder/workflow-dsl/SKILL.md",
122
349
  "dependencies": {
123
- "@codemation/core-nodes": "0.10.0",
124
- "@codemation/host": "0.10.0"
350
+ "@codemation/core-nodes": "0.12.0",
351
+ "@codemation/core": "0.14.0"
125
352
  },
126
- "code": "---\nname: codemation-workflow-dsl\ndescription: Guides Codemation workflow authoring. Use when creating or updating workflow definitions in `src/workflows` — manual-trigger flows via `workflow(\"...\").manualTrigger(...)`, or cron/webhook/other triggers via `createWorkflowBuilder({id, name}).trigger(...)`.\ncompatibility: Designed for Codemation apps and plugins that author workflows.\ntags: workflow, dsl, authoring\nuses: \"@codemation/core-nodes, @codemation/host\"\n---\n\n# Codemation Workflow DSL\n\n## Mental model\n\nA workflow definition describes how items move from a trigger through downstream node steps. Items carry data in `item.json`; earlier outputs are available through `ctx.data`. Activations are batch-shaped but most node steps execute per-item. Every workflow definition finishes with `.build()`, which validates node ids and emits a `WorkflowDefinitionError` on collision or empty id.\n\n## When to use / when NOT\n\nUse this skill when authoring or reviewing workflow definitions under `src/workflows/`.\nDo not use for CLI-only troubleshooting or deep host architecture questions unless they directly affect workflow authoring.\n\n## Quickstart — pick API by trigger type\n\n```ts\n// Manual trigger — full fluent sugar (.map, .if, .switch, .agent, .node, .then)\nimport { workflow } from \"@codemation/host\";\nexport default workflow(\"wf.example\")\n .manualTrigger(\"Start\", {\n /* seed items */\n })\n .map(/* ... */)\n .build();\n\n// Cron / webhook / any other trigger — low-level .then(new NodeConfig(...)) only\nimport { createWorkflowBuilder, CronTrigger } from \"@codemation/core-nodes\";\nexport default createWorkflowBuilder({ id: \"wf.example\", name: \"Example\" })\n .trigger(new CronTrigger(\"Daily\", { schedule: \"0 9 * * *\", timezone: \"UTC\" }))\n .then(/* new SomeNodeConfig(...) */)\n .build();\n```\n\nFor full patterns — multi-step pipelines, branching, SubWorkflow, binary, agent tools, TestTrigger, and complete working examples — use your harness's example-discovery tool: `find_examples({ query: \"...\" })`. Useful queries: `\"CronTrigger\"`, `\"if branch\"`, `\"AIAgent multi-step\"`, `\"SubWorkflow binary\"`, `\"TestTrigger assertion\"`.\n\n## Decision branches & gotchas\n\n**Two authoring APIs — pick by trigger type.** `workflow(\"id\").manualTrigger(...)` returns a `WorkflowChain` with full fluent helpers (`.map`, `.if`, `.switch`, `.split`, `.agent`, `.node`). `createWorkflowBuilder({id, name}).trigger(new XxxTrigger(...))` returns a `ChainCursor` whose only chain method is `.then(new NodeConfig(...))`. Do NOT call `.trigger(...)` on the `workflow(...)` builder — it doesn't exist there.\n\n**Node ids and stability.** When no explicit `id:` is given, the engine slugifies the node's `name` label (lowercase, non-alphanumeric → `-`). `\"Send Email\"` → `\"send-email\"`. Nodes sharing credential bindings use `(workflowId, nodeId, slotKey)` as the binding key — renaming a label orphans the binding. **Set explicit `id:` on every credential-using node.** `.build()` throws `WorkflowDefinitionError` on empty or duplicate ids.\n\n**Id collision pitfall.** A manual-trigger label and a downstream agent label that share the same string both slugify to the same id — `.build()` throws. Fix: add `id: \"...-agent\"` to disambiguate.\n\n**Collection nodes** use `.then(node.create(...))` instead of `.node(label, node, opts)` — TypeScript can't infer the `ParamDeep` constraint via the fluent helper. See `find_examples({ query: \"collection crud\" })`.\n\n**Install state in example results.** Every `find_examples` result includes `installed: boolean` and `requiresInstall: string[]`. If `installed` is `false` or `requiresInstall` is non-empty, call `install_package` for each missing package before writing any workflow code that imports them.\n\n**When no example matches — self-solving fallback chain.**\n\n1. Retry with intent variations (different verb, more generic term).\n2. For HTTP APIs: `find_examples({ query: \"defineRestNode\" })` — covers basic and credential-slotted REST.\n3. For one-shot inline HTTP: `find_examples({ query: \"HttpRequest\" })`.\n4. For non-HTTP custom logic: `find_examples({ query: \"defineNode template\" })`.\n Do NOT ask the user to pick between primitives — they can't help; use the chain. Do NOT grep `node_modules/@codemation/*` for node implementations — examples are authoritative. Surface the technique used in your reply.\n\n**Workflow testing.** Three built-in nodes from `@codemation/core-nodes`: `TestTrigger` (yields one item per test case), `IsTestRun` (routes `true`/`false` by `ctx.testContext`), `Assertion` (emits `AssertionResult[]`, sets `emitsAssertions: true`). See `references/workflow-testing.md` for authoring details.\n\n**SubWorkflow binary.** `item.binary` slots pass transparently through SubWorkflow boundaries in both directions — no special config needed. Both runs share the same `BinaryStorage` singleton.\n\n**Verify your workflow.** Call `verify_workflow({ path: \"src/workflows/my-workflow.ts\" })` instead of running `pnpm typecheck` yourself. Returns `{ ok, data: { typecheck, lint, build, structure }, hint? }`.\n\n## Anti-patterns\n\n- Do not call `.trigger(...)` on the `workflow(...)` manual builder — use `createWorkflowBuilder(...)` for non-manual triggers.\n- Do not rely on slug-derived node ids for production workflows with credential bindings — always set an explicit `id:`.\n- Do not improvise from memory when `find_examples` returns zero hits — use the fallback chain above.\n\n## Read next when needed\n\n- `references/builder-patterns.md` — item-flow rules and fluent authoring patterns.\n- `references/workflow-testing.md` — TestTrigger / IsTestRun / Assertion with full examples.\n- `references/complete-example.md` — dense end-to-end example covering most authoring features.\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`, `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",
354
+ "format": "doc",
355
+ "verified": false,
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"
127
357
  },
128
358
  {
129
- "name": "codemation-workspace-files",
130
- "description": "ListWorkspaceFiles + ReadWorkspaceFile nodes — read files from the shared workspace pool. Covers read-by-filename (latest-wins), pinned fileId, binary slot handoff, and the raw-upload concierge-digests workflow-reads-derived-file pattern. Read before building any workflow that reads workspace files.",
359
+ "name": "workspace-files",
360
+ "description": "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.",
131
361
  "tags": [
132
362
  "workspace",
133
363
  "files",
364
+ "pool",
134
365
  "binary",
135
366
  "storage",
136
367
  "read",
368
+ "write",
369
+ "list",
137
370
  "csv",
138
- "json"
371
+ "json",
372
+ "lookup"
139
373
  ],
140
- "sourcePath": "skills/codemation-workspace-files/SKILL.md",
374
+ "sourcePath": "skills/builder/workspace-files/SKILL.md",
141
375
  "dependencies": {
142
- "@codemation/core-nodes-workspace-files": "0.2.0"
376
+ "@codemation/core-nodes-workspace-files": "0.3.0"
143
377
  },
144
- "code": "---\nname: codemation-workspace-files\ndescription: ListWorkspaceFiles + ReadWorkspaceFile nodes — read files from the shared workspace pool. Covers read-by-filename (latest-wins), pinned fileId, binary slot handoff, and the raw-upload concierge-digests workflow-reads-derived-file pattern. Read before building any workflow that reads workspace files.\ncompatibility: Codemation core-nodes-workspace-files. Requires WORKSPACE_ID and BLOB_STORAGE_* env vars.\ntags: workspace, files, binary, storage, read, csv, json\nuses: \"@codemation/core-nodes-workspace-files\"\n---\n\n# Codemation Workspace Files\n\n## Mental model\n\nWorkflows **read** the shared workspace file pool; they do **not** write to it. Files are\ncreated and managed on the control-plane side (the Files UI, the concierge, the\nDocumentScanner). The framework's role is to provide `ListWorkspaceFiles` and\n`ReadWorkspaceFile` as pure read nodes.\n\nThe **headline scenario** is: a user uploads a raw PDF; the concierge digests it into a\nstructured JSON; the workflow reads the _derived JSON_, not the raw bytes. Workflows\nnever touch raw uploads directly.\n\n## When to use / when NOT\n\nUse `ReadWorkspaceFile` when a workflow needs data that lives in the workspace pool\n(pricing sheets, config JSON, concierge-derived documents, CSV exports).\n\nUse `ListWorkspaceFiles` to discover what files exist or to drive a fan-out (one item per file).\n\nDo NOT use these nodes to write files writing is CP-mediated and deferred to v2.\n\nDo NOT base64-encode bytes onto `item.json`. Binary payloads always flow through\n`item.binary` via `ctx.binary`.\n\n## Quickstart\n\n```ts\nimport { readWorkspaceFileNode } from \"@codemation/core-nodes-workspace-files\";\n\n// Read the latest \"pricing.csv\" by name picks up the newest upload automatically.\nreadWorkspaceFileNode.create({ filename: \"pricing.csv\", binarySlot: \"data\" }, \"Read pricing CSV\", \"read-pricing-csv\");\n```\n\n```ts\n// Pin to an exact version — a later upload never changes what this reads.\nreadWorkspaceFileNode.create(\n { fileId: \"abc123def456\", binarySlot: \"data\" },\n \"Read pinned pricing CSV\",\n \"read-pricing-pinned\",\n);\n```\n\nFor full patterns (parse the bytes, scenario walkthrough, list + filter), use your\nharness's example-discovery tool: `find_examples({ query: \"workspace files\" })`.\n\n## Resolution modes\n\n| Mode | Config | Behaviour |\n| ------------------------- | ------------------------- | -------------------------------------------------------------------------------------------------- |\n| **latest-wins** (default) | `filename: \"pricing.csv\"` | 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 this ref. |\n\nUse **latest-wins** for \"always use the current sheet\" patterns.\nUse **pinned fileId** for reproducible/auditable runs (e.g., regression tests, compliance audits).\n\n## Binary slot handoff\n\n`ReadWorkspaceFile` streams the file's bytes into `item.binary[binarySlot]` (default `\"data\"`).\nThe node emits:\n\n```ts\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\nDownstream nodes read the bytes via `ctx.binary.openReadStream(item.binary[\"data\"])`.\nThe bytes are **never** base64-encoded on `item.json`.\n\n## Concierge → digest → workflow pattern\n\nThis is the intended headline flow:\n\n```\nUser uploads PDF → CP Files UI stores it in the workspace pool\nConcierge sees upload → DocumentScanner digests it → writes \"report-digested.json\" back\nWorkflow runs (schedule/webhook) → ReadWorkspaceFile(\"report-digested.json\") → acts\n```\n\nThe workflow is **decoupled** from the upload event. It reads the _derived_ file that the\nconcierge produced, not the raw upload. The concierge's job is to bridge the raw-upload world\nand the structured-data world.\n\nKey boundaries:\n\n- **CP side (write)**: raw file ingest, concierge digest, derived file write, Files UI.\n- **Workflow side (read)**: `ReadWorkspaceFile` + `ListWorkspaceFiles` only.\n\n## Anti-patterns\n\n- Do NOT tell users to read the raw PDF upload in a workflowpoint at the concierge-derived JSON.\n- Do NOT base64-encode file bytes onto `item.json` use `item.binary[slot]` + `ctx.binary`.\n- Do NOT attempt to write a file from a workflow node there is no write surface in v1.\n- Do NOT assume `WORKSPACE_ID` is always set — in local dev without CP integration, the storage\n token resolves to `undefined`. Add a guard if your workflow runs in dev mode.\n\n## Node reference\n\n### `listWorkspaceFilesNode`\n\n```ts\nlistWorkspaceFilesNode.create(\n {\n filenameFilter?: string; // optional substring match (case-insensitive)\n },\n \"List files\",\n \"list-files\",\n)\n```\n\nOutput per item: `{ fileId, filename, contentType, size, lastModified }`. Sorted newest-first.\n\n### `readWorkspaceFileNode`\n\n```ts\nreadWorkspaceFileNode.create(\n {\n filename?: string; // latest-wins resolution\n fileId?: string; // pinned resolution (takes precedence over filename)\n binarySlot?: string; // default: \"data\"\n maxBytes?: number; // default: 100 MiBraise for large files\n },\n \"Read file\",\n \"read-file\",\n)\n```\n\nEither `filename` or `fileId` must be set. Output: metadata JSON + bytes in `item.binary[binarySlot]`.\n"
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
+ "format": "doc",
380
+ "verified": false,
381
+ "l2": "- Codemation Workspace Files\n - The lifecycle — how an automation gets at a platform-managed file\n - A complete read-and-use workflow\n - Resolution modes (read)\n - Binary slot handoff\n - Node reference\n - `listWorkspaceFilesNode` — discover what's in the pool\n - `readWorkspaceFileNode` — read a file's bytes\n - `writeWorkspaceFileNode` — produce a file back into the pool\n - Gotchas\n - Read next when needed"
382
+ },
383
+ {
384
+ "name": "credentials",
385
+ "description": "Explains how the concierge connects a credential — the slot/type/instance model and how it presents and binds API-key, OAuth2, and MCP credentials. Use whenever a workflow needs an external account connected (an API key, an OAuth login, an MCP server) before it can run.",
386
+ "tags": [
387
+ "audience:concierge",
388
+ "credentials",
389
+ "oauth",
390
+ "mcp",
391
+ "api-key",
392
+ "binding"
393
+ ],
394
+ "sourcePath": "skills/concierge/credentials/SKILL.md",
395
+ "dependencies": {},
396
+ "code": "---\nname: credentials\ndescription: Explains how the concierge connects a credential — the slot/type/instance model and how it presents and binds API-key, OAuth2, and MCP credentials. Use whenever a workflow needs an external account connected (an API key, an OAuth login, an MCP server) before it can run.\ncompatibility: Concierge planning skill — conversation + binding guidance, no code.\ntags: audience:concierge, credentials, oauth, mcp, api-key, binding\n---\n\n# Codemation Credentials\n\n## Mental model\n\nFour words run through everything here:\n\n- **Credential TYPE** — a schema for one kind of connection (e.g. \"Odoo API\", \"Gmail\").\n It declares `publicFields` (config, e.g. base URL) and `secretFields` (secret material, e.g.\n an API key or token).\n- **Credential INSTANCE** — one concrete connection the owner has set up against a type (their\n actual Odoo, their actual mailbox). A type can have several instances (personal vs work Gmail).\n- **SLOT** — a credential requirement a node declares. The binding key is\n `(workflowId, nodeId, slotKey)`.\n- **BIND** — pointing a slot at an instance, so the node authenticates with that connection at run time.\n\nThe division of labour: **the builder DECLARES and USES** slots (a node says \"I need an Odoo\ncredential\"); **the concierge BINDS** an instance into each slot. A workflow can't be activated while\nany required slot has no healthy bound instance — so binding is the concierge's job to close out.\n\n## Forms render themselves — you collect and bind\n\nYou do **not** hand-build credential forms. The control-plane UI and the credential tools render the\nfields automatically from the type's `publicFields` / `secretFields` schema served by the host. Each\nfield carries its own presentation: `type` (`string` / `password` / `textarea` / `json` / `boolean`),\nwhether it's `required`, and `visibility: \"advanced\"` (renders in a collapsed section for power-user\nfields). You pick the right credential type, walk the owner through the rendered fields, and bind.\n\n**Secrets go straight to the host, never into the chat.** When the owner types an API key or password,\nit is submitted directly to the host as secret material — you never echo it, store it in the\nconversation, or pass it through a tool argument. Public config (a base URL, a database name) is fine\nin conversation; secret material is not.\n\n## The three presentation patterns\n\nThese are three _shapes the form takes_, not three different credential systems. Only OAuth2 is a\ndistinct auth flow under the hood — API-key and MCP both reduce to \"render the schema, then bind\".\n\n```text\nAPI-key / basic-auth credential\n - the type declares plain publicFields + secretFields (e.g. baseUrl + apiKey, or username + password)\n - you: surface the form → owner fills it → secrets go to the host → test → bind to the slot\n - the host's test() probes the real service; a \"failing\" result blocks activation, so resolve it first\n\nOAuth2 credential (redirect flow)\n - the type carries an oauth2 auth definition (provider, scopes, authorize/token URLs)\n - there is NO secret field to type — the token comes from the redirect, not from chat\n - you: surface the connection → owner clicks Connect → redirected to the provider → grants access\n → the host stores the returned token as material → bind the resulting instance to the slot\n - if a client id / secret field is part of publicFields, those are config, not the user's secret\n\nMCP server credential\n - an MCP server (from the control-plane registry) declares acceptedCredentialTypes — usually an\n OAuth connection (e.g. Gmail). MCP is NOT its own credential kind; you bind an instance of the\n accepted type exactly as above\n - one instance can serve both a node (e.g. a Gmail trigger) and the MCP server in the same workflow\n```\n\n## How a binding gets done\n\n```text\n1. find the workflow's open credential slots (the builder declared them on specific nodes)\n2. for each slot, identify the credential type it accepts\n3. is there already a healthy instance of that type? → reuse it; otherwise set one up:\n - API-key/basic: present the form → collect → secrets to host → test\n - OAuth2 (incl. most MCP): present Connect → owner completes the redirect\n4. bind the instance to the slot\n5. confirm every required slot is bound and healthy → only then can the workflow activate\n```\n\n## The things owners forget — surface them\n\n- **Connect before they expect to.** A workflow that \"looks built\" still won't run until its slots are\n bound. Drive the owner through connecting accounts as part of finishing, not after they hit an error.\n- **Reuse, don't re-ask.** If a healthy instance of the right type already exists, bind it rather than\n making the owner reconnect.\n- **A failing test is a blocker, not a warning.** The host tests a credential on Connect and before\n activation. Treat a \"failing\" status as work to resolve (wrong key, bad URL), not something to skip.\n- **One real test before live.** After binding, run the workflow once on real input so the owner sees\n the connection actually working end to end.\n\n## Read next when needed\n\n- `credential-development` — the builder side: declaring a credential type and node slots.\n- `mcp-capabilities` — discover MCP server ids and their accepted credential types.\n",
397
+ "format": "doc",
398
+ "verified": false,
399
+ "l2": "- Codemation Credentials\n - Mental model\n - Forms render themselves — you collect and bind\n - The three presentation patterns\n - How a binding gets done\n - The things owners forget — surface them\n - Read next when needed"
400
+ },
401
+ {
402
+ "name": "intake-automation-playbook",
403
+ "description": "Scopes an INTAKE automation (something arrives → gets understood → an action happens) and proactively surfaces the dimensions owners forget — traceability, duplicate-handling, human-review, testing on real data. Use whenever an owner wants to automate handling of incoming emails, messages, orders, leads, or form submissions.",
404
+ "tags": [
405
+ "audience:concierge",
406
+ "audience:concierge-only",
407
+ "intake",
408
+ "automation",
409
+ "planning"
410
+ ],
411
+ "sourcePath": "skills/concierge/intake-automation-playbook/SKILL.md",
412
+ "dependencies": {},
413
+ "code": "---\nname: intake-automation-playbook\ndescription: Scopes an INTAKE automation (something arrives → gets understood → an action happens) and proactively surfaces the dimensions owners forget — traceability, duplicate-handling, human-review, testing on real data. Use whenever an owner wants to automate handling of incoming emails, messages, orders, leads, or form submissions.\ncompatibility: Concierge planning skill — plain-language conversation guidance, no code.\ntags: audience:concierge, audience:concierge-only, intake, automation, planning\n---\n\n# Intake automation playbook\n\n## Mental model\n\nMost automations a business owner asks for are the same shape: an **intake automation**. Something\narrives → it gets understood → an action happens → the loop is closed. \"Process my order emails\", \"new\nleads into my CRM\", \"contact-form submissions into a spreadsheet\" are all this one pattern.\n\nYour job is not to transcribe what the owner asks — it's to recognise the pattern and **proactively fill\nin the parts they don't know to ask for**. A non-technical owner describes the happy path; they rarely\nmention what happens when the same email arrives twice, when the AI misreads a total, or how they'll\nknow it works on _their_ data. You supply that missing input.\n\n## When to use\n\nThe moment a request looks like intake: an incoming thing (email / message / order / lead / form /\ndocument) that should be processed into an action. Walk the owner through the dimensions below,\n**suggesting** the ones they didn't raise — as recommendations, not a checklist. Don't use this for\none-off tasks, reports with no incoming trigger, or pure data lookups.\n\n## The nine dimensions\n\nThe owner usually volunteers 1, 2, and 7. **You** raise the rest.\n\n1. **Trigger — what starts it.** Confirm the exact source (which inbox / label / endpoint).\n2. **Ingest / understand — turn messy input into clean data.** Read PDFs and free text, pull out the fields.\n3. **Match + link + report.** The one most underestimated: each company / contact / product must be\n matched to the record in their system and linked — and whatever can't be matched is reported, not\n silently dropped.\n4. **Human-review — a person checks before it commits, when it matters.** Pause on low-confidence or\n unusual cases for the owner's OK.\n5. **Traceability / audit trail — keep the original** so a human can reconstruct what happened. This is\n what makes them trust it.\n6. **Idempotency — don't process the same thing twice.** A re-sent or forwarded item must not double-enter.\n7. **Act — the actual output.** Be concrete: create the sale order, print the ticket, add the contact.\n8. **Notify / close the loop.** Reply to confirm or send a summary; mark the item handled.\n9. **Testability — the owner verifies on THEIR real data, no code.** Let them drop ~5 real items into a\n test path and see exactly what it would do before switching it on. For a mailbox workflow this means a\n **designated test folder** — ask for the folder/label name, resolve its id (via MCP), and pass it to\n the builder. Fabricating fixtures defeats the point: the test must exercise the real processing on the\n real input shape. See the `testing` skill for the principle and the integration skill (e.g. `gmail`)\n for the concrete mechanism.\n\n## Per-vertical extensions\n\nThe nine are the universal core; each vertical adds its own concerns. Pull the matching scenario skill\nif one exists (e.g. `scenario-procurement-intake`,\n`scenario-invoice-to-accounting`), or reason from the same shape: what's the domain's\nsystem-of-record (3), what's domain-specific to verify before committing (4), what counts as a\nduplicate (6)? If no scenario skill exists, the nine still apply.\n\n## Using it in conversation — not an interrogation\n\n- Lead with the owner's goal; weave the forgotten dimensions in as **suggestions you recommend**. Two or\n three well-placed \"I'd also set it up to…\" beats nine blunt questions.\n- Prioritise the high-value forgotten ones — **traceability, duplicate-handling, human-review, and a way\n to test on real data**. These are what owners are most grateful you raised.\n- Fold them into the plan you present, so it visibly covers them — that plan becomes the brief the\n builder works from.\n\n## Anti-patterns\n\n- **Mirroring the naive input** — building exactly the happy path, omitting traceability / dedup /\n review / testing. That's the failure this skill exists to prevent.\n- **Interrogating** — firing all nine as questions. Suggest and recommend instead.\n- **Recognise-without-link** — \"the AI read the order\" isn't done. If matched entities aren't linked into\n the system of record and the unmatched aren't reported, a human has to redo it.\n- **Silent skips on the hard cases** — low-confidence or unmatched items must surface, never be quietly\n guessed or dropped.\n- **Leaking how it's built** — keep it plain-language outcomes; never mention nodes, tools, or code to\n the owner.\n",
414
+ "format": "doc",
415
+ "verified": false,
416
+ "l2": "- Intake automation playbook\n - Mental model\n - When to use\n - The nine dimensions\n - Per-vertical extensions\n - Using it in conversation — not an interrogation\n - Anti-patterns"
417
+ },
418
+ {
419
+ "name": "scenario-invoice-to-accounting",
420
+ "description": "Plans an invoice-to-accounting automation — an incoming invoice is scanned, its vendor/amount/line-items extracted, and posted to an accounting system, with low-confidence ones held for review. Use when an owner wants supplier invoices read off and pushed into their accounting system automatically.",
421
+ "tags": [
422
+ "scenario",
423
+ "invoice",
424
+ "accounting",
425
+ "ocr",
426
+ "document",
427
+ "confidence",
428
+ "audience:concierge"
429
+ ],
430
+ "sourcePath": "skills/concierge/scenario-invoice-to-accounting/SKILL.md",
431
+ "dependencies": {},
432
+ "code": "---\nname: scenario-invoice-to-accounting\ndescription: Plans an invoice-to-accounting automation — an incoming invoice is scanned, its vendor/amount/line-items extracted, and posted to an accounting system, with low-confidence ones held for review. Use when an owner wants supplier invoices read off and pushed into their accounting system automatically.\ntags: scenario, invoice, accounting, ocr, document, confidence, audience:concierge\n---\n\n# Invoice → accounting — scenario guidance\n\nA business receives supplier invoices and wants the key fields read off and pushed into their\naccounting system automatically — with the uncertain ones held back for a person to check. This is the\nshape to plan from; build each step from the skills listed below.\n\n## The flow\n\n```text\non invoice arriving (webhook / upload / email)\n → scan the document (invoice analyzer, per-field confidence)\n → normalise the fields (vendor, invoice number, date, total, tax, line items)\n → if any key field's confidence is below threshold → human review, else continue\n → post the structured invoice to the accounting API\n → keep the original document + the posted result audited\n```\n\n## What to ask the owner first\n\n- How do invoices arrive — a webhook/upload, an email attachment, a watched folder?\n- Which accounting system, and what's its API for creating a bill/expense? Which fields does it need?\n- What confidence is \"safe to auto-post\" vs \"send to a human\"? (the scanner returns per-field confidence)\n- Which fields matter — vendor, invoice number, date, total, tax, line items?\n- How will they test it before going live — which ~5 real invoices?\n\n## Build it from these skills\n\n- `workflow-dsl` — the builder, trigger, and flow-control spine. Start here.\n- `workflow-dsl` — receive the invoice by webhook/upload; or `gmail` if it arrives by email.\n- `document-ai` — the invoice analyzer with `includeConfidence` for per-field confidence.\n- `workflow-dsl` — normalise the extracted fields into the shape the accounting API wants.\n- `human-in-the-loop` — the confidence gate that routes uncertain invoices to review.\n- `workflow-dsl` — post to the accounting API.\n- `credentials` — connect the accounting API and bind it to the slot the builder declared.\n- `testing` — let them dry-run on real invoices before it goes live.\n\n## The things owners forget — surface them\n\n- **Gate on confidence** — don't auto-post a low-confidence amount; route it to review.\n- **Idempotency** — the same invoice arriving twice must not post twice (dedupe on invoice number).\n- **Traceability** — keep the original document and a record of what was posted.\n- **Testability is part of done** — let them dry-run on a handful of real invoices before going live.\n",
433
+ "format": "doc",
434
+ "verified": false,
435
+ "l2": "- Invoice → accounting — scenario guidance\n - The flow\n - What to ask the owner first\n - Build it from these skills\n - The things owners forget — surface them"
436
+ },
437
+ {
438
+ "name": "scenario-procurement-intake",
439
+ "description": "Plans an order-intake automation — order emails are read, matched into the customer's ERP, and turned into sales orders, with a human in the loop for the uncertain ones. Use when an owner wants incoming purchase orders processed into their ERP (Odoo, AFAS, Salesforce, …) automatically.",
440
+ "tags": [
441
+ "scenario",
442
+ "procurement",
443
+ "order-intake",
444
+ "erp",
445
+ "gmail",
446
+ "ocr",
447
+ "audience:concierge"
448
+ ],
449
+ "sourcePath": "skills/concierge/scenario-procurement-intake/SKILL.md",
450
+ "dependencies": {},
451
+ "code": "---\nname: scenario-procurement-intake\ndescription: Plans an order-intake automation — order emails are read, matched into the customer's ERP, and turned into sales orders, with a human in the loop for the uncertain ones. Use when an owner wants incoming purchase orders processed into their ERP (Odoo, AFAS, Salesforce, …) automatically.\ntags: scenario, procurement, order-intake, erp, gmail, ocr, audience:concierge\n---\n\n# Procurement order-intake — scenario guidance\n\nA dealer or distributor receives purchase orders by email — usually a PDF — and wants them turned into\nsales orders in their ERP automatically and safely, with a human checking the uncertain ones. The ERP\nis **whatever the owner uses** (Odoo, AFAS, Salesforce, …) — ask, don't assume. This is the shape to\nplan from; build each step from the skills listed below.\n\n## The flow\n\n```text\non new mail @ \"orders\" label\n → OCR the attachment + read the body\n → [agent] router : is this really an order? (order / not-order / needs-human)\n → [agent] extractor : company, buyer, PO number, line items (description, code, qty) as structured output\n → erp-sync : MATCH company + contacts + products in the customer's ERP, LINK them into a new\n sales order, REPORT anything unmatched — never silently drop it\n → if low-confidence or unusual → human review gate, else continue\n → create the sales order (header + lines for the matched items)\n → reply to confirm + label the mail processed\n → keep the original email + PDF audited throughout\n```\n\n## What to ask the owner first\n\n- Which Gmail label do order emails land on? Are orders a PDF attachment, inline text, or both?\n- **Which ERP / business system do they use** (Odoo, AFAS, Salesforce, …)? products matched by code/SKU?\n existing customers to match? which company owns the order?\n- The buyer: is it the sender, or a buyer address inside the mail? any company to exclude (their own)?\n default country / currency?\n- How will they test it before going live — which ~5 real order emails?\n\n## Build it from these skills\n\n- `workflow-dsl` — the builder, trigger, and flow-control spine. Start here.\n- `gmail` — trigger on the label; reply and label the mail processed.\n- `document-ai` — OCR the attachment (managed, no Azure key).\n- `ai-agent` — the router and the extractor (with an output schema). The agents classify and extract;\n the ERP sync itself is deterministic integration work, not an agent.\n- `connect-external-systems` — **how to reach the ERP**: once you know which ERP the owner uses, follow\n the routing rule — a specialized node if one exists (e.g. `odoo`), else an MCP-backed agent, else\n `defineRestNode`. Never hand-roll the ERP's HTTP/JSON-RPC in a Callback.\n- `human-in-the-loop` — the review gate, on the live path, not beside a NoOp.\n- `testing` — TestTrigger reads a label, IsTestRun guards live writes, assertions.\n- `credential-development` — author or bind the ERP credential the integration nodes declare.\n\n## The things owners forget — surface them\n\n- **Match → link → report** every entity (company, contacts, products): recognise it, match the ERP\n record, link it, and report what didn't match for review. Recognise-only isn't enough.\n- **Traceability** — keep the original email + PDF attached and audited.\n- **Idempotency** — the same mail processed twice must not create a duplicate sales order.\n- **Testability is part of done** — the workflow is tested on **real order emails from a designated test folder**, not fabricated data. Ask the owner for the test-folder name, resolve its Gmail label id (via MCP), and pass it to the builder in the build task. The builder will `report_flag` a gap if it's missing. See the `testing` and `gmail` skills for the mechanism.\n",
452
+ "format": "doc",
453
+ "verified": false,
454
+ "l2": "- Procurement order-intake — scenario guidance\n - The flow\n - What to ask the owner first\n - Build it from these skills\n - The things owners forget — surface them"
145
455
  }
146
456
  ]
147
457
  }