@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
@@ -0,0 +1,498 @@
1
+ ---
2
+ name: odoo
3
+ 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).
4
+ compatibility: Requires @codemation/core-nodes-odoo. An Odoo credential (URL + database + API key) binds on slot "odoo".
5
+ tags: odoo, erp, sale-order, partner, product, match, link, crud, call_kw, credential, integration
6
+ uses: "@codemation/core-nodes-odoo, @codemation/core-nodes, @codemation/core"
7
+ ---
8
+
9
+ # Odoo nodes
10
+
11
+ The `@codemation/core-nodes-odoo` plugin talks to Odoo over JSON-RPC. Each node **declares the `odoo`
12
+ credential slot itself** (`credentials: { odoo }`) — so the moment you use one, the platform knows the
13
+ workflow needs an Odoo connection and prompts the operator to bind it before activation. You do **not**
14
+ hand-roll `fetch`/JSON-RPC and you do **not** declare the slot separately.
15
+
16
+ **Discipline:** author straight from this file, then run `verify_workflow` and fix only what it flags.
17
+ Use `workflow-dsl` for the surrounding builder, trigger, and flow-control surface.
18
+
19
+ ## The nodes
20
+
21
+ | Node | Odoo op | Use for |
22
+ | ---------------- | ------------- | --------------------------------------------------------------- |
23
+ | `odooQueryNode` | `search_read` | MATCH records — find a partner by email, products by code |
24
+ | `odooCreateNode` | `create` | CREATE a record — a `sale.order` header, a partner |
25
+ | `odooReadNode` | `read` | read one record's fields by id |
26
+ | `odooUpdateNode` | `write` | update a record by id |
27
+ | `odooDeleteNode` | `unlink` | delete a record by id |
28
+ | `odooCallKwNode` | any `call_kw` | anything the CRUD nodes don't cover (e.g. `sale.order` + lines) |
29
+
30
+ ## MATCH → LINK → REPORT (the order-intake shape)
31
+
32
+ `odooQueryNode` runs `search_read(model, domain, fields)` and **fans out one item per matched row** —
33
+ this is the generic node fan-out behaviour (a node returning an array emits one item per element; see
34
+ `workflow-dsl` → **Fan-out & fan-in**). Zero rows ⇒ zero items downstream, so branch on that to REPORT
35
+ what didn't match, never silently drop it. Each downstream item's `item.json` is one `OdooQueryOutputRow`.
36
+ `odooCreateNode` writes a record (e.g. a `sale.order`) and emits its new `id`; LINK the matched partner
37
+ into it with `itemExpr`.
38
+
39
+ **For every entity in an order-intake workflow (company, contacts, products), apply the same three-step
40
+ discipline:**
41
+
42
+ 1. **MATCH** — run `odooQueryNode` to search for the ERP record. Fan-out: zero rows = unmatched.
43
+ 2. **LINK** — wire the matched record's `id` into the downstream create/update via `itemExpr`.
44
+ 3. **REPORT** — when a record CANNOT be matched, do NOT drop it silently and do NOT fabricate an id.
45
+ Instead, report it: write an audit note / human-review flag / `unmatchedItems` field on `item.json`
46
+ so the concierge can surface it and the user can act. Keep the original email + any PDF attachment
47
+ on `item.binary` throughout so the audit trail is complete.
48
+
49
+ ```typescript
50
+ import { createWorkflowBuilder, ManualTrigger, Callback } from "@codemation/core-nodes";
51
+ import { itemExpr } from "@codemation/core";
52
+ import type { Items } from "@codemation/core";
53
+ import { odooQueryNode, odooCreateNode, type OdooQueryOutputRow } from "@codemation/core-nodes-odoo";
54
+
55
+ type OrderCanonical = { companyName: string; poNumber: string; buyerEmail: string };
56
+ type WithPartner = OrderCanonical & { partnerId: number };
57
+ type UnmatchedReport = OrderCanonical & { unmatchedReason: string };
58
+
59
+ export default createWorkflowBuilder({ id: "wf.odoo.order-sync", name: "Odoo order sync" })
60
+ .trigger(
61
+ new ManualTrigger<OrderCanonical>("Start", [
62
+ { companyName: "ACME", poNumber: "PO-001", buyerEmail: "buyer@acme.example" },
63
+ ]),
64
+ )
65
+ // MATCH: search_read res.partner by email — fans out one item per match (none ⇒ none downstream).
66
+ .then(
67
+ odooQueryNode.create<OrderCanonical>(
68
+ {
69
+ model: "res.partner",
70
+ fields: ["id", "name", "email"],
71
+ query: [{ field: "email", operator: "=", value: "buyer@acme.example" }],
72
+ mode: "and",
73
+ pagination: { limit: 1 },
74
+ },
75
+ "Match customer",
76
+ "match-partner",
77
+ ),
78
+ )
79
+ // LINK + CREATE: each item is now one matched partner row — wire its id into a new sale.order.
80
+ .then(
81
+ odooCreateNode.create(
82
+ {
83
+ model: "sale.order",
84
+ values: itemExpr<Record<string, unknown>, OdooQueryOutputRow>(({ item }) => ({
85
+ partner_id: Number(item.json["id"]),
86
+ origin: "order-intake",
87
+ })),
88
+ },
89
+ "Create sale order",
90
+ "create-order",
91
+ ),
92
+ )
93
+ .build();
94
+ ```
95
+
96
+ **REPORT the unmatched (the missing stage agents often skip):** when the match query returns
97
+ zero rows, branch on that and produce an explicit unmatched record — not silence:
98
+
99
+ ```typescript
100
+ import { Callback } from "@codemation/core-nodes";
101
+ import type { Items } from "@codemation/core";
102
+
103
+ type OrderCanonical = { companyName: string; poNumber: string; buyerEmail: string };
104
+ type UnmatchedReport = OrderCanonical & { unmatchedReason: string };
105
+
106
+ // In the zero-match branch: preserve the full order payload + record WHY it didn't match.
107
+ // Surface this as an audit note or a human-review item — never drop the unmatched entity.
108
+ const reportUnmatched = new Callback<OrderCanonical, UnmatchedReport>(
109
+ "Flag unmatched company",
110
+ (items: Items<OrderCanonical>) =>
111
+ items.map((i) => ({
112
+ json: {
113
+ ...i.json, // ← preserve the entire canonical payload
114
+ unmatchedReason: `No Odoo partner found for email "${i.json.buyerEmail}" (company: "${i.json.companyName}") — needs manual review`,
115
+ },
116
+ })),
117
+ { id: "flag-unmatched-company" },
118
+ );
119
+ export {};
120
+ ```
121
+
122
+ ## Fuzzy matching? Use an Odoo AGENT, not a deterministic chain
123
+
124
+ When you need to match real-world text (company names, product descriptions, buyer contacts) against Odoo records, a deterministic `odooQueryNode` chain **fails**:
125
+
126
+ - `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.
127
+ - 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`.
128
+ - 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.
129
+
130
+ **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.
131
+
132
+ ### The pattern: `AgentToolFactory.asTool(odooNode, { mapInput, mapOutput })` for each entity
133
+
134
+ Wrap each Odoo operation as an agent tool. The key discipline:
135
+
136
+ - **`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.
137
+ - **`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.
138
+ - **`onRejected: "halt"`** on `request_human_review` so the workflow halts when a human rejects a critical gap.
139
+
140
+ ### MATCH → LINK → REPORT system prompt
141
+
142
+ The agent's system prompt enforces three phases:
143
+
144
+ 1. **MATCH**: for each entity, call the corresponding search tool. Accept the result; never invent an id.
145
+ 2. **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.
146
+ 3. **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.
147
+
148
+ ### Compilable example
149
+
150
+ ```typescript
151
+ import { AIAgent, CodemationChatModelConfig, inboxApproval } from "@codemation/core-nodes";
152
+ import { AgentToolFactory } from "@codemation/core";
153
+ import { odooQueryNode, odooCreateNode, odooCallKwNode, type OdooQueryOutputRow } from "@codemation/core-nodes-odoo";
154
+ import { z } from "zod";
155
+ import type { Item } from "@codemation/core";
156
+
157
+ // --- Tool 1: search res.partner by email (node-backed) ---
158
+ // Static config on .create(): model + fields + pagination (the "outer shape").
159
+ // mapInput supplies the dynamic per-call query from the agent's args.
160
+ // mapOutput handles zero-match safely — never throws, never fabricates an id.
161
+ const odoo_search_partner = AgentToolFactory.asTool(
162
+ odooQueryNode.create(
163
+ { model: "res.partner", fields: ["id", "name", "email"], mode: "and", pagination: { limit: 1 } },
164
+ "Search partner",
165
+ "odoo-search-partner",
166
+ ),
167
+ {
168
+ name: "odoo_search_partner",
169
+ description: "Search res.partner by email. Returns { found: false, id: null } on no match — NEVER fabricate an id.",
170
+ inputSchema: z.object({
171
+ email: z.string().describe("Email address to search for"),
172
+ }),
173
+ outputSchema: z.object({
174
+ found: z.boolean(),
175
+ id: z.number().nullable(),
176
+ name: z.string().nullable(),
177
+ }),
178
+ mapInput: ({ input }) => ({ json: { query: [{ field: "email", operator: "=", value: input.email }] } }),
179
+ mapOutput: ({ outputs }: { outputs: { main?: ReadonlyArray<{ json: unknown }> } }) => {
180
+ const row = outputs.main?.[0]?.json as OdooQueryOutputRow | undefined;
181
+ return row
182
+ ? { found: true, id: Number(row["id"]), name: String(row["name"] ?? "") }
183
+ : { found: false, id: null, name: null };
184
+ },
185
+ },
186
+ );
187
+
188
+ // --- Tool 2: search product.template by description or code (node-backed) ---
189
+ const odoo_search_product = AgentToolFactory.asTool(
190
+ odooQueryNode.create(
191
+ { model: "product.template", fields: ["id", "name", "default_code"], mode: "or", pagination: { limit: 3 } },
192
+ "Search product",
193
+ "odoo-search-product",
194
+ ),
195
+ {
196
+ name: "odoo_search_product",
197
+ description:
198
+ "Search product.template by description (ilike) or exact product code. " +
199
+ "Returns the best match or { found: false } on no match.",
200
+ inputSchema: z.object({
201
+ description: z.string().describe("Product description to fuzzy-match"),
202
+ code: z.string().optional().describe("Exact product code to try first"),
203
+ }),
204
+ outputSchema: z.object({ found: z.boolean(), id: z.number().nullable(), name: z.string().nullable() }),
205
+ mapInput: ({ input }) => ({
206
+ json: {
207
+ query: [
208
+ ...(input.code ? [{ field: "default_code", operator: "=", value: input.code }] : []),
209
+ { field: "name", operator: "ilike", value: input.description },
210
+ ],
211
+ },
212
+ }),
213
+ mapOutput: ({ outputs }: { outputs: { main?: ReadonlyArray<{ json: unknown }> } }) => {
214
+ const row = outputs.main?.[0]?.json as OdooQueryOutputRow | undefined;
215
+ return row
216
+ ? { found: true, id: Number(row["id"]), name: String(row["name"] ?? "") }
217
+ : { found: false, id: null, name: null };
218
+ },
219
+ },
220
+ );
221
+
222
+ // --- Tool 3: create sale.order (node-backed) ---
223
+ const odoo_create_sale_order = AgentToolFactory.asTool(
224
+ odooCreateNode.create({ model: "sale.order" }, "Create sale order", "odoo-create-sale-order"),
225
+ {
226
+ name: "odoo_create_sale_order",
227
+ description: "Create a sale.order with matched partner and order lines.",
228
+ inputSchema: z.object({
229
+ partnerId: z.number().describe("res.partner id (billing contact or company)"),
230
+ clientOrderRef: z.string().nullable().describe("Customer PO or reference number"),
231
+ orderLines: z.array(
232
+ z.object({
233
+ productId: z.number().nullable().describe("product.template id; null for unmatched lines"),
234
+ name: z.string().describe("Line description (required when productId is null)"),
235
+ productUomQty: z.number().default(1),
236
+ }),
237
+ ),
238
+ }),
239
+ outputSchema: z.object({ saleOrderId: z.number().nullable() }),
240
+ mapInput: ({ input }) => ({
241
+ json: {
242
+ values: {
243
+ partner_id: input.partnerId,
244
+ client_order_ref: input.clientOrderRef,
245
+ order_line: input.orderLines.map((l) => [
246
+ 0,
247
+ 0,
248
+ { product_id: l.productId ?? false, name: l.name, product_uom_qty: l.productUomQty },
249
+ ]),
250
+ },
251
+ },
252
+ }),
253
+ mapOutput: ({ outputs }: { outputs: { main?: ReadonlyArray<{ json: unknown }> } }) => {
254
+ const row = outputs.main?.[0]?.json as Record<string, unknown> | undefined;
255
+ return { saleOrderId: row?.id != null ? Number(row["id"]) : null };
256
+ },
257
+ },
258
+ );
259
+
260
+ // --- Tool 4: HITL escalation (node-backed) ---
261
+ const request_human_review = AgentToolFactory.asTool(
262
+ inboxApproval.create(
263
+ {
264
+ title: ({ item }: { item: Item }) => String((item.json as { title?: unknown }).title ?? "Review needed"),
265
+ body: ({ item }: { item: Item }) => String((item.json as { body?: unknown }).body ?? ""),
266
+ priority: "high",
267
+ timeout: "8h",
268
+ onTimeout: "halt",
269
+ },
270
+ "Human review request",
271
+ ),
272
+ {
273
+ name: "request_human_review",
274
+ description:
275
+ "Escalate to a human when a critical entity (company, key product) cannot be matched. " +
276
+ "Call BEFORE creating the order. Halt if the reviewer rejects.",
277
+ onRejected: "halt",
278
+ inputSchema: z.object({
279
+ title: z.string(),
280
+ reason: z.string(),
281
+ missingEntities: z.array(z.string()),
282
+ }),
283
+ outputSchema: z.object({ approved: z.boolean(), note: z.string().optional() }),
284
+ mapInput: ({ input, item }) => ({
285
+ json: {
286
+ ...((item.json as Record<string, unknown>) ?? {}),
287
+ title: input.title,
288
+ body: `${input.reason}\n\nCould not match: ${input.missingEntities.join(", ")}`,
289
+ },
290
+ }),
291
+ mapOutput: ({ outputs }: { outputs: { main?: ReadonlyArray<{ json: unknown }> } }) => {
292
+ const first = outputs.main?.[0]?.json as { decision?: { status?: string; note?: string } } | undefined;
293
+ return { approved: first?.decision?.status === "approved", note: first?.decision?.note };
294
+ },
295
+ },
296
+ );
297
+
298
+ // --- Tool 4b: post audit comment — node-backed via odooCallKwNode message_post ---
299
+ // Post audit notes via a node-backed `asTool(odooCallKwNode … message_post)` tool — NOT a stubbed callableTool; the real node does the RPC.
300
+ const odoo_post_audit_comment = AgentToolFactory.asTool(
301
+ odooCallKwNode.create({ model: "sale.order", method: "message_post" }, "Post audit comment", "post-audit"),
302
+ {
303
+ name: "odoo_post_audit_comment",
304
+ description: "Post an audit chatter note on the sale.order summarising matched/unmatched entities.",
305
+ inputSchema: z.object({ saleOrderId: z.number(), body: z.string() }),
306
+ outputSchema: z.object({ posted: z.boolean() }),
307
+ mapInput: ({ input }) => ({ json: { args: [[input.saleOrderId]], kwargs: { body: input.body } } }),
308
+ mapOutput: () => ({ posted: true }),
309
+ },
310
+ );
311
+
312
+ // --- Shared types ---
313
+ const OdooSyncOutput = z.object({
314
+ saleOrderId: z.number().nullable(),
315
+ matched: z.object({
316
+ partnerId: z.number().nullable(),
317
+ productIds: z.array(z.number()),
318
+ }),
319
+ unmatched: z.object({
320
+ products: z.array(z.string()),
321
+ contacts: z.array(z.string()),
322
+ }),
323
+ });
324
+ type OdooSyncOutputT = z.infer<typeof OdooSyncOutput>;
325
+ type OrderT = { company: string; buyerEmail: string; lines: Array<{ desc: string; code?: string; qty: number }> };
326
+
327
+ // --- The Odoo sync agent ---
328
+ new AIAgent<OrderT, OdooSyncOutputT>({
329
+ name: "Odoo sync agent",
330
+ id: "odoo-sync-agent",
331
+ messages: [
332
+ {
333
+ role: "system",
334
+ content:
335
+ "You are an Odoo integration agent. Follow MATCH → LINK → REPORT strictly:\n" +
336
+ "\n" +
337
+ "1. MATCH each entity:\n" +
338
+ " - Company/partner: odoo_search_partner(email)\n" +
339
+ " - Each line item: odoo_search_product(description, code?) — try code first, then description\n" +
340
+ "\n" +
341
+ "2. LINK: call odoo_create_sale_order with the matched partnerId and orderLines.\n" +
342
+ " - For unmatched products, set productId: null and include the original line description.\n" +
343
+ " - NEVER invent a partnerId. If company is not found AND order has >2 lines: call request_human_review FIRST.\n" +
344
+ "\n" +
345
+ "3. REPORT: call odoo_post_audit_comment with a summary of matched ids and unmatched entities.\n" +
346
+ "\n" +
347
+ "Return {saleOrderId, matched:{partnerId, productIds:[]}, unmatched:{products:[], contacts:[]}}.",
348
+ },
349
+ { role: "user", content: ({ item }) => JSON.stringify(item.json, null, 2) },
350
+ ],
351
+ chatModel: new CodemationChatModelConfig("Managed AI", "medium"),
352
+ tools: [
353
+ odoo_search_partner,
354
+ odoo_search_product,
355
+ odoo_create_sale_order,
356
+ odoo_post_audit_comment,
357
+ request_human_review,
358
+ ],
359
+ outputSchema: OdooSyncOutput,
360
+ guardrails: { maxTurns: 20 },
361
+ });
362
+ export {};
363
+ ```
364
+
365
+ ### Why this beats a deterministic node chain
366
+
367
+ | Concern | Deterministic chain | Odoo agent |
368
+ | --------------------------------------- | -------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- |
369
+ | 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 |
370
+ | 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 |
371
+ | Fuzzy matching (typos, partial names) | Hard — requires multiple `odooQueryNode` branches | Natural — agent reasons over candidates, tries code then description |
372
+ | Escalate unresolvable gaps | Requires extra HITL node wired correctly outside the chain | Built-in — agent calls `request_human_review` tool |
373
+
374
+ **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.
375
+
376
+ ## Querying — the `query` domain
377
+
378
+ `query` is a list of `{ field, operator, value }` leaves combined with `mode: "and" | "or"`; it compiles
379
+ to an Odoo domain. Common operators: `"="`, `"!="`, `"ilike"` (fuzzy, case-insensitive — use it for
380
+ product-name matching), `"in"`. `pagination` takes `{ limit, offset, order }`. Set `includeTotalCount: true`
381
+ to add a `totalCount` to each row when you need the full match count.
382
+
383
+ ```typescript
384
+ import { odooQueryNode } from "@codemation/core-nodes-odoo";
385
+
386
+ // Fuzzy product match by code OR name — fans out every candidate so a Callback can rank/report.
387
+ const findProducts = odooQueryNode.create(
388
+ {
389
+ model: "product.product",
390
+ fields: ["id", "default_code", "name"],
391
+ query: [
392
+ { field: "default_code", operator: "=", value: "PUMP-100" },
393
+ { field: "name", operator: "ilike", value: "centrifugal pump" },
394
+ ],
395
+ mode: "or",
396
+ pagination: { limit: 5 },
397
+ includeTotalCount: true,
398
+ },
399
+ "Find products",
400
+ "find-products",
401
+ );
402
+ ```
403
+
404
+ ## Per-item values with `itemExpr`
405
+
406
+ Static config is the default. When a field must come from the current item (the matched id, an
407
+ extracted value), wrap it with `itemExpr(...)` — the engine resolves it per item before `execute`.
408
+
409
+ ```typescript
410
+ import { itemExpr } from "@codemation/core";
411
+ import { odooReadNode } from "@codemation/core-nodes-odoo";
412
+
413
+ const readMatched = odooReadNode.create(
414
+ {
415
+ model: "res.partner",
416
+ fields: ["id", "name", "email"],
417
+ id: itemExpr<number, { id: number }>(({ item }) => item.json.id),
418
+ },
419
+ "Read matched partner",
420
+ "read-partner",
421
+ );
422
+ ```
423
+
424
+ ## Chaining after any upstream (humanApproval, transforms, triggers)
425
+
426
+ Every Odoo node's input brand is `any`, so `.then(odooXxxNode.create(...))` compiles after **any**
427
+ upstream — a trigger, a transform, a `humanApproval` step, or another Odoo node. Type-safe `itemExpr`
428
+ access to the upstream item shape comes from the `create<TUpstreamJson>()` generic, not from the
429
+ node's brand.
430
+
431
+ ```typescript
432
+ import { createWorkflowBuilder, ManualTrigger, inboxApproval } from "@codemation/core-nodes";
433
+ import { itemExpr } from "@codemation/core";
434
+ import { odooCallKwNode } from "@codemation/core-nodes-odoo";
435
+
436
+ type OrderJson = Record<string, unknown> & { orderId: string; vendor: string };
437
+
438
+ export default createWorkflowBuilder({ id: "wf.odoo.approval-chain", name: "Approval → Odoo" })
439
+ .trigger(new ManualTrigger<OrderJson>("Start", [{ orderId: "42", vendor: "ACME" }]))
440
+ // humanApproval emits OrderJson & { decision: HumanApprovalDecisionResult }
441
+ .humanApproval(inboxApproval, {
442
+ title: "Approve order?",
443
+ body: "Review the order.",
444
+ priority: "normal",
445
+ timeout: "24h",
446
+ onTimeout: "halt",
447
+ })
448
+ // create<UpstreamType>() gives typed item.json inside itemExpr; the node chains regardless of upstream shape
449
+ .then(
450
+ odooCallKwNode.create<OrderJson & { decision: unknown }>({
451
+ model: "sale.order",
452
+ method: "create",
453
+ args: itemExpr<unknown[], OrderJson & { decision: unknown }>(({ item }) => [
454
+ { partner_id: 1, origin: item.json.orderId },
455
+ ]),
456
+ }),
457
+ )
458
+ .build();
459
+ ```
460
+
461
+ ## Beyond CRUD — `odooCallKwNode`
462
+
463
+ A `sale.order` with its `order_line` is one `create` with nested commands, which is cleaner via the
464
+ generic node. `args`/`kwargs` map straight onto Odoo's `call_kw(model, method, args, kwargs)`.
465
+
466
+ ```typescript
467
+ import { odooCallKwNode } from "@codemation/core-nodes-odoo";
468
+
469
+ const createOrderWithLines = odooCallKwNode.create(
470
+ {
471
+ model: "sale.order",
472
+ method: "create",
473
+ args: [
474
+ {
475
+ partner_id: 1,
476
+ order_line: [
477
+ // Odoo command 0 = "create a new line"
478
+ [0, 0, { product_id: 42, product_uom_qty: 3 }],
479
+ ],
480
+ },
481
+ ],
482
+ },
483
+ "Create order + lines",
484
+ "create-order-lines",
485
+ );
486
+ ```
487
+
488
+ ## Credential
489
+
490
+ The `odoo` slot is declared by the nodes — bind an Odoo credential (instance URL + database + API key)
491
+ to it in the canvas before activating. See `credential-development` to author a new Odoo credential
492
+ type, or bind an existing one. Nothing to declare in the workflow beyond using the nodes.
493
+
494
+ ## Related
495
+
496
+ - `workflow-dsl` — the builder, trigger, and flow-control spine. Start here.
497
+ - `ai-agent` — extract structured order fields (company, line items) before the Odoo match.
498
+ - `document-ai` — OCR an order PDF into `{ markdown, fields }` upstream of the match.
@@ -1,15 +1,12 @@
1
1
  ---
