@alvera-ai/platform-sdk 0.10.0-rc.8 → 0.11.0

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 (66) hide show
  1. package/.agent/AGENTS.md +499 -0
  2. package/.agent/account_management.md +456 -0
  3. package/.agent/action_status_updaters.md +264 -0
  4. package/.agent/ai_agents.md +462 -0
  5. package/.agent/ai_sandbox.md +265 -0
  6. package/.agent/async.md +112 -0
  7. package/.agent/connected_apps.md +408 -0
  8. package/.agent/cookbook/_fixtures/README.md +106 -0
  9. package/.agent/cookbook/_fixtures/accounts_receivable/_customers_accounts_receivable_customer.liquid +32 -0
  10. package/.agent/cookbook/_fixtures/accounts_receivable/_customers_accounts_receivable_mdm.liquid +20 -0
  11. package/.agent/cookbook/_fixtures/accounts_receivable/stripe_customers_batch1.csv +5 -0
  12. package/.agent/cookbook/_fixtures/foundation/_lead_submissions_foundation_generic_table.liquid +33 -0
  13. package/.agent/cookbook/_fixtures/foundation/_lead_submissions_foundation_legal_entity.liquid +88 -0
  14. package/.agent/cookbook/_fixtures/foundation/_lead_submissions_foundation_mdm.liquid +48 -0
  15. package/.agent/cookbook/_fixtures/healthcare/_cahps_appointments_healthcare_appointment.liquid +47 -0
  16. package/.agent/cookbook/_fixtures/healthcare/_cahps_appointments_healthcare_mdm.liquid +24 -0
  17. package/.agent/cookbook/_fixtures/healthcare/_cahps_appointments_healthcare_patient.liquid +38 -0
  18. package/.agent/cookbook/_fixtures/healthcare/memorandum-of-association-01.png +0 -0
  19. package/.agent/cookbook/_fixtures/healthcare/sample_two_page.pdf +43 -0
  20. package/.agent/cookbook/_fixtures/payment_risk/_compliance_screenings_payment_risk_compliance_screening.liquid +59 -0
  21. package/.agent/cookbook/_fixtures/payment_risk/_compliance_screenings_payment_risk_mdm.liquid +36 -0
  22. package/.agent/cookbook/_fixtures/payment_risk/_payment_accounts_payment_risk_mdm.liquid +30 -0
  23. package/.agent/cookbook/_fixtures/payment_risk/_payment_accounts_payment_risk_payment_account.liquid +55 -0
  24. package/.agent/cookbook/_setup/accounts_receivable.md +282 -0
  25. package/.agent/cookbook/_setup/foundation.md +277 -0
  26. package/.agent/cookbook/_setup/healthcare.md +279 -0
  27. package/.agent/cookbook/_setup/payment_risk.md +283 -0
  28. package/.agent/cookbook/action-status-updaters.md +212 -0
  29. package/.agent/cookbook/ai-agent-invoke.md +243 -0
  30. package/.agent/cookbook/appointment-review-sms-workflow.md +761 -0
  31. package/.agent/cookbook/birthday-greeting-sms-trigger.md +656 -0
  32. package/.agent/cookbook/bulk-ingest.md +254 -0
  33. package/.agent/cookbook/contact-us-triage-with-llm.md +622 -0
  34. package/.agent/cookbook/custom-tables.md +201 -0
  35. package/.agent/cookbook/dunning-sms-for-delinquent.md +619 -0
  36. package/.agent/cookbook/invite-team.md +194 -0
  37. package/.agent/cookbook/kyc-notification-on-account-activation.md +619 -0
  38. package/.agent/cookbook/rest-fetch.md +246 -0
  39. package/.agent/cookbook/sanctions-screening-with-agent-review.md +733 -0
  40. package/.agent/cookbook/score-leads-with-llm-categorization.md +624 -0
  41. package/.agent/cookbook/system-templates.md +129 -0
  42. package/.agent/cookbook/triage-prospects-by-priority.md +533 -0
  43. package/.agent/cookbook/welcome-sms-for-customers.md +607 -0
  44. package/.agent/data_activation_clients.md +559 -0
  45. package/.agent/data_sources.md +235 -0
  46. package/.agent/datalakes.md +714 -0
  47. package/.agent/debugging.md +137 -0
  48. package/.agent/errors.md +190 -0
  49. package/.agent/generic_tables.md +351 -0
  50. package/.agent/interoperability_contracts.md +417 -0
  51. package/.agent/mdm.md +293 -0
  52. package/.agent/mutations.md +126 -0
  53. package/.agent/templates.md +98 -0
  54. package/.agent/tool-call-configs.md +90 -0
  55. package/.agent/tools.md +547 -0
  56. package/.agent/type_naming.md +129 -0
  57. package/.agent/workflows.md +617 -0
  58. package/README.md +178 -0
  59. package/dist/bin/platform-sdk.d.mts +1 -0
  60. package/dist/bin/platform-sdk.mjs +106 -0
  61. package/dist/bin/platform-sdk.mjs.map +1 -0
  62. package/dist/index.d.mts +1310 -44116
  63. package/dist/index.d.mts.map +1 -1
  64. package/dist/index.mjs +1194 -7344
  65. package/dist/index.mjs.map +1 -1
  66. package/package.json +19 -10
