@alvera-ai/platform-sdk 0.17.0 → 0.18.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 (83) hide show
  1. package/.agent/AGENTS.md +82 -144
  2. package/.agent/account_management.md +2 -2
  3. package/.agent/action_logs.md +4 -4
  4. package/.agent/ai_agents.md +28 -21
  5. package/.agent/ai_sandbox.md +49 -39
  6. package/.agent/connected_apps.md +3 -3
  7. package/.agent/cookbook/_fixtures/README.md +1 -1
  8. package/.agent/cookbook/_fixtures/{foundation → organic-marketing}/_lead_submissions_foundation_generic_table.liquid +1 -1
  9. package/.agent/cookbook/_fixtures/organic-marketing/_lead_submissions_foundation_legal_entity.liquid +80 -0
  10. package/.agent/cookbook/_fixtures/{foundation → organic-marketing}/_lead_submissions_foundation_mdm.liquid +2 -1
  11. package/.agent/cookbook/_fixtures/payments-compliance/_compliance_screenings_generic_table.liquid +57 -0
  12. package/.agent/cookbook/_fixtures/payments-compliance/_compliance_screenings_legal_entity.liquid +30 -0
  13. package/.agent/cookbook/_fixtures/payments-compliance/_compliance_screenings_mdm.liquid +44 -0
  14. package/.agent/cookbook/_fixtures/payments-compliance/_payment_accounts_generic_table.liquid +57 -0
  15. package/.agent/cookbook/_fixtures/payments-compliance/_payment_accounts_legal_entity.liquid +36 -0
  16. package/.agent/cookbook/_fixtures/payments-compliance/_payment_accounts_mdm.liquid +41 -0
  17. package/.agent/cookbook/_fixtures/primary-care-feedback/_cahps_appointments_generic_table.liquid +70 -0
  18. package/.agent/cookbook/_fixtures/primary-care-feedback/_cahps_appointments_legal_entity.liquid +52 -0
  19. package/.agent/cookbook/_fixtures/primary-care-feedback/_cahps_appointments_mdm.liquid +42 -0
  20. package/.agent/cookbook/_fixtures/subscription-saas/_customers_subscription_generic_table.liquid +38 -0
  21. package/.agent/cookbook/_fixtures/subscription-saas/_customers_subscription_legal_entity.liquid +48 -0
  22. package/.agent/cookbook/_fixtures/subscription-saas/_customers_subscription_mdm.liquid +49 -0
  23. package/.agent/cookbook/organic-marketing.md +2801 -0
  24. package/.agent/cookbook/payments-compliance.md +2180 -0
  25. package/.agent/cookbook/primary-care.md +2175 -0
  26. package/.agent/cookbook/subscription-saas.md +2403 -0
  27. package/.agent/data_activation_clients.md +65 -52
  28. package/.agent/datalakes.md +338 -171
  29. package/.agent/errors.md +3 -3
  30. package/.agent/generic_tables.md +151 -62
  31. package/.agent/interoperability_contracts.md +57 -22
  32. package/.agent/mdm.md +136 -153
  33. package/.agent/messages.md +36 -34
  34. package/.agent/mock-services.md +1 -1
  35. package/.agent/mutations.md +2 -2
  36. package/.agent/templates.md +14 -13
  37. package/.agent/tools.md +63 -21
  38. package/.agent/type_naming.md +13 -13
  39. package/.agent/workflows.md +99 -53
  40. package/README.md +2 -2
  41. package/dist/bin/platform-sdk.mjs +33 -47
  42. package/dist/bin/platform-sdk.mjs.map +1 -1
  43. package/dist/index.d.mts +565 -379
  44. package/dist/index.d.mts.map +1 -1
  45. package/dist/index.mjs +494 -59
  46. package/dist/index.mjs.map +1 -1
  47. package/package.json +4 -3
  48. package/.agent/cookbook/_fixtures/foundation/_lead_submissions_foundation_legal_entity.liquid +0 -88
  49. package/.agent/cookbook/_fixtures/healthcare/_cahps_appointments_healthcare_appointment.liquid +0 -47
  50. package/.agent/cookbook/_fixtures/healthcare/_cahps_appointments_healthcare_mdm.liquid +0 -24
  51. package/.agent/cookbook/_fixtures/healthcare/_cahps_appointments_healthcare_patient.liquid +0 -38
  52. package/.agent/cookbook/_fixtures/payments/_compliance_screenings_payments_compliance_screening.liquid +0 -59
  53. package/.agent/cookbook/_fixtures/payments/_compliance_screenings_payments_mdm.liquid +0 -36
  54. package/.agent/cookbook/_fixtures/payments/_payment_accounts_payments_mdm.liquid +0 -30
  55. package/.agent/cookbook/_fixtures/payments/_payment_accounts_payments_payment_account.liquid +0 -55
  56. package/.agent/cookbook/_fixtures/subscription/_customers_subscription_mdm.liquid +0 -20
  57. package/.agent/cookbook/_setup/foundation.md +0 -359
  58. package/.agent/cookbook/_setup/healthcare.md +0 -361
  59. package/.agent/cookbook/_setup/payments.md +0 -365
  60. package/.agent/cookbook/_setup/subscription.md +0 -364
  61. package/.agent/cookbook/action-status-updaters.md +0 -278
  62. package/.agent/cookbook/ai-agent-invoke.md +0 -279
  63. package/.agent/cookbook/appointment-review-sms-workflow.md +0 -801
  64. package/.agent/cookbook/birthday-greeting-sms-trigger.md +0 -696
  65. package/.agent/cookbook/bulk-ingest.md +0 -302
  66. package/.agent/cookbook/contact-us-triage-with-llm.md +0 -663
  67. package/.agent/cookbook/dunning-sms-for-delinquent.md +0 -659
  68. package/.agent/cookbook/generic-tables.md +0 -244
  69. package/.agent/cookbook/invite-team.md +0 -200
  70. package/.agent/cookbook/kyc-notification-on-account-activation.md +0 -661
  71. package/.agent/cookbook/marketing-campaign-send.md +0 -1044
  72. package/.agent/cookbook/paginated-restapi-poller.md +0 -383
  73. package/.agent/cookbook/rest-fetch.md +0 -273
  74. package/.agent/cookbook/sanctions-screening-with-agent-review.md +0 -773
  75. package/.agent/cookbook/score-leads-with-llm-categorization.md +0 -665
  76. package/.agent/cookbook/system-templates.md +0 -165
  77. package/.agent/cookbook/talk-to-data.md +0 -178
  78. package/.agent/cookbook/triage-prospects-by-priority.md +0 -571
  79. package/.agent/cookbook/welcome-sms-for-customers.md +0 -647
  80. /package/.agent/cookbook/_fixtures/{healthcare → primary-care-feedback}/memorandum-of-association-01.png +0 -0
  81. /package/.agent/cookbook/_fixtures/{healthcare → primary-care-feedback}/sample_two_page.pdf +0 -0
  82. /package/.agent/cookbook/_fixtures/{subscription → subscription-saas}/_customers_subscription_customer.liquid +0 -0
  83. /package/.agent/cookbook/_fixtures/{subscription → subscription-saas}/stripe_customers_batch1.csv +0 -0
