@alvera-ai/platform-sdk 0.10.0-rc.2 → 0.10.0-rc.21

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 (55) hide show
  1. package/.agent/AGENTS.md +440 -0
  2. package/.agent/account_management.md +455 -0
  3. package/.agent/action_status_updaters.md +262 -0
  4. package/.agent/ai_agents.md +423 -0
  5. package/.agent/ai_sandbox.md +265 -0
  6. package/.agent/async.md +111 -0
  7. package/.agent/connected_apps.md +407 -0
  8. package/.agent/cookbook/_fixtures/README.md +99 -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/foundation/_lead_submissions_foundation_generic_table.liquid +33 -0
  12. package/.agent/cookbook/_fixtures/foundation/_lead_submissions_foundation_legal_entity.liquid +88 -0
  13. package/.agent/cookbook/_fixtures/foundation/_lead_submissions_foundation_mdm.liquid +48 -0
  14. package/.agent/cookbook/_fixtures/healthcare/_cahps_appointments_healthcare_appointment.liquid +47 -0
  15. package/.agent/cookbook/_fixtures/healthcare/_cahps_appointments_healthcare_mdm.liquid +24 -0
  16. package/.agent/cookbook/_fixtures/healthcare/_cahps_appointments_healthcare_patient.liquid +38 -0
  17. package/.agent/cookbook/_fixtures/payment_risk/_compliance_screenings_payment_risk_compliance_screening.liquid +59 -0
  18. package/.agent/cookbook/_fixtures/payment_risk/_compliance_screenings_payment_risk_mdm.liquid +36 -0
  19. package/.agent/cookbook/_fixtures/payment_risk/_payment_accounts_payment_risk_mdm.liquid +30 -0
  20. package/.agent/cookbook/_fixtures/payment_risk/_payment_accounts_payment_risk_payment_account.liquid +55 -0
  21. package/.agent/cookbook/_setup/accounts_receivable.md +282 -0
  22. package/.agent/cookbook/_setup/foundation.md +277 -0
  23. package/.agent/cookbook/_setup/healthcare.md +279 -0
  24. package/.agent/cookbook/_setup/payment_risk.md +283 -0
  25. package/.agent/cookbook/appointment-review-sms-workflow.md +761 -0
  26. package/.agent/cookbook/birthday-greeting-sms-trigger.md +656 -0
  27. package/.agent/cookbook/contact-us-triage-with-llm.md +603 -0
  28. package/.agent/cookbook/dunning-sms-for-delinquent.md +619 -0
  29. package/.agent/cookbook/kyc-notification-on-account-activation.md +619 -0
  30. package/.agent/cookbook/sanctions-screening-with-agent-review.md +711 -0
  31. package/.agent/cookbook/score-leads-with-llm-categorization.md +602 -0
  32. package/.agent/cookbook/welcome-sms-for-customers.md +607 -0
  33. package/.agent/data_activation_clients.md +557 -0
  34. package/.agent/data_sources.md +234 -0
  35. package/.agent/datalakes.md +712 -0
  36. package/.agent/debugging.md +137 -0
  37. package/.agent/errors.md +196 -0
  38. package/.agent/generic_tables.md +351 -0
  39. package/.agent/interoperability_contracts.md +351 -0
  40. package/.agent/mdm.md +293 -0
  41. package/.agent/mutations.md +152 -0
  42. package/.agent/templates.md +98 -0
  43. package/.agent/tool-call-configs.md +90 -0
  44. package/.agent/tools.md +546 -0
  45. package/.agent/type_naming.md +131 -0
  46. package/.agent/workflows.md +601 -0
  47. package/README.md +46 -0
  48. package/dist/bin/platform-sdk.d.mts +1 -0
  49. package/dist/bin/platform-sdk.mjs +106 -0
  50. package/dist/bin/platform-sdk.mjs.map +1 -0
  51. package/dist/index.d.mts +1200 -43201
  52. package/dist/index.d.mts.map +1 -1
  53. package/dist/index.mjs +1859 -7319
  54. package/dist/index.mjs.map +1 -1
  55. package/package.json +19 -10
