@codemation/agent-skills 0.4.0 → 0.5.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +173 -0
- package/dist/metadata.json +358 -48
- package/package.json +3 -1
- package/skills/builder/ai-agent/SKILL.md +314 -0
- package/skills/builder/ai-agent/references/anti-patterns.md +24 -0
- package/skills/{codemation-cli → builder/cli}/SKILL.md +1 -8
- package/skills/builder/connect-external-systems/SKILL.md +191 -0
- package/skills/builder/credential-development/SKILL.md +86 -0
- package/skills/{codemation-credential-development → builder/credential-development}/references/credential-patterns.md +3 -3
- package/skills/builder/custom-node-development/SKILL.md +61 -0
- package/skills/builder/custom-node-development/references/credential-aware-nodes.md +52 -0
- package/skills/builder/custom-node-development/references/define-batch-node.md +54 -0
- package/skills/{codemation-custom-node-development → builder/custom-node-development}/references/define-node-per-item.md +14 -14
- package/skills/{codemation-custom-node-development → builder/custom-node-development}/references/node-patterns.md +33 -49
- package/skills/builder/document-ai/SKILL.md +167 -0
- package/skills/builder/execution-context/SKILL.md +436 -0
- package/skills/{codemation-framework-concepts → builder/framework-concepts}/SKILL.md +18 -18
- package/skills/builder/gmail/SKILL.md +327 -0
- package/skills/builder/human-in-the-loop/SKILL.md +82 -0
- package/skills/{codemation-mcp-capabilities → builder/mcp-capabilities}/SKILL.md +5 -12
- package/skills/builder/mcp-capabilities/references/agent-with-mcp.ts +24 -0
- package/skills/builder/msgraph/SKILL.md +338 -0
- package/skills/builder/odoo/SKILL.md +498 -0
- package/skills/{codemation-plugin-development → builder/plugin-development}/SKILL.md +4 -7
- package/skills/{codemation-plugin-development → builder/plugin-development}/references/plugin-anatomy.md +36 -15
- package/skills/{codemation-plugin-development → builder/plugin-development}/references/plugin-structure.md +2 -2
- package/skills/builder/rest-node/SKILL.md +148 -0
- package/skills/builder/testing/SKILL.md +142 -0
- package/skills/builder/workflow-dsl/SKILL.md +492 -0
- package/skills/builder/workspace-files/SKILL.md +191 -0
- package/skills/concierge/credentials/SKILL.md +91 -0
- package/skills/concierge/intake-automation-playbook/SKILL.md +78 -0
- package/skills/concierge/scenario-invoice-to-accounting/SKILL.md +48 -0
- package/skills/concierge/scenario-procurement-intake/SKILL.md +58 -0
- package/skills/codemation-ai-agent-node/SKILL.md +0 -66
- package/skills/codemation-ai-agent-node/references/anti-patterns.md +0 -11
- package/skills/codemation-credential-development/SKILL.md +0 -57
- package/skills/codemation-custom-node-development/SKILL.md +0 -61
- package/skills/codemation-custom-node-development/references/credential-aware-nodes.md +0 -38
- package/skills/codemation-custom-node-development/references/define-batch-node.md +0 -38
- package/skills/codemation-document-scanner/SKILL.md +0 -136
- package/skills/codemation-mcp-capabilities/references/agent-with-mcp.ts +0 -44
- package/skills/codemation-workflow-dsl/SKILL.md +0 -78
- package/skills/codemation-workflow-dsl/references/builder-patterns.md +0 -120
- package/skills/codemation-workflow-dsl/references/complete-example.md +0 -263
- package/skills/codemation-workflow-dsl/references/workflow-testing.md +0 -194
- package/skills/codemation-workspace-files/SKILL.md +0 -142
- /package/skills/{codemation-cli → builder/cli}/references/command-map.md +0 -0
- /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:
|
|
3
|
-
description:
|
|
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
|
-
|
|
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 `
|
|
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 `
|
|
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 `
|
|
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 {
|
|
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: "
|
|
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
|
-
|
|
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
|
-
|
|
93
|
-
|
|
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
|
-
```
|
|
45
|
-
// Inside execute
|
|
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
|