@@ -1,571 +0,0 @@
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 subscription 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: subscription
5
- slug: triage-prospects-by-priority
6
- vitest_source:
7
- - integration-tests/tests/subscription/agent-lead-triage.test.ts
8
- - integration-tests/tests/subscription/interoperability-contracts.test.ts
9
- - integration-tests/tests/subscription/run-dac-single.test.ts
10
- - integration-tests/tests/subscription/create-dac.test.ts
11
- - integration-tests/tests/subscription/tools.test.ts
12
- - integration-tests/tests/subscription/data-sources.test.ts
13
- - integration-tests/tests/subscription/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/subscription/agent-lead-triage.test.ts`.
44
- The setup file `_setup/subscription.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!, 'subscription/_customers_subscription_customer.liquid'),
234
- 'utf8',
235
- )
236
- const mdmTemplate = readFileSync(
237
- join(process.env.COOKBOOK_FIXTURES_DIR!, 'subscription/_customers_subscription_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 → Subscription 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
- tags: ['ar', 'triage'],
297
- filter_config: {
298
- type: 'custom',
299
- body: '{% if customer.phone and customer.tax_id %}true{% endif %}',
300
- output_schema: { type: 'boolean' },
301
- },
302
- decision_config: {
303
- type: 'custom',
304
- body: DECISION_CONFIG_BODY,
305
- output_schema: DECISION_OUTPUT_SCHEMA,
306
- },
307
- actions: BANDS.map((band) => ({
308
- decision_key: band,
309
- action_type: 'sms',
310
- tool_id: toolId,
311
- position: 0,
312
- trigger_template: 'now',
313
- idempotency_template: `{{ customer_id }}-${band}`,
314
- tool_call: {
315
- tool_call_type: 'sms_request',
316
- to: { type: 'custom', body: '{{ mdm_output.regulated_customer.phone }}' },
317
- body: {
318
- type: 'custom',
319
- body: `[${band}] {{ mdm_output.regulated_customer.name }}, a note about account {{ customer.customer_number }}.`,
320
- },
321
- sms_type: 'transactional',
322
- },
323
- })),
324
- // Nest the triage agent inline. Workflow joins omit output_schema — the
325
- // server pins it from the agent's input_schema.
326
- workflow_ai_agents: [
327
- {
328
- ai_agent_id: aiAgentId,
329
- position: 0,
330
- context_mapping_config: { type: 'custom', body: CONTEXT_MAPPING_BODY },
331
- },
332
- ],
333
- })
334
- workflowId = workflowResp.data.id!
335
- ctx.workflowSlug = workflowResp.data.slug!
336
- ```
337
-
338
- ## 009 — ingest two Stripe customers
339
-
340
- Two rows through the manual-upload DAC: one enterprise account, one individual.
341
- Both carry a phone and a tax id so both pass the filter and reach the agent;
342
- they differ in `customer_type`, which is exactly what the agent bands on. Each
343
- ingest returns its own batch id; both are pinned for the run scope.
344
-
345
- ```typescript
346
- const customerRows = [
347
- {
348
- customer_number: `CUS-ENT-${runSuffix}`,
349
- customer_type: 'enterprise',
350
- status: 'contracted',
351
- name: 'Pinnacle Financial Group',
352
- email: `ap-${runSuffix}@pinnacle.example`,
353
- phone: '+12125559000',
354
- tax_id: '84-2156789',
355
- currency: 'usd',
356
- source_uri: 'stripe.example.com',
357
- },
358
- {
359
- customer_number: `CUS-IND-${runSuffix}`,
360
- customer_type: 'individual',
361
- status: 'contracted',
362
- name: 'James Whitfield',
363
- email: `james-${runSuffix}@example.com`,
364
- phone: '+12025551001',
365
- tax_id: '123-45-6789',
366
- currency: 'usd',
367
- source_uri: 'stripe.example.com',
368
- },
369
- ]
370
-
371
- const ingestResults = await Promise.all(
372
- customerRows.map((row) =>
373
- api.dataActivationClients.ingest(tenantSlug, datalakeSlug, ctx.dacSlug, { data: row }),
374
- ),
375
- )
376
- ctx.customerBatchIds = ingestResults.map((r) => r.data.batch_id!)
377
- if (ctx.customerBatchIds.length !== 2) {
378
- throw new Error(`expected 2 ingest batch ids, got ${ctx.customerBatchIds.length}`)
379
- }
380
- ```
381
-
382
- ## 010 — wait for the two ingest batches to reach steady-state
383
-
384
- Ingestion is async — poll the DAC's activation logs until both batches show a
385
- customer row was upserted (`rows_ingested >= 1` and a non-empty `output_files`).
386
-
387
- ```typescript
388
- const targetBatches = new Set<string>(ctx.customerBatchIds)
389
- const deadline = Date.now() + 90_000
390
- let greenCount = 0
391
- while (Date.now() < deadline) {
392
- const { data } = await api.dataActivationClients.logs.list(tenantSlug, datalakeSlug, ctx.dacSlug)
393
- const green = new Set<string>()
394
- for (const row of (data.data ?? []) as Array<Record<string, unknown>>) {
395
- const b = row.batch_id
396
- if (typeof b !== 'string' || !targetBatches.has(b)) continue
397
- if (typeof row.rows_ingested !== 'number' || row.rows_ingested < 1) continue
398
- if (!Array.isArray(row.output_files) || row.output_files.length === 0) continue
399
- green.add(b)
400
- }
401
- greenCount = green.size
402
- if (greenCount === targetBatches.size) break
403
- await new Promise((r) => setTimeout(r, 1_000))
404
- }
405
- if (greenCount !== targetBatches.size) {
406
- throw new Error(`only ${greenCount}/2 customer batches reached steady-state within 90s`)
407
- }
408
- ```
409
-
410
- ## 011 — run the workflow against the two customers
411
-
412
- `workflows.run` drives the full agent-driven pipeline per row: filter → agent
413
- enrichment (the inference call that bands the customer) → decision (the band
414
- interpolated into the decision array) → action fan-out. The SQL where-clause
415
- scopes the run to the two batches (`ra` is the customer dataset alias). The
416
- window is generous because each row's enrichment is a live LLM call.
417
-
418
- ```typescript
419
- const batchList = (ctx.customerBatchIds as string[]).map((b) => `'${b}'`).join(', ')
420
-
421
- const runResp = await api.workflows.run(tenantSlug, datalakeSlug, ctx.workflowSlug, {
422
- sql_where_clause: `ra.batch_id IN (${batchList})`,
423
- mode: 'live',
424
- manual_override: true,
425
- })
426
- // run-workflow only SCHEDULES the run. The log id and batch id are
427
- // written when it fires, so read them back via workflowRuns.get.
428
- const fired = await ctx.waitForFiredRun(datalakeSlug, runResp.data.workflow_run_id)
429
- ctx.runLogId = fired.workflowRunLogId
430
- ctx.runBatchId = fired.batchId!
431
-
432
- const deadline = Date.now() + 240_000
433
- let status: string | null = null
434
- while (Date.now() < deadline) {
435
- const { data: log } = await api.workflows.batchLogs.refresh(tenantSlug, datalakeSlug, ctx.workflowSlug, ctx.runLogId)
436
- status = log.status ?? null
437
- if (status && status !== 'pending') break
438
- await new Promise((r) => setTimeout(r, 2_000))
439
- }
440
- if (status === 'failed') {
441
- throw new Error('agent-driven triage workflow run reached :failed')
442
- }
443
- if (!status || status === 'pending') {
444
- throw new Error('workflow run did not leave :pending within 240s')
445
- }
446
- ```
447
-
448
- ## 012 — verify the agent's band steered the fan-out
449
-
450
- Each customer produced a Workflow Execution Log. Every WEL has exactly **three**
451
- action execution logs — one per band — and exactly **one** is matched
452
- (`:pending` or `:completed`, the band the agent emitted) while the other **two**
453
- are `:skipped`. A WEL with two matched actions, or zero, would mean the agent's
454
- output did not actually steer the decision.
455
-
456
- ```typescript
457
- const { data: wfLogs } = await api.workflows.workflowLogs.list(tenantSlug, datalakeSlug, ctx.workflowSlug)
458
- const ourWels = (wfLogs.data ?? []).filter(
459
- (w) => (w as { batch_id?: string }).batch_id === ctx.runBatchId,
460
- )
461
- if (ourWels.length !== 2) {
462
- throw new Error(`expected 2 WELs for the run, got ${ourWels.length}`)
463
- }
464
-
465
- for (const wel of ourWels) {
466
- const welId = (wel as { id?: string }).id
467
- const aels =
468
- (wel as { action_execution_logs?: Array<{ status?: string }> }).action_execution_logs ?? []
469
- if (aels.length !== 3) {
470
- throw new Error(`WEL ${welId}: expected 3 AELs (one per band), got ${aels.length}`)
471
- }
472
- const byStatus: Record<string, number> = {}
473
- for (const ael of aels) {
474
- const st = ael.status ?? 'unknown'
475
- byStatus[st] = (byStatus[st] ?? 0) + 1
476
- }
477
- const matched = (byStatus.pending ?? 0) + (byStatus.completed ?? 0)
478
- if (matched !== 1) {
479
- throw new Error(`WEL ${welId}: expected exactly 1 matched AEL, got ${matched} — ${JSON.stringify(byStatus)}`)
480
- }
481
- if ((byStatus.skipped ?? 0) !== 2) {
482
- throw new Error(`WEL ${welId}: expected 2 :skipped AELs — ${JSON.stringify(byStatus)}`)
483
- }
484
- }
485
- ```
486
-
487
- ## 013 — write the integration test
488
-
489
- End the build with a test you keep: re-read the workflow and prove the
490
- pipeline still executes — without a side effect. `mode: 'dry_run'` with a
491
- never-matching selection runs the FULL pipeline (selection → filter →
492
- decision) and intercepts only the final action call, so no message
493
- leaves, yet the acknowledgement proves the workflow is runnable. This
494
- block runs live under `make validate-cookbook`.
495
-
496
- ```typescript
497
- // Re-GET — the workflow must still be live, or nothing will run.
498
- const { data: wfRow } = await api.workflows.get(tenantSlug, datalakeSlug, workflowId)
499
- if (wfRow.status !== 'live') {
500
- throw new Error(`workflow regressed from live: ${wfRow.status}`)
501
- }
502
- // Behavioural probe — a dry run against a selection no row can match:
503
- // the pipeline executes end-to-end, the final action call is
504
- // intercepted, and the acknowledgement carries the scheduled run id.
505
- const { data: probeRun } = await api.workflows.run(tenantSlug, datalakeSlug, ctx.workflowSlug, {
506
- sql_where_clause: "ra.batch_id = 'test-never-matching-batch'",
507
- mode: 'dry_run',
508
- manual_override: false,
509
- })
510
- // The run-log id does not exist until the run fires — wait, do not read a null.
511
- const probeFired = await ctx.waitForFiredRun(datalakeSlug, probeRun.workflow_run_id)
512
- if (probeFired.workflowRunLogId.length === 0) {
513
- throw new Error('dry-run probe never produced a workflow_run_log_id')
514
- }
515
- ```
516
-
517
- If the probe fails in production, escalate with the run response as
518
- evidence — don't flip the workflow's status or rewrite its configs to
519
- chase the error.
520
-
521
- # Branches
522
-
523
- - **Agent emits an out-of-enum band** — the response schema's
524
- `enum: ['priority_high','priority_medium','priority_low']` guards the decision
525
- interpolation. If the model returned an unexpected string the row's execution
526
- log would land `:failed`. Production deployments either tighten the prompt or
527
- add a `default` band action as a fallback.
528
- - **Per-band SMS template variation** — each action carries its own
529
- `tool_call.body.body` Liquid. The cookbook renders the same body shape with
530
- the band interpolated for clarity; production scenarios author a distinct
531
- message per band (white-glove for high, a standard nudge for medium, a
532
- low-urgency note for low).
533
- - **The filter still runs** — `customer.phone and customer.tax_id` gates which
534
- customers reach the agent at all; both rows here qualify, so both are banded.
535
- A customer missing either field would be `:filtered` before the agent saw it.
536
-
537
- # Rollback
538
-
539
- The cookbook doctest harness does not currently tear down created resources. The
540
- `_setup/subscription.md` setup file's runSuffix-scoped names keep each run
541
- isolated; the seeded local DB is cheap to reset (`mix ecto.reset` on the platform
542
- repo).
543
-
544
- # Outcome
545
-
546
- After this cookbook runs green:
547
-
548
- - An SMS tool, an Ollama chat-completion LLM tool, a Priority Triage AI agent
549
- (qwen3-vl:8b-instruct, three-band response schema), and an agent-driven
550
- workflow over the `customer` dataset are all registered
551
- - Two Stripe customers (enterprise + individual) have been ingested through the
552
- AR customer chain
553
- - Running the workflow drove each through the agent: the LLM banded the
554
- customer, the decision interpolated the band, and the matching band's SMS
555
- action fired
556
- - The routing is verified row-by-row — every Workflow Execution Log carries
557
- exactly one matched action and two `:skipped`, proving the agent's output
558
- steered the fan-out
559
-
560
- # See also
561
-
562
- - `_setup/subscription.md` — the inlined bootstrap this cookbook starts from
563
- - `dunning-sms-for-delinquent.md` — the static-decision AR counterpart (same
564
- customer chain, one fixed action, no agent)
565
- - `score-leads-with-llm-categorization.md` — the foundation analogue (leads into
566
- four bands over a generic table)
567
- - `ai-agent-invoke.md` — the capability doc for the agent + tool surface this uses
568
- - `.agent/workflows.md` — agent-driven workflow primitive; `workflow_ai_agents`,
569
- `context_mapping_config`, agent-output decision interpolation
570
- - `integration-tests/tests/subscription/agent-lead-triage.test.ts` — the
571
- anchor green test these snippets are lifted from