@@ -0,0 +1,129 @@
1
+ ---
2
+ title: "Capability: discover the platform's built-in row-mapping templates"
3
+ summary: A capability walk for system-template discovery. List the Liquid templates the platform ships for your datalake's data domain (`api.templates.systemTemplates`), read the human-readable catalog (`api.templates.metadata`), and pull one template's detail (`api.templates.metadataDetails`) — so you can start an interoperability contract from a platform template instead of hand-writing Liquid. Read-only; creates nothing.
4
+ industry: accounts_receivable
5
+ slug: system-templates
6
+ vitest_source:
7
+ - integration-tests/tests/accounts_receivable/system-templates.test.ts
8
+ - integration-tests/tests/accounts_receivable/bootstrap.test.ts
9
+ status: green
10
+ ---
11
+
12
+ # Capability
13
+
14
+ **What you get:** the list of row-mapping Liquid templates the platform already
15
+ ships for your datalake's data domain, so an interoperability contract can start
16
+ from a vetted template instead of hand-written Liquid.
17
+
18
+ `api.templates.*` is read-only discovery. Three calls:
19
+
20
+ - `systemTemplates(tenantSlug, datalakeSlug)` — the machine-readable list:
21
+ `{ data: [{ path, content, output_schema }] }`. The list is **filtered to your
22
+ datalake's data domain** plus globals — a payment-risk or healthcare template
23
+ never appears in an accounts-receivable datalake.
24
+ - `metadata(tenantSlug, datalakeSlug)` — the same catalog as a markdown summary.
25
+ - `metadataDetails(tenantSlug, datalakeSlug, basename, intent)` — one template's
26
+ detail by basename + intent.
27
+
28
+ This is **global** to every data domain — only the anchor template paths below
29
+ differ by domain. See `templates.md` for the reference, and
30
+ `interoperability_contracts.md` for how a template becomes a contract.
31
+
32
+ # Walkthrough
33
+
34
+ The `_setup/accounts_receivable.md` bootstrap already provisioned the tenant +
35
+ datalake and left `api`, `tenantSlug`, and `datalakeSlug` populated. This walk
36
+ starts from there.
37
+
38
+ ## 001 — list the system templates in scope for this datalake
39
+
40
+ `systemTemplates` returns every platform-shipped Liquid template visible to this
41
+ datalake: the templates for its data domain (here, `accounts_receivable`) plus
42
+ domain-agnostic globals (e.g. the SNS delivery-status poller). Each entry has a
43
+ `path`, the Liquid `content`, and an `output_schema` (`null` when the template
44
+ has no companion schema). The list is domain-scoped — templates for other data
45
+ domains (`healthcare`, `payment_risk`, `foundation`, …) are filtered out, so you
46
+ only see what is usable here.
47
+
48
+ ```typescript
49
+ const { data: templates } = await api.templates.systemTemplates(tenantSlug, datalakeSlug)
50
+
51
+ const paths = (templates.data ?? []).map((t) => t.path)
52
+ if (paths.length === 0) {
53
+ throw new Error('expected the platform to ship at least one system template')
54
+ }
55
+
56
+ // The accounts-receivable anchor template is present...
57
+ const arAnchor = 'data_activation/interoperability/accounts_receivable/stripe/customers_accounts_receivable_customer'
58
+ if (!paths.some((p) => p.includes(arAnchor))) {
59
+ throw new Error(`expected the AR customer template (${arAnchor}) in the list`)
60
+ }
61
+
62
+ // ...and templates for other data domains are filtered out of an AR datalake.
63
+ const otherDomains = ['healthcare', 'payment_risk', 'foundation', 'core_banking', 'service_commerce', 'trading']
64
+ const leaked = paths.filter((p) => otherDomains.some((d) => p.split('/').includes(d)))
65
+ if (leaked.length > 0) {
66
+ throw new Error(`cross-domain templates leaked into an AR datalake: ${leaked.join(', ')}`)
67
+ }
68
+ ```
69
+
70
+ ## 002 — read the catalog as markdown
71
+
72
+ `metadata` returns the same catalog as a markdown document — the form an agent
73
+ reads to decide which template to start from. It names the in-scope templates,
74
+ including the AR customer template by basename.
75
+
76
+ ```typescript
77
+ const { data: catalog } = await api.templates.metadata(tenantSlug, datalakeSlug)
78
+ if (typeof catalog !== 'string' || catalog.length === 0) {
79
+ throw new Error('expected a non-empty markdown template catalog')
80
+ }
81
+ if (!catalog.includes('customers_accounts_receivable_customer')) {
82
+ throw new Error('expected the AR customer template named in the catalog markdown')
83
+ }
84
+ ```
85
+
86
+ ## 003 — read one template's detail by basename + intent
87
+
88
+ `metadataDetails` pulls a single template's detail. Address it by its
89
+ **basename** (the template name without the path) and its **intent** (the path
90
+ prefix joined with underscores — here `data_activation_interoperability`). The
91
+ result is a markdown body you can show an operator or feed an agent before it
92
+ copies the template into a contract's `template_config`.
93
+
94
+ ```typescript
95
+ const { data: detail } = await api.templates.metadataDetails(
96
+ tenantSlug,
97
+ datalakeSlug,
98
+ 'customers_accounts_receivable_customer',
99
+ 'data_activation_interoperability',
100
+ )
101
+ if (typeof detail !== 'string' || detail.length === 0) {
102
+ throw new Error('expected a non-empty markdown detail for the AR customer template')
103
+ }
104
+ ```
105
+
106
+ # Gotchas
107
+
108
+ - **The list is domain-scoped, not global.** `systemTemplates` only returns
109
+ templates for this datalake's data domain plus globals. Calling it on a
110
+ healthcare datalake returns the healthcare templates, never the AR ones —
111
+ there is no "all templates" view.
112
+ - **`output_schema` is often `null`.** A template carries a non-null
113
+ `output_schema` only when it ships a companion schema; most interop templates
114
+ return `null`. Don't treat `null` as an error.
115
+ - **`metadataDetails` keys on basename + intent, not the full path.** Pass the
116
+ bare template name and the underscore-joined path prefix (the "intent"), not
117
+ the slash path from `systemTemplates`.
118
+ - **Discovery only.** None of these calls create or mutate anything — they read
119
+ what the platform ships. To turn a template into a live mapping, copy its
120
+ `content` into an `interoperabilityContracts.create(...)` `template_config`
121
+ (see `interoperability_contracts.md`).
122
+
123
+ # See also
124
+
125
+ - `templates.md` — the `api.templates.*` reference (wire shapes, intents)
126
+ - `interoperability_contracts.md` — how a template becomes a live row mapping
127
+ - `_setup/accounts_receivable.md` — the bootstrap this walk starts from
128
+ - `integration-tests/tests/accounts_receivable/system-templates.test.ts` — the
129
+ green test these calls are lifted from
@@ -0,0 +1,533 @@
1
+ ---
2
+ title: Triage inbound AR prospects into priority bands via an LLM agent and route each to a tailored SMS
3
+ summary: End-to-end agent-driven workflow over the accounts-receivable customer dataset — a chat-completion LLM agent classifies each customer into a priority band (high/medium/low) by account type, the workflow's decision interpolates the agent's band, three SMS actions (one per band) fan out, and the run is verified row-by-row so every Workflow Execution Log carries exactly one matched action and two skipped.
4
+ industry: accounts_receivable
5
+ slug: triage-prospects-by-priority
6
+ vitest_source:
7
+ - integration-tests/tests/accounts_receivable/agent-lead-triage.test.ts
8
+ - integration-tests/tests/accounts_receivable/interoperability-contracts.test.ts
9
+ - integration-tests/tests/accounts_receivable/run-dac-single.test.ts
10
+ - integration-tests/tests/accounts_receivable/create-dac.test.ts
11
+ - integration-tests/tests/accounts_receivable/tools.test.ts
12
+ - integration-tests/tests/accounts_receivable/data-sources.test.ts
13
+ - integration-tests/tests/accounts_receivable/bootstrap.test.ts
14
+ status: green
15
+ ---
16
+
17
+ # Problem
18
+
19
+ The `dunning-sms-for-delinquent` cookbook fired the **same** action for every
20
+ customer that passed its filter. Many AR engagements need the opposite: which
21
+ outreach fires should depend on **what kind of account** the customer is — a
22
+ high-value enterprise account gets a white-glove message, a self-serve
23
+ individual gets a standard nudge, a slow-paying government account gets a
24
+ low-urgency note. That banding is a judgement call over the account, not a
25
+ fixed rule.
26
+
27
+ The Alvera platform's agent-driven workflow turns the decision step into an LLM
28
+ classification plus a Liquid interpolation. An AI agent reads each customer's
29
+ type, returns a JSON object whose `priority_band` is one of three enum values,
30
+ and the workflow's `decision_config` interpolates that band into a one-element
31
+ decision array (`["{{ additional_context["agent-slug"].priority_band }}"]`).
32
+ Three SMS actions are registered, one per band; the agent's output picks which
33
+ one runs.
34
+
35
+ This cookbook walks the **whole** scenario: it provisions the SMS tool, the LLM
36
+ tool, the triage agent, the AR customer ingestion chain, and the agent-driven
37
+ workflow; ingests two Stripe customers (an enterprise and an individual);
38
+ runs the workflow so the agent bands each one; and verifies the routing
39
+ row-by-row — every Workflow Execution Log carries exactly one matched action
40
+ and two `:skipped`.
41
+
42
+ The scenario is anchored to
43
+ `platform/integration-tests/tests/accounts_receivable/agent-lead-triage.test.ts`.
44
+ The setup file `_setup/accounts_receivable.md` already provisioned the tenant +
45
+ datalake + tenant-scoped client; this cookbook starts from there.
46
+
47
+ # Composition
48
+
49
+ | Resource provisioned | Owner |
50
+ |--------------------------------------------|-------|
51
+ | SMS tool (SNS-backed) | build |
52
+ | LLM tool (Ollama chat-completion adapter) | build |
53
+ | Priority Triage AI agent | build |
54
+ | Stripe data source | build |
55
+ | Manual Upload tool | build |
56
+ | Stripe Customer interop contract | build |
57
+ | Manual-upload DAC | build |
58
+ | Priority Triage workflow | build |
59
+
60
+ # Walkthrough
61
+
62
+ ## 001 — create the SMS tool
63
+
64
+ A single SMS tool dispatches every band's message; the per-band body lives on
65
+ each workflow action. SNS-backed, pointed at LocalStack locally; `intent: 'sms'`
66
+ tags it for workflow actions.
67
+
68
+ ```typescript
69
+ const smsToolResp = await api.tools.create(tenantSlug, datalakeSlug, {
70
+ name: `Cookbook SMS Tool ${runSuffix}`,
71
+ description: 'SNS-backed SMS dispatcher for the priority-triage workflow, wired to LocalStack.',
72
+ intent: 'sms',
73
+ status: 'active',
74
+ datalake_id: ctx.datalakeId,
75
+ body: {
76
+ tool_body_type: 'sns',
77
+ auth_method: 'access_key',
78
+ region: 'us-east-1',
79
+ phone_number: '+15551234567',
80
+ endpoint_url: 'http://localhost:4566',
81
+ access_key_id: 'test',
82
+ secret_access_key: 'test',
83
+ },
84
+ })
85
+ toolId = smsToolResp.data.id!
86
+ ```
87
+
88
+ ## 002 — create the LLM tool
89
+
90
+ The triage agent calls a chat-completion endpoint. This tool is a **provider
91
+ adapter**: `base_body` authors Ollama's native `/api/chat` request with
92
+ `think: false` and a `format` schema so the model returns clean,
93
+ schema-constrained JSON, and `response_extractor` maps the response back to the
94
+ canonical `{ output_json, … }`. The extractor's `output_schema` is required for
95
+ an `llm_enrichment` tool.
96
+
97
+ ```typescript
98
+ const ENRICHMENT_OUTPUT_SCHEMA = {
99
+ type: 'object',
100
+ properties: {
101
+ output_json: {},
102
+ input_tokens: { type: ['integer', 'null'] },
103
+ output_tokens: { type: ['integer', 'null'] },
104
+ total_tokens: { type: ['integer', 'null'] },
105
+ explanation: { type: ['string', 'null'] },
106
+ },
107
+ required: ['output_json'],
108
+ }
109
+
110
+ const OLLAMA_BASE_BODY =
111
+ '{"model": "{{ model }}", "messages": [{"role": "user", "content": "{{ rendered_prompt | json_escape }}", "images": [{% for img in images %}{% unless forloop.first %}, {% endunless %}"{{ img.data }}"{% endfor %}]}], "stream": false, "think": false, "options": {"temperature": 0, "num_ctx": 40960}, "format": {{ schema | to_json }}}'
112
+
113
+ const OLLAMA_EXTRACTOR =
114
+ '{"output_json": "{{ msg.message.content | json_escape }}", "explanation": "{{ msg.message.thinking | json_escape }}", "input_tokens": {{ msg.prompt_eval_count | default: 0 }}, "output_tokens": {{ msg.eval_count | default: 0 }}, "total_tokens": {{ msg.prompt_eval_count | default: 0 | plus: msg.eval_count }}}'
115
+
116
+ const llmToolResp = await api.tools.create(tenantSlug, datalakeSlug, {
117
+ name: `Cookbook LLM Tool ${runSuffix}`,
118
+ description: 'Ollama-backed chat-completion adapter for AR priority triage.',
119
+ intent: 'llm_enrichment',
120
+ status: 'active',
121
+ datalake_id: ctx.datalakeId,
122
+ response_extractor: { type: 'custom', body: OLLAMA_EXTRACTOR, output_schema: ENRICHMENT_OUTPUT_SCHEMA },
123
+ body: {
124
+ tool_body_type: 'rest_api',
125
+ base_url: 'http://localhost:11434',
126
+ base_path: { type: 'custom', body: '/api/chat' },
127
+ auth_method: 'api_key',
128
+ api_key: 'stub-key',
129
+ api_key_name: 'Authorization',
130
+ api_key_location: 'header',
131
+ request_type: 'json',
132
+ response_type: 'json',
133
+ timeout_ms: 60_000,
134
+ base_body: { type: 'custom', body: OLLAMA_BASE_BODY },
135
+ },
136
+ })
137
+ ctx.llmToolId = llmToolResp.data.id!
138
+ ```
139
+
140
+ ## 003 — create the Priority Triage agent
141
+
142
+ The agent binds the model, an `input_schema` the workflow's context-mapping must
143
+ satisfy, and an `llm_response_schema` whose `enum: ['priority_high',
144
+ 'priority_medium', 'priority_low']` guards the decision interpolation from
145
+ emitting a band the workflow has no action for. `temperature: 0.0` makes
146
+ identical inputs classify identically. `data_access: 'unregulated'` keeps the
147
+ agent on the tokenized projection.
148
+
149
+ ```typescript
150
+ const TRIAGE_INPUT_SCHEMA = {
151
+ type: 'object',
152
+ properties: {
153
+ customer_number: { type: 'string' },
154
+ customer_type: { type: 'string' },
155
+ },
156
+ required: ['customer_number', 'customer_type'],
157
+ }
158
+
159
+ const TRIAGE_RESPONSE_SCHEMA = {
160
+ type: 'object',
161
+ properties: {
162
+ priority_band: { type: 'string', enum: ['priority_high', 'priority_medium', 'priority_low'] },
163
+ },
164
+ required: ['priority_band'],
165
+ }
166
+
167
+ const TRIAGE_PROMPT_BODY = `You are an AR customer-priority triage assistant. Classify the customer's dunning priority into EXACTLY ONE band by account type:
168
+
169
+ - "priority_high" — enterprise (large account, high balance, white-glove outreach)
170
+ - "priority_medium" — individual (standard self-serve account)
171
+ - "priority_low" — government (long payment cycles, low urgency)
172
+
173
+ Customer number: {{ customer_number }}
174
+ Customer type: {{ customer_type }}
175
+
176
+ Respond with JSON: {"priority_band": "<one of priority_high|priority_medium|priority_low>"}`
177
+
178
+ const agentResp = await api.aiAgents.create(tenantSlug, datalakeSlug, {
179
+ name: `Cookbook Priority Triage Agent ${runSuffix}`,
180
+ tool_id: ctx.llmToolId,
181
+ model: 'qwen3-vl:8b-instruct',
182
+ data_access: 'unregulated',
183
+ temperature: 0.0,
184
+ max_tokens: 1024,
185
+ enabled: true,
186
+ input_schema: TRIAGE_INPUT_SCHEMA,
187
+ llm_response_schema: TRIAGE_RESPONSE_SCHEMA,
188
+ prompt_config: { type: 'custom', body: TRIAGE_PROMPT_BODY },
189
+ })
190
+ aiAgentId = agentResp.data.id!
191
+ ctx.agentSlug = agentResp.data.slug!
192
+ ```
193
+
194
+ ## 004 — create the Stripe data source
195
+
196
+ ```typescript
197
+ const dataSourceResp = await api.dataSources.create(tenantSlug, datalakeSlug, {
198
+ name: `Cookbook Stripe Source ${runSuffix}`,
199
+ uri: 'stripe.example.com',
200
+ description: 'Stripe billing system — origin of the customer rows the triage workflow runs on.',
201
+ status: 'active',
202
+ is_default: false,
203
+ })
204
+ dataSourceId = dataSourceResp.data.id!
205
+ ```
206
+
207
+ ## 005 — create the Manual Upload tool
208
+
209
+ ```typescript
210
+ const manualUploadToolResp = await api.tools.create(tenantSlug, datalakeSlug, {
211
+ name: `Cookbook Manual Upload Tool ${runSuffix}`,
212
+ description: 'Manual-upload data-exchange tool — backs the DAC that ingests Stripe customer rows.',
213
+ intent: 'data_exchange',
214
+ status: 'active',
215
+ datalake_id: ctx.datalakeId,
216
+ data_source_id: dataSourceId,
217
+ body: { tool_body_type: 'manual_upload' },
218
+ })
219
+ ctx.manualUploadToolId = manualUploadToolResp.data.id!
220
+ ```
221
+
222
+ ## 006 — create the Stripe Customer interoperability contract
223
+
224
+ The contract maps each inbound Stripe customer row into a `Customer` upsert.
225
+ It loads the production Stripe customer + MDM templates from the vendored
226
+ fixtures. `customer_type` flows through to `mdm_output.customer.customer_type`,
227
+ which §008's context-mapping feeds the agent.
228
+
229
+ ```typescript
230
+ const { readFileSync } = await import('node:fs')
231
+ const { join } = await import('node:path')
232
+ const customerTemplate = readFileSync(
233
+ join(process.env.COOKBOOK_FIXTURES_DIR!, 'accounts_receivable/_customers_accounts_receivable_customer.liquid'),
234
+ 'utf8',
235
+ )
236
+ const mdmTemplate = readFileSync(
237
+ join(process.env.COOKBOOK_FIXTURES_DIR!, 'accounts_receivable/_customers_accounts_receivable_mdm.liquid'),
238
+ 'utf8',
239
+ )
240
+
241
+ const contractResp = await api.interoperabilityContracts.create(tenantSlug, datalakeSlug, {
242
+ name: `Cookbook Stripe Customer Contract ${runSuffix}`,
243
+ description: 'Stripe customers → AccountsReceivable Customer (custom Liquid + MDM input).',
244
+ resource_type: 'customer',
245
+ template_config: { type: 'custom', body: customerTemplate },
246
+ mdm_input_config: { type: 'custom', body: mdmTemplate },
247
+ generic_table_id: null,
248
+ })
249
+ interopContractId = contractResp.data.id!
250
+ ```
251
+
252
+ ## 007 — create the manual-upload DAC
253
+
254
+ ```typescript
255
+ const dacResp = await api.dataActivationClients.create(tenantSlug, datalakeSlug, {
256
+ name: `Cookbook Stripe Customer DAC ${runSuffix}`,
257
+ description: 'Manual-upload DAC — ingests Stripe customer rows into Customer via the interop contract.',
258
+ tool_id: ctx.manualUploadToolId,
259
+ data_source_id: dataSourceId,
260
+ tool_call: { tool_call_type: 'manual_upload' },
261
+ interoperability_contract_ids: [interopContractId],
262
+ })
263
+ dacId = dacResp.data.id!
264
+ ctx.dacSlug = dacResp.data.slug!
265
+ ```
266
+
267
+ ## 008 — create the Priority Triage workflow
268
+
269
+ The workflow targets the AR `customer` dataset. Two things make it
270
+ agent-driven. First, the triage agent is **nested** in the create body (the
271
+ `workflow_ai_agents` array) with a `context_mapping_config` that projects each
272
+ customer's fields into the agent's input schema. Second, the `decision_config`
273
+ interpolates the agent's `priority_band` (read from
274
+ `additional_context["<agent-slug>"]`, bracket access because the slug has
275
+ hyphens) into a one-element decision array. The three `actions` are keyed by
276
+ band; whichever the agent emits fires, leaving the other two `:skipped`. The
277
+ filter keeps only SMS-reachable, KYC-complete customers
278
+ (`customer.phone and customer.tax_id`).
279
+
280
+ ```typescript
281
+ const BANDS = ['priority_high', 'priority_medium', 'priority_low'] as const
282
+
283
+ const CONTEXT_MAPPING_BODY = JSON.stringify({
284
+ customer_number: '{{ customer.customer_number }}',
285
+ customer_type: '{{ mdm_output.customer.customer_type }}',
286
+ })
287
+
288
+ const DECISION_CONFIG_BODY = `["{{ additional_context["${ctx.agentSlug}"].priority_band }}"]`
289
+ const DECISION_OUTPUT_SCHEMA = { type: 'array', items: { type: 'string' } }
290
+
291
+ const workflowResp = await api.workflows.create(tenantSlug, datalakeSlug, {
292
+ name: `Cookbook Priority Triage Workflow ${runSuffix}`,
293
+ description: 'Bands AR customers into priority_high/medium/low via an LLM agent; one SMS action per band.',
294
+ dataset_type: 'customer',
295
+ status: 'live',
296
+ filter_config: {
297
+ type: 'custom',
298
+ body: '{% if customer.phone and customer.tax_id %}true{% endif %}',
299
+ output_schema: { type: 'boolean' },
300
+ },
301
+ decision_config: {
302
+ type: 'custom',
303
+ body: DECISION_CONFIG_BODY,
304
+ output_schema: DECISION_OUTPUT_SCHEMA,
305
+ },
306
+ actions: BANDS.map((band) => ({
307
+ decision_key: band,
308
+ action_type: 'sms',
309
+ tool_id: toolId,
310
+ position: 0,
311
+ trigger_template: 'now',
312
+ idempotency_template: `{{ customer_id }}-${band}-{{ "" | uuid }}`,
313
+ tool_call: {
314
+ tool_call_type: 'sms_request',
315
+ to: { type: 'custom', body: '{{ mdm_output.regulated_customer.phone }}' },
316
+ body: {
317
+ type: 'custom',
318
+ body: `[${band}] {{ mdm_output.regulated_customer.name }}, a note about account {{ customer.customer_number }}.`,
319
+ },
320
+ sms_type: 'transactional',
321
+ },
322
+ })),
323
+ // Nest the triage agent inline. Workflow joins omit output_schema — the
324
+ // server pins it from the agent's input_schema.
325
+ workflow_ai_agents: [
326
+ {
327
+ ai_agent_id: aiAgentId,
328
+ position: 0,
329
+ context_mapping_config: { type: 'custom', body: CONTEXT_MAPPING_BODY },
330
+ },
331
+ ],
332
+ })
333
+ workflowId = workflowResp.data.id!
334
+ ctx.workflowSlug = workflowResp.data.slug!
335
+ ```
336
+
337
+ ## 009 — ingest two Stripe customers
338
+
339
+ Two rows through the manual-upload DAC: one enterprise account, one individual.
340
+ Both carry a phone and a tax id so both pass the filter and reach the agent;
341
+ they differ in `customer_type`, which is exactly what the agent bands on. Each
342
+ ingest returns its own batch id; both are pinned for the run scope.
343
+
344
+ ```typescript
345
+ const customerRows = [
346
+ {
347
+ customer_number: `CUS-ENT-${runSuffix}`,
348
+ customer_type: 'enterprise',
349
+ status: 'contracted',
350
+ name: 'Pinnacle Financial Group',
351
+ email: `ap-${runSuffix}@pinnacle.example`,
352
+ phone: '+12125559000',
353
+ tax_id: '84-2156789',
354
+ currency: 'usd',
355
+ source_uri: 'stripe.example.com',
356
+ },
357
+ {
358
+ customer_number: `CUS-IND-${runSuffix}`,
359
+ customer_type: 'individual',
360
+ status: 'contracted',
361
+ name: 'James Whitfield',
362
+ email: `james-${runSuffix}@example.com`,
363
+ phone: '+12025551001',
364
+ tax_id: '123-45-6789',
365
+ currency: 'usd',
366
+ source_uri: 'stripe.example.com',
367
+ },
368
+ ]
369
+
370
+ const ingestResults = await Promise.all(
371
+ customerRows.map((row) =>
372
+ api.dataActivationClients.ingest(tenantSlug, datalakeSlug, ctx.dacSlug, { data: row }),
373
+ ),
374
+ )
375
+ ctx.customerBatchIds = ingestResults.map((r) => r.data.batch_id!)
376
+ if (ctx.customerBatchIds.length !== 2) {
377
+ throw new Error(`expected 2 ingest batch ids, got ${ctx.customerBatchIds.length}`)
378
+ }
379
+ ```
380
+
381
+ ## 010 — wait for the two ingest batches to reach steady-state
382
+
383
+ Ingestion is async — poll the DAC's activation logs until both batches show a
384
+ customer row was upserted (`rows_ingested >= 1` and a non-empty `output_files`).
385
+
386
+ ```typescript
387
+ const targetBatches = new Set<string>(ctx.customerBatchIds)
388
+ const deadline = Date.now() + 90_000
389
+ let greenCount = 0
390
+ while (Date.now() < deadline) {
391
+ const { data } = await api.dataActivationClients.logs.list(tenantSlug, datalakeSlug, ctx.dacSlug)
392
+ const green = new Set<string>()
393
+ for (const row of (data.data ?? []) as Array<Record<string, unknown>>) {
394
+ const b = row.batch_id
395
+ if (typeof b !== 'string' || !targetBatches.has(b)) continue
396
+ if (typeof row.rows_ingested !== 'number' || row.rows_ingested < 1) continue
397
+ if (!Array.isArray(row.output_files) || row.output_files.length === 0) continue
398
+ green.add(b)
399
+ }
400
+ greenCount = green.size
401
+ if (greenCount === targetBatches.size) break
402
+ await new Promise((r) => setTimeout(r, 1_000))
403
+ }
404
+ if (greenCount !== targetBatches.size) {
405
+ throw new Error(`only ${greenCount}/2 customer batches reached steady-state within 90s`)
406
+ }
407
+ ```
408
+
409
+ ## 011 — run the workflow against the two customers
410
+
411
+ `workflows.run` drives the full agent-driven pipeline per row: filter → agent
412
+ enrichment (the inference call that bands the customer) → decision (the band
413
+ interpolated into the decision array) → action fan-out. The SQL where-clause
414
+ scopes the run to the two batches (`ra` is the customer dataset alias). The
415
+ window is generous because each row's enrichment is a live LLM call.
416
+
417
+ ```typescript
418
+ const batchList = (ctx.customerBatchIds as string[]).map((b) => `'${b}'`).join(', ')
419
+
420
+ const runResp = await api.workflows.run(tenantSlug, datalakeSlug, ctx.workflowSlug, {
421
+ sql_where_clause: `ra.batch_id IN (${batchList})`,
422
+ mode: 'live',
423
+ manual_override: true,
424
+ })
425
+ ctx.runLogId = runResp.data.workflow_run_log_id!
426
+ ctx.runBatchId = runResp.data.batch_id!
427
+
428
+ const deadline = Date.now() + 240_000
429
+ let status: string | null = null
430
+ while (Date.now() < deadline) {
431
+ const { data: log } = await api.workflows.batchLogs.refresh(tenantSlug, datalakeSlug, ctx.workflowSlug, ctx.runLogId)
432
+ status = log.status ?? null
433
+ if (status && status !== 'pending') break
434
+ await new Promise((r) => setTimeout(r, 2_000))
435
+ }
436
+ if (status === 'failed') {
437
+ throw new Error('agent-driven triage workflow run reached :failed')
438
+ }
439
+ if (!status || status === 'pending') {
440
+ throw new Error('workflow run did not leave :pending within 240s')
441
+ }
442
+ ```
443
+
444
+ ## 012 — verify the agent's band steered the fan-out
445
+
446
+ Each customer produced a Workflow Execution Log. Every WEL has exactly **three**
447
+ action execution logs — one per band — and exactly **one** is matched
448
+ (`:pending` or `:completed`, the band the agent emitted) while the other **two**
449
+ are `:skipped`. A WEL with two matched actions, or zero, would mean the agent's
450
+ output did not actually steer the decision.
451
+
452
+ ```typescript
453
+ const { data: wfLogs } = await api.workflows.workflowLogs.list(tenantSlug, datalakeSlug, ctx.workflowSlug)
454
+ const ourWels = (wfLogs.data ?? []).filter(
455
+ (w) => (w as { batch_id?: string }).batch_id === ctx.runBatchId,
456
+ )
457
+ if (ourWels.length !== 2) {
458
+ throw new Error(`expected 2 WELs for the run, got ${ourWels.length}`)
459
+ }
460
+
461
+ for (const wel of ourWels) {
462
+ const welId = (wel as { id?: string }).id
463
+ const aels =
464
+ (wel as { action_execution_logs?: Array<{ status?: string }> }).action_execution_logs ?? []
465
+ if (aels.length !== 3) {
466
+ throw new Error(`WEL ${welId}: expected 3 AELs (one per band), got ${aels.length}`)
467
+ }
468
+ const byStatus: Record<string, number> = {}
469
+ for (const ael of aels) {
470
+ const st = ael.status ?? 'unknown'
471
+ byStatus[st] = (byStatus[st] ?? 0) + 1
472
+ }
473
+ const matched = (byStatus.pending ?? 0) + (byStatus.completed ?? 0)
474
+ if (matched !== 1) {
475
+ throw new Error(`WEL ${welId}: expected exactly 1 matched AEL, got ${matched} — ${JSON.stringify(byStatus)}`)
476
+ }
477
+ if ((byStatus.skipped ?? 0) !== 2) {
478
+ throw new Error(`WEL ${welId}: expected 2 :skipped AELs — ${JSON.stringify(byStatus)}`)
479
+ }
480
+ }
481
+ ```
482
+
483
+ # Branches
484
+
485
+ - **Agent emits an out-of-enum band** — the response schema's
486
+ `enum: ['priority_high','priority_medium','priority_low']` guards the decision
487
+ interpolation. If the model returned an unexpected string the row's execution
488
+ log would land `:failed`. Production deployments either tighten the prompt or
489
+ add a `default` band action as a fallback.
490
+ - **Per-band SMS template variation** — each action carries its own
491
+ `tool_call.body.body` Liquid. The cookbook renders the same body shape with
492
+ the band interpolated for clarity; production scenarios author a distinct
493
+ message per band (white-glove for high, a standard nudge for medium, a
494
+ low-urgency note for low).
495
+ - **The filter still runs** — `customer.phone and customer.tax_id` gates which
496
+ customers reach the agent at all; both rows here qualify, so both are banded.
497
+ A customer missing either field would be `:filtered` before the agent saw it.
498
+
499
+ # Rollback
500
+
501
+ The cookbook doctest harness does not currently tear down created resources. The
502
+ `_setup/accounts_receivable.md` setup file's runSuffix-scoped names keep each run
503
+ isolated; the seeded local DB is cheap to reset (`mix ecto.reset` on the platform
504
+ repo).
505
+
506
+ # Outcome
507
+
508
+ After this cookbook runs green:
509
+
510
+ - An SMS tool, an Ollama chat-completion LLM tool, a Priority Triage AI agent
511
+ (qwen3-vl:8b-instruct, three-band response schema), and an agent-driven
512
+ workflow over the `customer` dataset are all registered
513
+ - Two Stripe customers (enterprise + individual) have been ingested through the
514
+ AR customer chain
515
+ - Running the workflow drove each through the agent: the LLM banded the
516
+ customer, the decision interpolated the band, and the matching band's SMS
517
+ action fired
518
+ - The routing is verified row-by-row — every Workflow Execution Log carries
519
+ exactly one matched action and two `:skipped`, proving the agent's output
520
+ steered the fan-out
521
+
522
+ # See also
523
+
524
+ - `_setup/accounts_receivable.md` — the inlined bootstrap this cookbook starts from
525
+ - `dunning-sms-for-delinquent.md` — the static-decision AR counterpart (same
526
+ customer chain, one fixed action, no agent)
527
+ - `score-leads-with-llm-categorization.md` — the foundation analogue (leads into
528
+ four bands over a generic table)
529
+ - `ai-agent-invoke.md` — the capability doc for the agent + tool surface this uses
530
+ - `.agent/workflows.md` — agent-driven workflow primitive; `workflow_ai_agents`,
531
+ `context_mapping_config`, agent-output decision interpolation
532
+ - `integration-tests/tests/accounts_receivable/agent-lead-triage.test.ts` — the
533
+ anchor green test these snippets are lifted from