2
- name: codemation-plugin-development
3
- 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.
4
- compatibility: Designed for Codemation plugin packages and the Codemation plugin starter template.
2
+ name: plugin-development
3
+ 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.
5
4
  tags: plugin, node, package
6
5
  ---
7
6
 
8
7
  # Codemation Plugin Development
9
8
 
10
- ## Mental model
11
-
12
- A 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.
9
+ A 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.
13
10
 
14
11
  ## When to use / when NOT
15
12
 
@@ -19,7 +16,7 @@ Use this skill for published plugin packages, plugin starter work, and sandbox-d
19
16
 
20
17
  ## Decision branches & gotchas
21
18
 
22
- **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.
19
+ **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.
23
20
 
24
21
  **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`.
25
22
 
@@ -6,6 +6,29 @@ Plugin authoring is a **framework-author / non-managed task**. Managed-mode agen
6
6
 
7
7
  ```ts
8
8
  import { definePlugin } from "@codemation/host/authoring";
9
+ import { defineCredential, defineNode } from "@codemation/core";
10
+
11
+ const myCredentialType = defineCredential({
12
+ key: "my-provider.api-key",
13
+ label: "My Provider API Key",
14
+ public: { baseUrl: "string" },
15
+ secret: { apiKey: "password" },
16
+ createSession(args) {
17
+ return { baseUrl: String(args.publicConfig.baseUrl ?? ""), apiKey: String(args.material.apiKey ?? "") };
18
+ },
19
+ test() {
20
+ return { status: "healthy", testedAt: new Date().toISOString() };
21
+ },
22
+ });
23
+
24
+ const myNode = defineNode({
25
+ key: "my-provider.ping",
26
+ title: "Ping",
27
+ credentials: { api: myCredentialType },
28
+ async execute() {
29
+ return { ok: true };
30
+ },
31
+ });
9
32
 