@@ -0,0 +1,603 @@
1
+ ---
2
+ title: Triage inbound contact-us messages into three priority buckets via an LLM agent
3
+ summary: End-to-end agent-driven workflow — a chat-completion LLM agent classifies each contact-us submission into appointment_request / job_inquiry / flag_spam, the workflow's decision interpolates the agent's category, three SMS actions (one per bucket) fan out, and the run is verified row-by-row so every WEL carries exactly one matched action and two skipped.
4
+ industry: healthcare
5
+ slug: contact-us-triage-with-llm
6
+ vitest_source:
7
+ - integration-tests/tests/healthcare/agent-driven-workflow.test.ts
8
+ - integration-tests/tests/healthcare/generic-tables.test.ts
9
+ - integration-tests/tests/healthcare/tools.test.ts
10
+ - integration-tests/tests/healthcare/bootstrap.test.ts
11
+ status: green
12
+ ---
13
+
14
+ # Problem
15
+
16
+ A clinic's "Contact Us" form is a firehose of mixed intent. One
17
+ message asks to book an appointment, the next is a nurse applying
18
+ for a job, the third is promotional spam. Routing all three to the
19
+ same inbox means the appointment request waits behind the spam.
20
+
21
+ A keyword filter handles the obvious cases and misses the rest —
22
+ "I'd love to come in and see Dr. Johnson" has no booking keyword,
23
+ "openings" appears in both a job inquiry and a spam blast. The
24
+ classification is a language task, not a rules task.
25
+
26
+ The Alvera platform's agent-driven workflow primitive turns the
27
+ decision step into an LLM enrichment plus a Liquid interpolation.
28
+ An AI agent receives the submission's free text via a
29
+ context-mapping template, returns a JSON object whose `category`
30
+ field is one of three enum values, and the workflow's
31
+ decision_config interpolates that category into a one-element
32
+ decision array (`["{{ additional_context["agent-slug"].category }}"]`).
33
+ Three SMS actions are registered against the workflow, one per
34
+ bucket; the agent's output picks which one runs.
35
+
36
+ This cookbook walks the **whole** scenario: it provisions the
37
+ generic table, agent, and workflow, ingests three contact-us
38
+ submissions through the generic-table's auto-provisioned default
39
+ DAC, runs the workflow so the agent classifies each row, and
40
+ verifies the routing row-by-row — every Workflow Execution Log
41
+ carries exactly one matched action and two skipped, proving the
42
+ agent's category actually steered the fan-out.
43
+
44
+ The scenario is anchored to
45
+ `platform/integration-tests/tests/healthcare/agent-driven-workflow.test.ts`
46
+ — a green end-to-end test whose positive branch (§1, §3, §4, §6,
47
+ §7) is exactly this shape. The setup file `_setup/healthcare.md`
48
+ already provisioned the tenant + datalake + tenant-scoped client;
49
+ this cookbook starts from there.
50
+
51
+ # Composition
52
+
53
+ | Resource provisioned | Owner |
54
+ |-------------------------------------|-------------|
55
+ | Contact Us submissions dataset | build |
56
+ | SMS tool (SNS-backed) | build |
57
+ | LLM tool (Ollama chat completion) | build |
58
+ | Contact Us Triage AI agent | build |
59
+ | Contact Us Triage workflow | build |
60
+
61
+ The generic table's **default DAC** — auto-provisioned by the
62
+ platform when the generic table is created — is reused for
63
+ ingestion; no separate data source / tool / interop contract / DAC
64
+ is created. The setup file `_setup/healthcare.md` already
65
+ provisioned the tenant + datalake + tenant-scoped client; this
66
+ cookbook starts from there.
67
+
68
+ # Walkthrough
69
+
70
+ ## 001 — create the Contact Us submissions generic table
71
+
72
+ The agent-driven workflow targets a Generic Table dataset
73
+ (`dataset_type: 'generic_table'`) rather than a regulated domain
74
+ entity, because contact-us form rows do not fit a canonical FHIR
75
+ entity. The table's columns mirror the shape a website form export
76
+ would produce; the `message` column is what the agent reads, and
77
+ `category` is what the agent eventually writes back in production.
78
+ The server-derived `name` is captured — the workflow run in §009
79
+ addresses the regulated table as `regulated_<name>`.
80
+
81
+ ```typescript
82
+ const genericTableResp = await api.genericTables.create(tenantSlug, datalakeSlug, {
83
+ title: `Cookbook Contact Us ${runSuffix}`,
84
+ description: 'Inbound contact-us form submissions the Triage agent classifies.',
85
+ columns: [
86
+ {
87
+ name: 'submission_id',
88
+ title: 'Submission ID',
89
+ type: 'string',
90
+ description: 'Vendor-supplied unique submission id',
91
+ is_unique: true,
92
+ privacy_requirement: 'none',
93
+ },
94
+ {
95
+ name: 'name',
96
+ title: 'Name',
97
+ type: 'string',
98
+ description: 'Submitter full name',
99
+ is_unique: false,
100
+ privacy_requirement: 'tokenize',
101
+ },
102
+ {
103
+ name: 'email',
104
+ title: 'Email',
105
+ type: 'string',
106
+ description: 'Submitter email',
107
+ is_unique: false,
108
+ privacy_requirement: 'tokenize',
109
+ },
110
+ {
111
+ name: 'message',
112
+ title: 'Message',
113
+ type: 'string',
114
+ description: 'Free-text contact-us message — the agent classifies this',
115
+ is_unique: false,
116
+ privacy_requirement: 'none',
117
+ },
118
+ {
119
+ name: 'category',
120
+ title: 'Category',
121
+ type: 'string',
122
+ description: 'Triage bucket assigned by the Contact Us Triage agent',
123
+ is_unique: false,
124
+ privacy_requirement: 'none',
125
+ },
126
+ ],
127
+ })
128
+ genericTableId = genericTableResp.data.id!
129
+ ctx.gtName = genericTableResp.data.name!
130
+ ```
131
+
132
+ ## 002 — create the SMS tool
133
+
134
+ A single SMS tool dispatches every bucket's outbound message; the
135
+ per-bucket SMS body lives on each workflow action, not on the tool.
136
+ SNS-backed LocalStack wiring — `intent: 'sms'` tags it for
137
+ workflow-action use.
138
+
139
+ ```typescript
140
+ const smsToolResp = await api.tools.create(tenantSlug, datalakeSlug, {
141
+ name: `Cookbook SMS Tool ${runSuffix}`,
142
+ description: 'SNS-backed SMS dispatcher for the Contact Us Triage workflow, wired to LocalStack.',
143
+ intent: 'sms',
144
+ status: 'active',
145
+ datalake_id: ctx.datalakeId,
146
+ body: {
147
+ tool_body_type: 'sns',
148
+ auth_method: 'access_key',
149
+ region: 'us-east-1',
150
+ phone_number: '+15551234567',
151
+ endpoint_url: 'http://localhost:4566',
152
+ access_key_id: 'test',
153
+ secret_access_key: 'test',
154
+ },
155
+ })
156
+ toolId = smsToolResp.data.id!
157
+ ```
158
+
159
+ ## 003 — create the LLM tool
160
+
161
+ The Triage agent calls a chat-completion endpoint to classify each
162
+ row. The tool's `intent: 'llm_enrichment'` distinguishes it from
163
+ the SMS tool above; `tool_body_type: 'rest_api'` plus an
164
+ OpenAI-compatible `base_url` lets the platform route Ollama,
165
+ Anthropic, OpenAI, or any other compatible inference server
166
+ through the same wire format. Local dev points at Ollama's
167
+ OpenAI-compat shim on `http://localhost:11434/v1` — note the `/v1`
168
+ root, not `/v1/chat/completions`; the platform's chat-completion
169
+ protocol appends `/chat/completions` itself. The
170
+ `api_key`/`auth_method` pair is required by the REST tool's
171
+ discriminated-union schema even though Ollama ignores the header.
172
+
173
+ ```typescript
174
+ const llmToolResp = await api.tools.create(tenantSlug, datalakeSlug, {
175
+ name: `Cookbook LLM Tool ${runSuffix}`,
176
+ description: 'Ollama-backed chat-completion endpoint for Contact Us triage classification.',
177
+ intent: 'llm_enrichment',
178
+ status: 'active',
179
+ datalake_id: ctx.datalakeId,
180
+ body: {
181
+ tool_body_type: 'rest_api',
182
+ base_url: 'http://localhost:11434/v1',
183
+ auth_method: 'api_key',
184
+ api_key: 'ollama-noop',
185
+ api_key_name: 'Authorization',
186
+ api_key_location: 'header',
187
+ request_type: 'json',
188
+ response_type: 'json',
189
+ timeout_ms: 60_000,
190
+ },
191
+ })
192
+ ctx.llmToolId = llmToolResp.data.id!
193
+ ```
194
+
195
+ ## 004 — create the Contact Us Triage AI agent
196
+
197
+ The agent binds three things: the model name (sent on every
198
+ inference call), an input schema the workflow's context-mapping
199
+ must satisfy, and a response schema the agent's output must match.
200
+ The response schema's `enum: ['appointment_request','job_inquiry','flag_spam']`
201
+ constraint is what guards the downstream decision interpolation
202
+ from emitting a category the workflow has no SMS action for.
203
+ `temperature: 0.0` removes sampling noise so identical inputs
204
+ classify identically. `data_access: 'unregulated'` says the agent
205
+ only ever sees the tokenized projection of each row.
206
+
207
+ ```typescript
208
+ const TRIAGE_INPUT_SCHEMA = {
209
+ type: 'object',
210
+ properties: {
211
+ msg: { type: 'string' },
212
+ submission_id: { type: 'string' },
213
+ },
214
+ required: ['msg', 'submission_id'],
215
+ }
216
+
217
+ const TRIAGE_RESPONSE_SCHEMA = {
218
+ type: 'object',
219
+ properties: {
220
+ category: {
221
+ type: 'string',
222
+ enum: ['appointment_request', 'job_inquiry', 'flag_spam'],
223
+ },
224
+ },
225
+ required: ['category'],
226
+ }
227
+
228
+ const AGENT_PROMPT_BODY = `You are a triage assistant. Categorize the following message into EXACTLY ONE of these categories:
229
+
230
+ - "appointment_request" — the user wants to book/reschedule a medical appointment
231
+ - "job_inquiry" — the user is asking about employment or job opportunities
232
+ - "flag_spam" — the message is promotional / spam / irrelevant
233
+
234
+ Submission ID: {{ submission_id }}
235
+ Message: {{ msg }}
236
+
237
+ Respond with a JSON object: {"category": "<one of the three categories above>"}`
238
+
239
+ const agentResp = await api.aiAgents.create(tenantSlug, datalakeSlug, {
240
+ name: `Cookbook Contact Us Triage Agent ${runSuffix}`,
241
+ tool_id: ctx.llmToolId,
242
+ model: 'gemma3:1b',
243
+ data_access: 'unregulated',
244
+ temperature: 0.0,
245
+ max_tokens: 1024,
246
+ enabled: true,
247
+ input_schema: TRIAGE_INPUT_SCHEMA,
248
+ llm_response_schema: TRIAGE_RESPONSE_SCHEMA,
249
+ prompt_config: { type: 'custom', body: AGENT_PROMPT_BODY },
250
+ })
251
+ aiAgentId = agentResp.data.id!
252
+ ctx.agentSlug = agentResp.data.slug!
253
+ ```
254
+
255
+ ## 005 — create the Contact Us Triage workflow
256
+
257
+ The workflow has the standard shape — filter, decision, actions —
258
+ but two things distinguish it from a static workflow. First, the
259
+ `ai_agents` array binds the Triage agent into the workflow's
260
+ enrichment phase, with a Liquid `context_mapping_config` that
261
+ projects each submission row's fields into the agent's input
262
+ schema. Second, the `decision_config` body is a Liquid template
263
+ that interpolates the agent's `category` output (read from
264
+ `additional_context["<agent-slug>"].category`) into a
265
+ single-element decision array. The three `actions` are keyed
266
+ `decision_key: appointment_request|job_inquiry|flag_spam`;
267
+ whichever category the agent emits picks the action that fires,
268
+ leaving the other two `:skipped`.
269
+
270
+ Bracket access (`additional_context["..."]`) is used because the
271
+ agent's slug contains hyphens, which a dotted Liquid lookup could
272
+ misread as subtraction operators.
273
+
274
+ ```typescript
275
+ const BUCKETS = ['appointment_request', 'job_inquiry', 'flag_spam'] as const
276
+
277
+ const CONTEXT_MAPPING_BODY = JSON.stringify({
278
+ msg: '{{ event_dataset.message }}',
279
+ submission_id: '{{ event_dataset.submission_id }}',
280
+ })
281
+
282
+ const DECISION_CONFIG_BODY = `["{{ additional_context["${ctx.agentSlug}"].category }}"]`
283
+ const DECISION_OUTPUT_SCHEMA = '{"type":"array","items":{"type":"string"}}'
284
+
285
+ const workflowResp = await api.workflows.create(tenantSlug, datalakeSlug, {
286
+ name: `Cookbook Contact Us Triage Workflow ${runSuffix}`,
287
+ description: 'Triages contact-us submissions into appointment_request/job_inquiry/flag_spam via an LLM agent; one SMS action per bucket.',
288
+ dataset_type: 'generic_table',
289
+ generic_table_id: genericTableId,
290
+ skip_mdm_resolution: true,
291
+ status: 'live',
292
+ filter_config: {
293
+ type: 'custom',
294
+ body: 'true',
295
+ output_schema: '{"type":"boolean"}',
296
+ },
297
+ decision_config: {
298
+ type: 'custom',
299
+ body: DECISION_CONFIG_BODY,
300
+ output_schema: DECISION_OUTPUT_SCHEMA,
301
+ },
302
+ ai_agents: [
303
+ {
304
+ ai_agent_id: aiAgentId,
305
+ position: 0,
306
+ context_mapping_config: {
307
+ type: 'custom',
308
+ body: CONTEXT_MAPPING_BODY,
309
+ output_schema: '{"type":"object"}',
310
+ },
311
+ },
312
+ ],
313
+ actions: BUCKETS.map((bucket) => ({
314
+ decision_key: bucket,
315
+ action_type: 'sms',
316
+ tool_id: toolId,
317
+ position: 0,
318
+ trigger_template: 'now',
319
+ idempotency_template: `{{ subject_id }}-{{ action_id }}-${bucket}-{{ "" | uuid }}`,
320
+ tool_call: {
321
+ tool_call_type: 'sms_request',
322
+ to: { type: 'custom', body: '+15551234567' },
323
+ body: {
324
+ type: 'custom',
325
+ body: `Triage [${bucket}]: {{ event_dataset.message }}`,
326
+ },
327
+ sms_type: 'transactional',
328
+ },
329
+ })),
330
+ })
331
+ workflowId = workflowResp.data.id!
332
+ ctx.workflowSlug = workflowResp.data.slug!
333
+ ```
334
+
335
+ ## 006 — find the generic table's auto-provisioned default DAC
336
+
337
+ Creating a generic table provisions two things automatically: an
338
+ identity interoperability contract and a default Data Activation
339
+ Client bound to it (name pattern `<datalake> <gt_name>
340
+ DataActivationClient`, `tool_call: manual_upload`). No data source,
341
+ tool, interop contract, or DAC needs to be created by hand for
342
+ plain row ingestion into the table — the default DAC is found by
343
+ GT-name convention in the DAC listing.
344
+
345
+ ```typescript
346
+ const deadline = Date.now() + 30_000
347
+ let found: { slug?: string | null; name?: string | null } | undefined
348
+ while (Date.now() < deadline && !found) {
349
+ const { data } = await api.dataActivationClients.list(tenantSlug, datalakeSlug)
350
+ found = (data.data ?? []).find((d) => (d.name ?? '').includes(ctx.gtName))
351
+ if (!found) await new Promise((r) => setTimeout(r, 1_000))
352
+ }
353
+ if (!found?.slug) {
354
+ throw new Error(`no default DAC found for GT ${ctx.gtName} within 30s`)
355
+ }
356
+ ctx.dacSlug = found.slug
357
+ ```
358
+
359
+ ## 007 — ingest three contact-us submissions through the default DAC
360
+
361
+ Three rows, ingested as inline JSON through the default DAC. Each
362
+ carries a deliberately distinct `message` — a clear appointment
363
+ request, an unmistakable job inquiry, and obvious promotional spam
364
+ — so the agent has something unambiguous to classify. The
365
+ `category` column is left unset on ingest; it is the agent's job
366
+ to assign one. Each ingest call returns its own batch id; all
367
+ three are pinned for the run scope in §009.
368
+
369
+ ```typescript
370
+ const submissionRows = [
371
+ {
372
+ submission_id: `SUB-APPT-${runSuffix}`,
373
+ name: 'Maria Garcia',
374
+ email: `maria-${runSuffix}@example.com`,
375
+ message:
376
+ 'I would like to schedule an appointment with Dr. Johnson next week if possible.',
377
+ },
378
+ {
379
+ submission_id: `SUB-JOB-${runSuffix}`,
380
+ name: 'James Wilson',
381
+ email: `james-${runSuffix}@example.com`,
382
+ message:
383
+ 'I am a registered nurse looking for employment opportunities at your clinic.',
384
+ },
385
+ {
386
+ submission_id: `SUB-SPAM-${runSuffix}`,
387
+ name: 'BestDeals2026',
388
+ email: `promo-${runSuffix}@cheapmeds.xyz`,
389
+ message:
390
+ 'HUGE DISCOUNT on cheap medications and miracle cures! CLICK HERE NOW for 90% off!',
391
+ },
392
+ ]
393
+
394
+ const ingestResults = await Promise.all(
395
+ submissionRows.map((row) =>
396
+ api.dataActivationClients.ingest(tenantSlug, datalakeSlug, ctx.dacSlug, { data: row }),
397
+ ),
398
+ )
399
+ ctx.submissionBatchIds = ingestResults.map((r) => r.data.batch_id!)
400
+ if (ctx.submissionBatchIds.length !== 3) {
401
+ throw new Error(`expected 3 ingest batch ids, got ${ctx.submissionBatchIds.length}`)
402
+ }
403
+ ```
404
+
405
+ ## 008 — wait for the three ingest batches to reach steady-state
406
+
407
+ Ingestion is async — the DAC enqueues per-row jobs that drain into
408
+ the generic table. Poll the DAC's activation logs until every
409
+ batch shows a row with `dataset_updated >= 1` (the submission row
410
+ was upserted into the table).
411
+
412
+ ```typescript
413
+ const targetBatches = new Set<string>(ctx.submissionBatchIds)
414
+ const deadline = Date.now() + 90_000
415
+ let greenCount = 0
416
+ while (Date.now() < deadline) {
417
+ const { data } = await api.dataActivationClients.logs.list(tenantSlug, datalakeSlug, ctx.dacSlug)
418
+ const green = new Set<string>()
419
+ for (const row of (data.data ?? []) as Array<Record<string, unknown>>) {
420
+ const b = row.batch_id
421
+ if (typeof b !== 'string' || !targetBatches.has(b)) continue
422
+ if (typeof row.dataset_updated !== 'number' || row.dataset_updated < 1) continue
423
+ green.add(b)
424
+ }
425
+ greenCount = green.size
426
+ if (greenCount === targetBatches.size) break
427
+ await new Promise((r) => setTimeout(r, 1_000))
428
+ }
429
+ if (greenCount !== targetBatches.size) {
430
+ throw new Error(`only ${greenCount}/3 submission batches reached steady-state within 90s`)
431
+ }
432
+ ```
433
+
434
+ ## 009 — run the workflow against the three submissions
435
+
436
+ `workflows.run` triggers the full agent-driven pipeline per row:
437
+ filter → agent enrichment (the Ollama call that classifies the
438
+ message) → decision (the category interpolated into the decision
439
+ array) → action fan-out. The SQL where-clause scopes the run to
440
+ exactly the three submission ids §007 ingested (`submission_id` is
441
+ the generic table's unique column, so it pins the run directly).
442
+
443
+ The Triage actions use `trigger_template: 'now'`, so they dispatch
444
+ immediately and the run reaches a terminal status — poll
445
+ `batchLogs.refresh` until it leaves `:pending`. The window is
446
+ generous because each row's enrichment is a live LLM inference
447
+ call.
448
+
449
+ ```typescript
450
+ const submissionIds = [
451
+ `SUB-APPT-${runSuffix}`,
452
+ `SUB-JOB-${runSuffix}`,
453
+ `SUB-SPAM-${runSuffix}`,
454
+ ]
455
+ const idList = submissionIds.map((id) => `'${id}'`).join(', ')
456
+
457
+ const runResp = await api.workflows.run(tenantSlug, datalakeSlug, ctx.workflowSlug, {
458
+ sql_where_clause: `submission_id IN (${idList})`,
459
+ mode: 'live',
460
+ manual_override: true,
461
+ })
462
+ ctx.runLogId = runResp.data.workflow_run_log_id!
463
+ ctx.runBatchId = runResp.data.batch_id!
464
+
465
+ const deadline = Date.now() + 240_000
466
+ let status: string | null = null
467
+ while (Date.now() < deadline) {
468
+ const { data: log } = await api.workflows.batchLogs.refresh(tenantSlug, datalakeSlug, ctx.workflowSlug, ctx.runLogId)
469
+ status = log.status ?? null
470
+ if (status && status !== 'pending') break
471
+ await new Promise((r) => setTimeout(r, 2_000))
472
+ }
473
+ if (status === 'failed') {
474
+ throw new Error('agent-driven workflow run reached :failed')
475
+ }
476
+ if (!status || status === 'pending') {
477
+ throw new Error('workflow run did not leave :pending within 240s')
478
+ }
479
+ ```
480
+
481
+ ## 010 — verify the agent's category steered the fan-out
482
+
483
+ Each submission produced a Workflow Execution Log. Every WEL has
484
+ exactly **three** action execution logs — one per bucket — and
485
+ exactly **one** is matched (`:pending` or `:completed`, the
486
+ category the agent emitted) while the other **two** are
487
+ `:skipped`. A run where a WEL had two matched actions, or zero,
488
+ would mean the agent's output did not actually steer the decision;
489
+ this step fails loudly in that case.
490
+
491
+ ```typescript
492
+ const { data: wfLogs } = await api.workflows.workflowLogs.list(tenantSlug, datalakeSlug, ctx.workflowSlug)
493
+ const ourWels = (wfLogs.data ?? []).filter(
494
+ (w) => (w as { batch_id?: string }).batch_id === ctx.runBatchId,
495
+ )
496
+ if (ourWels.length !== 3) {
497
+ throw new Error(`expected 3 WELs for the run, got ${ourWels.length}`)
498
+ }
499
+
500
+ for (const wel of ourWels) {
501
+ const welId = (wel as { id?: string }).id
502
+ const aels =
503
+ (wel as { action_execution_logs?: Array<{ status?: string; decision_key?: string }> })
504
+ .action_execution_logs ?? []
505
+ if (aels.length !== 3) {
506
+ throw new Error(`WEL ${welId}: expected 3 AELs (one per bucket), got ${aels.length}`)
507
+ }
508
+ const byStatus: Record<string, number> = {}
509
+ for (const ael of aels) {
510
+ const st = ael.status ?? 'unknown'
511
+ byStatus[st] = (byStatus[st] ?? 0) + 1
512
+ }
513
+ const matched = (byStatus.pending ?? 0) + (byStatus.completed ?? 0)
514
+ if (matched !== 1) {
515
+ throw new Error(`WEL ${welId}: expected exactly 1 matched AEL, got ${matched} — ${JSON.stringify(byStatus)}`)
516
+ }
517
+ if ((byStatus.skipped ?? 0) !== 2) {
518
+ throw new Error(`WEL ${welId}: expected 2 :skipped AELs — ${JSON.stringify(byStatus)}`)
519
+ }
520
+ const routed = aels.find((a) => a.status === 'pending' || a.status === 'completed')
521
+ if (!routed?.decision_key || !/^(appointment_request|job_inquiry|flag_spam)$/.test(routed.decision_key)) {
522
+ throw new Error(`WEL ${welId}: matched AEL has unexpected decision_key ${routed?.decision_key}`)
523
+ }
524
+ }
525
+ ```
526
+
527
+ # Branches
528
+
529
+ - **The filter is permissive** — `filter_config.body: 'true'`
530
+ passes every row, so all three submissions reach the agent. The
531
+ interesting routing happens at the decision step, not the
532
+ filter. A production triage workflow might gate on a
533
+ not-yet-handled flag in the filter; that polarity is covered by
534
+ the `appointment-review-sms-workflow` cookbook.
535
+ - **Agent emits an out-of-enum category** — the LLM response
536
+ schema's `enum` constraint guards the decision interpolation. If
537
+ the model returned an unexpected string the workflow row's
538
+ execution log would land `:failed`. The anchor vitest treats
539
+ that as a model-quality failure rather than a platform bug;
540
+ production deployments either tighten the prompt or add a
541
+ default SMS action as a fallback bucket.
542
+ - **Transport failure on the LLM call** — the anchor vitest's §8
543
+ wires a second LLM tool to a deliberately wrong Ollama URL and
544
+ asserts the WEL lands `:failed` with `error_code:
545
+ tool_execution_failed` in its `error.json` artifact. This
546
+ cookbook walks only the happy path; the failure branch is the
547
+ anchor test's own coverage.
548
+
549
+ # Rollback
550
+
551
+ The cookbook doctest harness does not currently tear down created
552
+ resources. The `_setup/healthcare.md` setup file's runSuffix-scoped
553
+ tenant / datalake / user names mean each run is naturally isolated;
554
+ the seeded local DB is cheap to reset (`mix ecto.reset` on the
555
+ platform repo).
556
+
557
+ # Outcome
558
+
559
+ After this cookbook's ten steps run green:
560
+
561
+ - A healthcare tenant exists with a healthcare-domain datalake
562
+ - A Contact Us Generic Table, an SMS tool, a chat-completion LLM
563
+ tool, a Contact Us Triage AI agent (gemma3:1b, three-category
564
+ response schema), and a Contact Us Triage agent-driven workflow
565
+ are all registered
566
+ - Three contact-us submissions have been ingested through the
567
+ table's auto-provisioned default DAC
568
+ - Running the workflow drove each row through the agent: the LLM
569
+ classified the message, the decision interpolated the category,
570
+ and the matching bucket's SMS action fired
571
+ - The routing is verified row-by-row — every Workflow Execution
572
+ Log carries exactly one matched action and two `:skipped`,
573
+ proving the agent's output actually steered the fan-out
574
+
575
+ The business outcome — inbound contact-us messages triaged by an
576
+ LLM and routed to bucket-specific outreach — is demonstrated
577
+ end-to-end, not merely provisioned.
578
+
579
+ # See also
580
+
581
+ - `_setup/healthcare.md` — the inlined bootstrap that provisions
582
+ the tenant + datalake this cookbook starts from
583
+ - `appointment-review-sms-workflow.md` — the static-decision
584
+ counterpart in healthcare; same SMS wiring, full data-activation
585
+ chain, no agent
586
+ - `score-leads-with-llm-categorization.md` — the foundation
587
+ agent-driven cookbook; identical agent + generic-table shape,
588
+ four bands instead of three buckets
589
+ - `.agent/tools.md` — SMS and chat-completion tool body shapes;
590
+ intent classification
591
+ - `.agent/ai_agents.md` — AI agent registration shape; input +
592
+ response schemas; prompt config
593
+ - `.agent/workflows.md` — standard and agent-driven workflow
594
+ primitives; `ai_agents`, `context_mapping_config`, and
595
+ agent-output decision interpolation
596
+ - `.agent/generic_tables.md` — generic-table creation and the
597
+ auto-provisioned identity contract + default DAC
598
+ - `integration-tests/tests/healthcare/agent-driven-workflow.test.ts` —
599
+ the anchor green test (positive branch §1, §3, §4, §6, §7) these
600
+ snippets are lifted from
601
+ - `integration-tests/tests/healthcare/generic-tables.test.ts`,
602
+ `tools.test.ts` — the per-resource create snippets are lifted
603
+ from