10
33
  export default definePlugin({
11
34
  nodes: [myNode],
@@ -47,25 +70,28 @@ Consumers discover the plugin through `package.json#codemation.plugin`, which mu
47
70
  - Build typed sessions in `createSession(...)`.
48
71
  - Implement `test(...)` so operators can validate configuration before activation.
49
72
  - For OAuth2 redirect flows, use the URL-template variant (`auth: { kind: "oauth2", authorizeUrl, tokenUrl, scopes }`).
50
- - See the `codemation-credential-development` skill for detailed credential patterns.
73
+ - See the `credential-development` skill for detailed credential patterns.
51
74
 
52
75
  ## Declaring MCP servers in a plugin
53
76
 
54
- > **Non-managed pattern.** In managed mode, MCP servers are loaded from the control plane — see `codemation-mcp-capabilities`. Plugin-declared MCP servers are for self-hosted / framework-author scenarios.
77
+ > **Non-managed pattern.** In managed mode, MCP servers are loaded from the control plane — see `mcp-capabilities`. Plugin-declared MCP servers are for self-hosted / framework-author scenarios.
55
78
 
56
79
  ```ts
57
- import { definePlugin } from "@codemation/host/authoring";
58
- import type { McpServerDeclaration } from "@codemation/host/authoring";
80
+ import type { McpServerDeclaration } from "@codemation/core";
59
81
 
60
- const myMcpServer: McpServerDeclaration = {
82
+ export const myMcpServer: McpServerDeclaration = {
61
83
  id: "my-provider-mcp", // globally unique slug /^[a-z0-9-]+$/
62
84
  displayName: "My Provider",
63
85
  description: "Exposes My Provider tools to AIAgent.",
64
- transport: "streamable-http",
86
+ transport: "http",
65
87
  url: "https://my-provider.example.com/mcp",
66
88
  acceptedCredentialTypes: ["my-provider.api-key"],
67
89
  };
90
+ ```
91
+
92
+ Pass the declaration to `definePlugin` alongside the plugin's nodes and credentials:
68
93
 
94
+ ```text
69
95
  export default definePlugin({
70
96
  nodes: [myNode],
71
97
  credentials: [myCredentialType],
@@ -79,18 +105,13 @@ Use plugin-declared MCP servers only when the provider has non-standard auth or
79
105
 
80
106
  ## WorkflowTestKit
81
107
 
82
- ```ts
83
- import { WorkflowTestKit } from "@codemation/core/testing";
84
- // For defineNode packages:
85
- import { registerDefinedNodes } from "@codemation/core/testing";
86
- registerDefinedNodes([myNode]);
87
- // Then use runNode(...) or run(...) for fuller graph tests.
88
- ```
108
+ Use `WorkflowTestKit` from `@codemation/core/testing`: construct it, register the plugin's defined nodes through its registration context, then run a workflow that wires them via `.create(...)` and assert on the emitted items.
89
109
 
90
110
  ## Binary payloads — never put bytes on item.json
91
111
 
92
- ```ts
93
- // Inside execute(items, ctx) when a node fetches binary content:
112
+ Inside `execute`, attach via `ctx.binary` (on the first arg) — never base64 on `item.json`:
113
+
114
+ ```text
94
115
  const stored = await ctx.binary.attach({
95
116
  name: "report.pdf",
96
117
  body: Buffer.from(bytes),
@@ -41,8 +41,8 @@ That file is the plugin repository's source composition root. Consumers should d
41
41
 
42
42
  The runtime persists each item's JSON into the runs table for telemetry, replay, and debugging. Putting megabyte-scale base64 strings in there bloats the database, slows queries, and makes telemetry unreadable. The binary system exists exactly for this: blobs live in object storage; the item JSON only carries a `BinaryAttachment` reference (`{ id, storageKey, mimeType, size, ... }`) under `item.binary[<slot-name>]`.
43
43
 
44
- ```ts
45
- // Inside execute(items, ctx) on a node that has fetched a file:
44
+ ```text
45
+ // Inside execute on a node that has fetched a file (ctx is on the first arg):
46
46
  const stored = await ctx.binary.attach({
47
47
  name: "report.pdf", // slot name (also the key under item.binary)
48
48
  body: Buffer.from(bytes), // Buffer / Uint8Array / Readable