@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,761 @@
1
+ ---
2
+ title: Send a patient review-request SMS after a fulfilled appointment
3
+ summary: End-to-end standard workflow — provision a Review SMS workflow over the FHIR appointment dataset, ingest two CAHPS appointment rows (one fulfilled, one cancelled) through patient + appointment interop contracts, run the workflow so the status filter routes each row, and close the connected-app reply loop. No agent, no LLM.
4
+ industry: healthcare
5
+ slug: appointment-review-sms-workflow
6
+ vitest_source:
7
+ - integration-tests/tests/healthcare/standard-workflow.test.ts
8
+ - integration-tests/tests/healthcare/interoperability-contracts.test.ts
9
+ - integration-tests/tests/healthcare/run-dac-single.test.ts
10
+ - integration-tests/tests/healthcare/create-dac.test.ts
11
+ - integration-tests/tests/healthcare/tools.test.ts
12
+ - integration-tests/tests/healthcare/data-sources.test.ts
13
+ - integration-tests/tests/healthcare/bootstrap.test.ts
14
+ status: green
15
+ ---
16
+
17
+ # Problem
18
+
19
+ A clinic wants to ask every patient for feedback after a visit —
20
+ but only after a visit that actually happened. A cancelled
21
+ appointment should never trigger a "how was your visit?" message.
22
+ The classic implementation queries the appointments table on a
23
+ nightly cron, filters on status, and hands each match to an SMS
24
+ sender; the status logic, the scheduling, and the EMR field
25
+ mapping all leak into application code.
26
+
27
+ The Alvera platform's standard workflow primitive keeps all three
28
+ inside the platform. A FHIR R4 `appointment` dataset is the
29
+ workflow's event source; a pure-Liquid filter gates on
30
+ `appointment.status == "fulfilled"`; a single SMS action carries
31
+ a deep-link to a connected-app feedback form. The EMR-to-FHIR
32
+ mapping is a separate concern owned by the interoperability
33
+ contracts that ingest the rows — the workflow sees only the
34
+ canonical `appointment.status`, never the vendor's raw
35
+ `appt_slot_status` string.
36
+
37
+ This cookbook walks the **whole** scenario, not just the
38
+ provisioning: it creates the workflow, ingests two CAHPS-shaped
39
+ appointment rows through the production data-activation chain
40
+ (one checked-out, one cancelled), runs the workflow so the filter
41
+ routes each row, and finally resolves the connected-app deep-link
42
+ the SMS carries — closing the SMS → reply loop.
43
+
44
+ The scenario is anchored to
45
+ `platform/integration-tests/tests/healthcare/standard-workflow.test.ts`
46
+ — a green end-to-end test (§1–§6) that exercises this exact shape.
47
+ This cookbook is a prose re-presentation of what that test walks.
48
+ The setup file `_setup/healthcare.md` already provisioned the
49
+ tenant, datalake, and industry-admin client; this cookbook starts
50
+ from there.
51
+
52
+ # Composition
53
+
54
+ | Resource provisioned | Owner |
55
+ |-------------------------------------|-------------|
56
+ | SMS tool (SNS-backed) | build |
57
+ | Review SMS connected app | build |
58
+ | Review SMS workflow | build |
59
+ | Athenahealth data source | build |
60
+ | Manual Upload tool | build |
61
+ | CAHPS Patient interop contract | build |
62
+ | CAHPS Appointment interop contract | build |
63
+ | Manual-upload DAC | build |
64
+
65
+ The setup file `_setup/healthcare.md` already provisioned the
66
+ tenant + datalake + tenant-scoped client; this cookbook starts
67
+ from there.
68
+
69
+ # Walkthrough
70
+
71
+ ## 001 — create the SMS tool
72
+
73
+ The Review SMS workflow's action invokes an SMS tool. The tool's
74
+ `body.tool_body_type: 'sns'` means it routes via AWS SNS; local
75
+ dev points it at LocalStack on `http://localhost:4566` via
76
+ `endpoint_url` so no real AWS credentials are needed. The
77
+ `intent: 'sms'` tags this tool for workflow actions that send SMS
78
+ (versus `data_exchange` for ingestion tools).
79
+
80
+ ```typescript
81
+ const smsToolResp = await api.tools.create(tenantSlug, datalakeSlug, {
82
+ name: `Cookbook SMS Tool ${runSuffix}`,
83
+ description: 'SNS-backed SMS dispatcher for the Review SMS workflow, wired to LocalStack.',
84
+ intent: 'sms',
85
+ status: 'active',
86
+ datalake_id: ctx.datalakeId,
87
+ body: {
88
+ tool_body_type: 'sns',
89
+ auth_method: 'access_key',
90
+ region: 'us-east-1',
91
+ phone_number: '+15551234567',
92
+ endpoint_url: 'http://localhost:4566',
93
+ access_key_id: 'test',
94
+ secret_access_key: 'test',
95
+ },
96
+ })
97
+ toolId = smsToolResp.data.id!
98
+ ```
99
+
100
+ ## 002 — create the Review SMS connected app
101
+
102
+ The workflow's SMS action carries a deep-link to a connected-app
103
+ page so the patient can complete the feedback form. The connected
104
+ app is a thin registration of the form's URL and mode; the actual
105
+ page is hosted outside the platform (`mode: 'self_hosted'`). The
106
+ server-derived `slug` is captured for the resolve-page call in
107
+ §014.
108
+
109
+ ```typescript
110
+ const connectedAppResp = await api.connectedApps.create(tenantSlug, datalakeSlug, {
111
+ name: `Cookbook Review SMS Form ${runSuffix}`,
112
+ description: 'Patient-review form linked from outbound SMS — used by the Review SMS workflow.',
113
+ mode: 'self_hosted',
114
+ urls: [
115
+ {
116
+ url: 'https://review.example.local',
117
+ is_primary: true,
118
+ label: 'production',
119
+ },
120
+ ],
121
+ })
122
+ connectedAppId = connectedAppResp.data.id!
123
+ ctx.connectedAppSlug = connectedAppResp.data.slug!
124
+ ```
125
+
126
+ ## 003 — create the Review SMS workflow
127
+
128
+ The workflow has the standard shape: a filter (Liquid; passes
129
+ appointments whose `status` is `fulfilled`), a decision (a literal
130
+ Liquid array naming one decision key), and one SMS action. The SMS
131
+ body references `{{ connected_app_form_url }}` — the platform
132
+ injects that variable at action-execution time after minting the
133
+ connected-app page token, so §014 can resolve the deep-link.
134
+
135
+ Two details distinguish this workflow from a generic-table one.
136
+ First, `dataset_type: 'appointment'` with `skip_mdm_resolution:
137
+ false` means the workflow resolves each appointment's patient via
138
+ MDM, so the SMS `to` and body templates can read
139
+ `mdm_output.regulated_patient`. Second, a `context_datasets` entry
140
+ queries the `message` dataset for any review SMS already sent to
141
+ this patient in the last six months — the platform's built-in
142
+ guard against re-pestering a patient. On a fresh tenant that
143
+ lookup returns empty, which is harmless.
144
+
145
+ ```typescript
146
+ const FILTER_BODY = '{% if appointment.status == "fulfilled" %}true{% endif %}'
147
+ const DECISION_KEY = 'send_appointment_review_sms'
148
+ const DECISION_BODY = `["${DECISION_KEY}"]`
149
+ const DECISION_OUTPUT_SCHEMA = '{"type":"array","items":{"type":"string"}}'
150
+ const SMS_TO_TEMPLATE =
151
+ '{{ mdm_output.regulated_patient.telecom | where: "system", "phone" | first | map: "value" | e164 }}'
152
+ const SMS_BODY_TEMPLATE =
153
+ 'Hi {{ mdm_output.regulated_patient.name | first | map: "given" | first }}, ' +
154
+ 'thank you for your recent visit. ' +
155
+ 'Please share your feedback at {{ connected_app_form_url }}'
156
+
157
+ const workflowResp = await api.workflows.create(tenantSlug, datalakeSlug, {
158
+ name: `Cookbook Review SMS Workflow ${runSuffix}`,
159
+ description: 'Sends a review-request SMS with a connected-app form link after a fulfilled appointment.',
160
+ dataset_type: 'appointment',
161
+ status: 'live',
162
+ skip_mdm_resolution: false,
163
+ filter_config: {
164
+ type: 'custom',
165
+ body: FILTER_BODY,
166
+ output_schema: '{"type":"boolean"}',
167
+ },
168
+ decision_config: {
169
+ type: 'custom',
170
+ body: DECISION_BODY,
171
+ output_schema: DECISION_OUTPUT_SCHEMA,
172
+ },
173
+ context_datasets: [
174
+ {
175
+ dataset_type: 'message',
176
+ where_clause:
177
+ `rm.patient_id = '{{ patient_id }}' AND rm.decision_key = '${DECISION_KEY}' ` +
178
+ "AND rm.sent_at > NOW() - INTERVAL '6 months'",
179
+ limit: 1,
180
+ position: 0,
181
+ },
182
+ ],
183
+ actions: [
184
+ {
185
+ action_type: 'sms',
186
+ tool_id: toolId,
187
+ decision_key: DECISION_KEY,
188
+ position: 0,
189
+ trigger_template: 'now',
190
+ idempotency_template:
191
+ '{{ patient_id }}-{{ appointment.unregulated_appointment_id }}-{{ decision_key }}',
192
+ connected_app_id: connectedAppId,
193
+ connected_app_route: '/forms/review',
194
+ connected_app_metadata_template:
195
+ '{"appointment_id":"{{ appointment.unregulated_appointment_id }}","patient_id":"{{ mdm_output.patient.id }}"}',
196
+ tool_call: {
197
+ tool_call_type: 'sms_request',
198
+ to: { type: 'custom', body: SMS_TO_TEMPLATE },
199
+ body: { type: 'custom', body: SMS_BODY_TEMPLATE },
200
+ sms_type: 'transactional',
201
+ },
202
+ },
203
+ ],
204
+ })
205
+ workflowId = workflowResp.data.id!
206
+ ctx.workflowSlug = workflowResp.data.slug!
207
+ ```
208
+
209
+ ## 004 — create the athenahealth data source
210
+
211
+ A workflow runs on rows; rows arrive through the data-activation
212
+ chain. The chain's first link is a `DataSource` — a registration
213
+ of where the rows originate. The `uri` is the system-of-record
214
+ address; it flows into each ingested row's `source_uri`, which the
215
+ CAHPS templates render onto the FHIR identifier `system` URN.
216
+
217
+ ```typescript
218
+ const dataSourceResp = await api.dataSources.create(tenantSlug, datalakeSlug, {
219
+ name: `Cookbook Athenahealth Source ${runSuffix}`,
220
+ uri: '12345.athenahealth.com',
221
+ description: 'Athenahealth EMR — origin of the CAHPS appointment rows the Review SMS workflow runs on.',
222
+ status: 'active',
223
+ is_default: false,
224
+ })
225
+ dataSourceId = dataSourceResp.data.id!
226
+ ```
227
+
228
+ ## 005 — create the Manual Upload tool
229
+
230
+ The Data Activation Client needs a tool. For inline-JSON ingest a
231
+ `manual_upload` tool is the minimal choice — `intent:
232
+ 'data_exchange'` distinguishes it from the SMS tool, and
233
+ `tool_body_type: 'manual_upload'` needs no endpoint or credential
234
+ wiring (the rows arrive in the ingest call body, not by the tool
235
+ fetching them).
236
+
237
+ ```typescript
238
+ const manualUploadToolResp = await api.tools.create(tenantSlug, datalakeSlug, {
239
+ name: `Cookbook Manual Upload Tool ${runSuffix}`,
240
+ description: 'Manual-upload data-exchange tool — backs the DAC that ingests CAHPS appointment rows.',
241
+ intent: 'data_exchange',
242
+ status: 'active',
243
+ datalake_id: ctx.datalakeId,
244
+ data_source_id: dataSourceId,
245
+ body: { tool_body_type: 'manual_upload' },
246
+ })
247
+ ctx.manualUploadToolId = manualUploadToolResp.data.id!
248
+ ```
249
+
250
+ ## 006 — create the CAHPS Patient interoperability contract
251
+
252
+ A CAHPS appointment row carries both patient and appointment
253
+ facts. The patient interoperability contract maps the row into a
254
+ FHIR R4 `Patient` upsert; this cookbook loads the production CAHPS
255
+ patient + MDM templates from the vendored fixtures directory
256
+ rather than inlining several hundred lines of Liquid.
257
+
258
+ Two template slots matter. `template_config` shapes the FHIR
259
+ Patient resource. `mdm_input_config` shapes the MDM input — the
260
+ identifiers, name, and demographics the platform's master-data
261
+ resolution keys on to decide whether this is a new patient or an
262
+ existing one.
263
+
264
+ The `filter_template` uses the CAHPS pipeline's parent-row guard.
265
+ Its semantics are inverted from a naive reading: a template that
266
+ renders **empty** means the row **passes**. `{% unless prnt_apptyn
267
+ == 'Y' %}true{% endunless %}` renders empty exactly when
268
+ `prnt_apptyn == 'Y'`, so only parent (canonical) appointment rows
269
+ flow through — the child rows the CAHPS export duplicates are
270
+ skipped.
271
+
272
+ ```typescript
273
+ const { readFileSync } = await import('node:fs')
274
+ const { join } = await import('node:path')
275
+ const patientTemplate = readFileSync(
276
+ join(process.env.COOKBOOK_FIXTURES_DIR!, 'healthcare/_cahps_appointments_healthcare_patient.liquid'),
277
+ 'utf8',
278
+ )
279
+ const mdmTemplate = readFileSync(
280
+ join(process.env.COOKBOOK_FIXTURES_DIR!, 'healthcare/_cahps_appointments_healthcare_mdm.liquid'),
281
+ 'utf8',
282
+ )
283
+
284
+ const PARENT_APPOINTMENT_FILTER =
285
+ "{% unless msg.row.prnt_apptyn == 'Y' or msg.row.prnt_apptyn == 'y' %}true{% endunless %}"
286
+
287
+ const patientContractResp = await api.interoperabilityContracts.create(tenantSlug, datalakeSlug, {
288
+ name: `Cookbook CAHPS Patient Contract ${runSuffix}`,
289
+ description: 'CAHPS appointments → FHIR R4 Patient (custom Liquid + MDM input).',
290
+ resource_type: 'patient',
291
+ filter_template: PARENT_APPOINTMENT_FILTER,
292
+ template_config: { type: 'custom', body: patientTemplate },
293
+ mdm_input_config: { type: 'custom', body: mdmTemplate },
294
+ generic_table_id: null,
295
+ })
296
+ interopContractId = patientContractResp.data.id!
297
+ ```
298
+
299
+ ## 007 — create the CAHPS Appointment interoperability contract
300
+
301
+ The appointment contract maps the same CAHPS row into a FHIR R4
302
+ `Appointment` upsert. Its `template_config` is the appointment
303
+ template — the one that maps the vendor's `appt_slot_status` string
304
+ (`3 - checked out`, `x - cancelled`, …) onto the canonical FHIR
305
+ `status` enum (`fulfilled`, `cancelled`, …) the workflow filter
306
+ reads. It carries the same `mdm_input_config` as the patient
307
+ contract so the appointment upsert can populate its `patient_id`
308
+ foreign key from the resolved patient.
309
+
310
+ The generator runs each numbered step as an isolated function, so
311
+ this step re-reads the vendored templates rather than relying on
312
+ §006's local variables.
313
+
314
+ ```typescript
315
+ const { readFileSync } = await import('node:fs')
316
+ const { join } = await import('node:path')
317
+ const appointmentTemplate = readFileSync(
318
+ join(process.env.COOKBOOK_FIXTURES_DIR!, 'healthcare/_cahps_appointments_healthcare_appointment.liquid'),
319
+ 'utf8',
320
+ )
321
+ const mdmTemplate = readFileSync(
322
+ join(process.env.COOKBOOK_FIXTURES_DIR!, 'healthcare/_cahps_appointments_healthcare_mdm.liquid'),
323
+ 'utf8',
324
+ )
325
+
326
+ const PARENT_APPOINTMENT_FILTER =
327
+ "{% unless msg.row.prnt_apptyn == 'Y' or msg.row.prnt_apptyn == 'y' %}true{% endunless %}"
328
+
329
+ const appointmentContractResp = await api.interoperabilityContracts.create(tenantSlug, datalakeSlug, {
330
+ name: `Cookbook CAHPS Appointment Contract ${runSuffix}`,
331
+ description: 'CAHPS appointments → FHIR R4 Appointment (custom Liquid + MDM input).',
332
+ resource_type: 'appointment',
333
+ filter_template: PARENT_APPOINTMENT_FILTER,
334
+ template_config: { type: 'custom', body: appointmentTemplate },
335
+ mdm_input_config: { type: 'custom', body: mdmTemplate },
336
+ generic_table_id: null,
337
+ })
338
+ ctx.appointmentContractId = appointmentContractResp.data.id!
339
+ ```
340
+
341
+ ## 008 — create the manual-upload DAC
342
+
343
+ The Data Activation Client binds the ingestion pieces — the
344
+ manual-upload tool, the data source, and **both** interop
345
+ contracts — into one ingestion endpoint. A single ingested CAHPS
346
+ row fans out through both contracts: the patient contract upserts
347
+ a `Patient`, the appointment contract upserts an `Appointment`
348
+ linked to it. `tool_call.tool_call_type: 'manual_upload'` selects
349
+ the inline-JSON ingest path. The server-derived `slug` is the
350
+ handle §009 ingests rows against.
351
+
352
+ ```typescript
353
+ const dacResp = await api.dataActivationClients.create(tenantSlug, datalakeSlug, {
354
+ name: `Cookbook CAHPS DAC ${runSuffix}`,
355
+ description: 'Manual-upload DAC — fans each CAHPS row through the patient + appointment contracts.',
356
+ tool_id: ctx.manualUploadToolId,
357
+ data_source_id: dataSourceId,
358
+ tool_call: { tool_call_type: 'manual_upload' },
359
+ interoperability_contract_ids: [interopContractId, ctx.appointmentContractId],
360
+ })
361
+ dacId = dacResp.data.id!
362
+ ctx.dacSlug = dacResp.data.slug!
363
+ ```
364
+
365
+ ## 009 — ingest two CAHPS appointment rows
366
+
367
+ Two rows, ingested as inline JSON through the manual-upload DAC.
368
+ Both are parent rows (`prnt_apptyn: 'Y'`) so the contract filter
369
+ passes them. They differ in one field that the workflow cares
370
+ about: `appt_slot_status`. The first row is `3 - checked out`,
371
+ which the appointment template maps to FHIR `status: fulfilled` —
372
+ the workflow filter will pass it. The second is `x - cancelled`,
373
+ mapped to `status: cancelled` — the filter must reject it.
374
+
375
+ The fulfilled patient's mobile number is per-run unique so §013
376
+ can find the exact SMS in LocalStack SNS. Each ingest call gets
377
+ its own batch id; both are pinned for the run scope in §011.
378
+
379
+ ```typescript
380
+ const phone7 = String(Date.now()).slice(-7).padStart(7, '0')
381
+ ctx.fulfilledPhone = `+1555${phone7}`
382
+ const fulfilledMobile = `555-${phone7.slice(0, 3)}-${phone7.slice(3)}`
383
+
384
+ const fulfilledRow = {
385
+ appointment_id: `APPT-FUL-${runSuffix}`,
386
+ parent_appointment_id: `APPT-FUL-${runSuffix}`,
387
+ appt_date: '3/12/2026',
388
+ prnt_apptyn: 'Y',
389
+ appt_start_time: '2:30 PM',
390
+ appt_slot_duration: '45',
391
+ appt_slot_status: '3 - checked out',
392
+ appt_type: 'Annual wellness visit',
393
+ patient_id: `PT-FUL-${runSuffix}`,
394
+ enterprise_id: `PT-FUL-${runSuffix}`,
395
+ patient_name: 'Elena Rivera',
396
+ patientdob: '7/22/1950',
397
+ patient_mobile_no: fulfilledMobile,
398
+ patient_risk_level: 'medium',
399
+ svc_department: 'EASTSIDE',
400
+ rndrng_provider_id: '602',
401
+ rndrng_provider: 'Dr. Thomas Nguyen',
402
+ }
403
+ const cancelledRow = {
404
+ appointment_id: `APPT-CAN-${runSuffix}`,
405
+ parent_appointment_id: `APPT-CAN-${runSuffix}`,
406
+ appt_date: '3/14/2026',
407
+ prnt_apptyn: 'Y',
408
+ appt_start_time: '9:00 AM',
409
+ appt_slot_duration: '30',
410
+ appt_slot_status: 'x - cancelled',
411
+ appt_type: 'Follow-up consultation',
412
+ patient_id: `PT-CAN-${runSuffix}`,
413
+ enterprise_id: `PT-CAN-${runSuffix}`,
414
+ patient_name: 'Marcus Doyle',
415
+ patientdob: '1/15/1972',
416
+ patient_mobile_no: '555-100-2000',
417
+ patient_risk_level: 'low',
418
+ svc_department: 'WESTSIDE',
419
+ rndrng_provider_id: '714',
420
+ rndrng_provider: 'Dr. Aisha Bello',
421
+ }
422
+
423
+ const [fulfilledIngest, cancelledIngest] = await Promise.all([
424
+ api.dataActivationClients.ingest(tenantSlug, datalakeSlug, ctx.dacSlug, { data: fulfilledRow }),
425
+ api.dataActivationClients.ingest(tenantSlug, datalakeSlug, ctx.dacSlug, { data: cancelledRow }),
426
+ ])
427
+ ctx.batchFulfilled = fulfilledIngest.data.batch_id!
428
+ ctx.batchCancelled = cancelledIngest.data.batch_id!
429
+ ```
430
+
431
+ ## 010 — wait for both ingest batches to reach steady-state
432
+
433
+ Ingestion is async — the DAC enqueues per-row jobs that drain
434
+ through both contracts and merge into the regulated patient +
435
+ appointment tables. Each CAHPS row produces two top-level DAC log
436
+ rows (one per contract) with `rows_ingested >= 1`; cascade rows
437
+ the appointment template emits for nested resources carry
438
+ `rows_ingested = 0` and are filtered out. Poll the DAC's
439
+ activation logs until both batches show their two top-level rows
440
+ with a non-empty `output_files` array (the merged Parquet landed
441
+ in object storage). Only then is it safe to run the workflow.
442
+
443
+ ```typescript
444
+ const targetBatches = new Set([ctx.batchFulfilled, ctx.batchCancelled])
445
+ const deadline = Date.now() + 120_000
446
+ let greenCount = 0
447
+ while (Date.now() < deadline) {
448
+ const { data } = await api.dataActivationClients.logs.list(tenantSlug, datalakeSlug, ctx.dacSlug)
449
+ const ingestedByBatch: Record<string, number> = {}
450
+ for (const row of (data.data ?? []) as Array<Record<string, unknown>>) {
451
+ const b = row.batch_id
452
+ if (typeof b !== 'string' || !targetBatches.has(b)) continue
453
+ if (typeof row.rows_ingested !== 'number' || row.rows_ingested < 1) continue
454
+ const files = row.output_files
455
+ if (!Array.isArray(files) || files.length === 0) continue
456
+ ingestedByBatch[b] = (ingestedByBatch[b] ?? 0) + 1
457
+ }
458
+ greenCount = Object.values(ingestedByBatch).filter((n) => n >= 2).length
459
+ if (greenCount === targetBatches.size) break
460
+ await new Promise((r) => setTimeout(r, 2_000))
461
+ }
462
+ if (greenCount !== targetBatches.size) {
463
+ throw new Error(
464
+ `only ${greenCount}/2 appointment batches reached steady-state ` +
465
+ '(patient + appointment contracts both merged) within 120s',
466
+ )
467
+ }
468
+ ```
469
+
470
+ ## 011 — run the workflow against the two batches
471
+
472
+ `workflows.run` with `manual_override: false` evaluates the
473
+ filter, so the `status == "fulfilled"` filter genuinely routes
474
+ each row. The SQL where-clause scopes the run to exactly the two
475
+ batches §009 ingested (`ra` is the regulated-appointments alias
476
+ the run-query exposes).
477
+
478
+ The SMS action's `trigger_template: 'now'` dispatches the action
479
+ immediately rather than scheduling it, so the run reaches a
480
+ terminal status on its own — poll `batchLogs.refresh` until it
481
+ leaves `:pending`. A `:partial` status is expected and fine here:
482
+ one row passed and one was filtered.
483
+
484
+ ```typescript
485
+ const runResp = await api.workflows.run(tenantSlug, datalakeSlug, ctx.workflowSlug, {
486
+ sql_where_clause: `ra.batch_id IN ('${ctx.batchFulfilled}', '${ctx.batchCancelled}')`,
487
+ mode: 'live',
488
+ manual_override: false,
489
+ })
490
+ ctx.runLogId = runResp.data.workflow_run_log_id!
491
+ ctx.runBatchId = runResp.data.batch_id!
492
+
493
+ const deadline = Date.now() + 120_000
494
+ let status: string | null = null
495
+ while (Date.now() < deadline) {
496
+ const { data: log } = await api.workflows.batchLogs.refresh(tenantSlug, datalakeSlug, ctx.workflowSlug, ctx.runLogId)
497
+ status = log.status ?? null
498
+ if (status && status !== 'pending') break
499
+ await new Promise((r) => setTimeout(r, 2_000))
500
+ }
501
+ if (status === 'failed') throw new Error('workflow run reached :failed')
502
+ if (!status || status === 'pending') {
503
+ throw new Error('workflow run did not leave :pending within 120s')
504
+ }
505
+ ```
506
+
507
+ ## 012 — verify the filter routed: fulfilled passes, cancelled is filtered
508
+
509
+ Each row produced a Workflow Execution Log. The fulfilled
510
+ appointment passes the filter, so its WEL is `:executing` or
511
+ `:completed`. The cancelled appointment fails the filter, so its
512
+ WEL is `:filtered`. A run where both passed — or both were
513
+ filtered — would mean the filter is not actually evaluating the
514
+ appointment status.
515
+
516
+ ```typescript
517
+ const { data: wfLogs } = await api.workflows.workflowLogs.list(tenantSlug, datalakeSlug, ctx.workflowSlug)
518
+ const ourWels = (wfLogs.data ?? []).filter(
519
+ (w) => (w as { batch_id?: string }).batch_id === ctx.runBatchId,
520
+ )
521
+ if (ourWels.length !== 2) {
522
+ throw new Error(`expected 2 WELs for the run, got ${ourWels.length}`)
523
+ }
524
+
525
+ const byStatus: Record<string, number> = {}
526
+ for (const w of ourWels) {
527
+ const st = (w as { status?: string }).status ?? 'unknown'
528
+ byStatus[st] = (byStatus[st] ?? 0) + 1
529
+ }
530
+ const passCount = (byStatus.executing ?? 0) + (byStatus.completed ?? 0)
531
+ if (passCount !== 1) {
532
+ throw new Error(`expected 1 pass-branch WEL, got ${passCount} — distribution ${JSON.stringify(byStatus)}`)
533
+ }
534
+ if ((byStatus.filtered ?? 0) !== 1) {
535
+ throw new Error(
536
+ `expected 1 :filtered WEL (the cancelled appointment) — distribution ${JSON.stringify(byStatus)}`,
537
+ )
538
+ }
539
+ ```
540
+
541
+ ## 013 — confirm the review SMS landed in LocalStack SNS
542
+
543
+ The SMS tool publishes direct-to-phone-number via AWS SNS;
544
+ LocalStack records every direct publish under
545
+ `/_aws/sns/sms-messages` keyed by recipient phone. The SMS `to`
546
+ template resolved the fulfilled patient's `patient_mobile_no`
547
+ through the `e164` Liquid filter, so the recipient is the
548
+ `+1555…` number §009 stamped onto that patient. Poll for a record
549
+ whose body carries the rendered greeting — proof the workflow's
550
+ stated business outcome (a review-request SMS) actually happened.
551
+
552
+ ```typescript
553
+ const LOCALSTACK = 'http://localhost:4566'
554
+ const deadline = Date.now() + 30_000
555
+ let matched: { Message: string; PhoneNumber: string } | undefined
556
+ while (Date.now() < deadline && !matched) {
557
+ const resp = await fetch(`${LOCALSTACK}/_aws/sns/sms-messages`)
558
+ if (resp.ok) {
559
+ const body = (await resp.json()) as {
560
+ sms_messages?: Record<string, Array<{ Message: string; PhoneNumber: string }>>
561
+ }
562
+ const records = body.sms_messages?.[ctx.fulfilledPhone] ?? []
563
+ matched = records.find(
564
+ (m) => m.Message.includes('thank you for your recent visit') && m.Message.includes('Elena'),
565
+ )
566
+ }
567
+ if (!matched) await new Promise((r) => setTimeout(r, 500))
568
+ }
569
+ if (!matched) {
570
+ throw new Error(`no review-request SMS landed in LocalStack SNS for ${ctx.fulfilledPhone} within 30s`)
571
+ }
572
+ ```
573
+
574
+ ## 014 — fire the action via workflows.execute for an introspectable WEL
575
+
576
+ The §011 run dispatched the SMS (§013 proved it landed in SNS),
577
+ but a run-created WEL schedules each action through a datetime
578
+ slot — the rendered `message_body` is not surfaced on its
579
+ action-execution-log. `workflows.execute` is the direct path: it
580
+ fires one named action on one dataset row and the resulting WEL
581
+ carries the fully-rendered message. `manual_override: true` is
582
+ load-bearing — §011 already processed this appointment, so without
583
+ the override the action's idempotency tuple would dedupe the
584
+ re-fire.
585
+
586
+ `workflows.execute` is addressed by the *unregulated* dataset id,
587
+ so first resolve the fulfilled appointment's id via a dataset
588
+ search scoped to its batch, then poll the execute WEL until it
589
+ reaches `:completed`.
590
+
591
+ ```typescript
592
+ const { data: us } = await api.datasets.createUserSearch(tenantSlug, datalakeSlug, 'appointment', {
593
+ search_query: `ra.batch_id = '${ctx.batchFulfilled}'`,
594
+ })
595
+ if (us.status !== 'completed') {
596
+ throw new Error(`appointment user-search status=${us.status} error=${us.error_message ?? '(none)'}`)
597
+ }
598
+ const { data: search } = await api.datasets.search(tenantSlug, datalakeSlug, 'appointment', {
599
+ userSearchId: us.id!,
600
+ dataAccessMode: 'unregulated',
601
+ })
602
+ const fulfilledAppointmentId = (search.data?.[0] as { id?: string } | undefined)?.id
603
+ if (!fulfilledAppointmentId) {
604
+ throw new Error('fulfilled appointment not found via unregulated search')
605
+ }
606
+
607
+ const { data: execResp } = await api.workflows.execute(tenantSlug, datalakeSlug, ctx.workflowSlug, {
608
+ dataset_id: fulfilledAppointmentId,
609
+ decision_key: 'send_appointment_review_sms',
610
+ manual_override: true,
611
+ })
612
+ ctx.execWelId = execResp.workflow_execution_log_id!
613
+
614
+ const deadline = Date.now() + 60_000
615
+ let welStatus: string | null = null
616
+ while (Date.now() < deadline) {
617
+ const { data: wel } = await api.workflows.workflowLogs.get(tenantSlug, datalakeSlug, ctx.workflowSlug, ctx.execWelId)
618
+ welStatus = (wel as { status?: string }).status ?? null
619
+ if (welStatus && welStatus !== 'pending' && welStatus !== 'executing') break
620
+ await new Promise((r) => setTimeout(r, 2_000))
621
+ }
622
+ if (welStatus !== 'completed') {
623
+ throw new Error(`execute WEL did not reach :completed (last status: ${welStatus})`)
624
+ }
625
+ ```
626
+
627
+ ## 015 — resolve the connected-app deep-link and post tracking
628
+
629
+ The action's SMS carries a `/t/<token>` shortlink minted from the
630
+ action's `connected_app_id` + `connected_app_route`. Read the
631
+ execute WEL in regulated mode to get the raw rendered
632
+ `message_body`, extract the token, and resolve it via
633
+ `connectedApps.resolvePage` — the `route_path` must match the
634
+ action's `connected_app_route`. Posting `opened_at` +
635
+ `form_submitted_at` via `updateMessageTracking` then mirrors what
636
+ the connected-app frontend does when the patient opens the page,
637
+ closing the SMS → reply loop end-to-end.
638
+
639
+ ```typescript
640
+ const { data: regulatedWel } = await api.workflows.workflowLogs.get(
641
+ tenantSlug,
642
+ datalakeSlug,
643
+ ctx.workflowSlug,
644
+ ctx.execWelId,
645
+ { dataAccessMode: 'regulated' },
646
+ )
647
+ const aelWithBody = (regulatedWel.action_execution_logs ?? []).find(
648
+ (ael) => typeof ael.message_body === 'string' && ael.message_body.length > 0,
649
+ )
650
+ if (!aelWithBody?.message_body) {
651
+ throw new Error('no AEL message_body on the executed WEL')
652
+ }
653
+ const tokenMatch = aelWithBody.message_body.match(/\/t\/([A-Za-z0-9_-]+)/)
654
+ if (!tokenMatch) {
655
+ throw new Error(`no /t/<token> in rendered SMS body: ${aelWithBody.message_body}`)
656
+ }
657
+ const shortPath = tokenMatch[1]!
658
+
659
+ const { data: resolved } = await api.connectedApps.resolvePage(tenantSlug, datalakeSlug, ctx.connectedAppSlug, {
660
+ short_path: shortPath,
661
+ user_agent: 'cookbook-doctest/appointment-review',
662
+ })
663
+ if (resolved.route_path !== '/forms/review') {
664
+ throw new Error(`resolvePage route_path mismatch: ${resolved.route_path}`)
665
+ }
666
+
667
+ const now = new Date().toISOString()
668
+ const { data: tracked } = await api.connectedApps.updateMessageTracking(tenantSlug, datalakeSlug, ctx.connectedAppSlug, {
669
+ short_path: shortPath,
670
+ opened_at: now,
671
+ form_submitted_at: now,
672
+ })
673
+ if (!tracked.message?.opened_at || !tracked.message?.form_submitted_at) {
674
+ throw new Error('message tracking did not persist opened_at + form_submitted_at')
675
+ }
676
+ ```
677
+
678
+ # Branches
679
+
680
+ - **The cancelled appointment is filtered, not failed** — §012
681
+ asserts the cancelled row's WEL is `:filtered`, a distinct
682
+ terminal status from `:failed`. A filtered row is a *correct*
683
+ outcome: the workflow looked at it, the `status == "fulfilled"`
684
+ filter rendered empty, and the platform recorded that the row
685
+ was intentionally skipped. No SMS action runs for a filtered row
686
+ — which is exactly the business rule (never ask for feedback on
687
+ a visit that did not happen).
688
+ - **The contract filter's inverted semantics** — §006/§007 wire a
689
+ `filter_template` whose convention is "renders empty ⇒ row
690
+ passes". The CAHPS export emits both parent and child rows for
691
+ one logical appointment; `{% unless prnt_apptyn == 'Y' %}…`
692
+ keeps only the parent. A row with `prnt_apptyn` other than `Y`
693
+ is dropped at the contract layer and never reaches the
694
+ appointment table — a different rejection point from the
695
+ workflow filter in §012.
696
+ - **The six-month recency guard** — §003's `context_datasets`
697
+ entry queries the `message` dataset for a prior review SMS to
698
+ the same patient. On this fresh tenant it returns empty, so the
699
+ action runs. On a tenant with history, a recent prior message
700
+ would make the action skip — the platform's built-in
701
+ anti-pestering guard, expressed as a context query rather than
702
+ application code.
703
+
704
+ # Rollback
705
+
706
+ The cookbook doctest harness does not currently tear down created
707
+ resources. The `_setup/healthcare.md` setup file's runSuffix-scoped
708
+ tenant / datalake / user names mean each run is naturally isolated;
709
+ the seeded local DB is cheap to reset (`mix ecto.reset` on the
710
+ platform repo).
711
+
712
+ # Outcome
713
+
714
+ After this cookbook's fifteen steps run green:
715
+
716
+ - A healthcare tenant exists with a healthcare-domain datalake
717
+ - An SMS tool, a Review SMS connected app, and a Review SMS
718
+ standard workflow (status `live`, `appointment` dataset) are
719
+ registered
720
+ - An athenahealth data source, a Manual Upload tool, a CAHPS
721
+ Patient interop contract, a CAHPS Appointment interop contract,
722
+ and a manual-upload DAC form a working ingestion chain
723
+ - Two appointments have been ingested through that chain — one
724
+ checked-out, one cancelled — each with its own resolved patient
725
+ - Running the workflow routed them correctly: the fulfilled
726
+ appointment passed the filter and dispatched an SMS; the
727
+ cancelled appointment was `:filtered`
728
+ - The dispatched SMS is observable in LocalStack SNS with the
729
+ rendered "thank you for your recent visit" greeting
730
+ - The connected-app deep-link the SMS carried resolves, and
731
+ message tracking records the open + form-submit timestamps
732
+
733
+ The business outcome — a review-request SMS sent to a patient
734
+ after a fulfilled visit, with a working feedback link — is
735
+ demonstrated end-to-end, not merely provisioned.
736
+
737
+ # See also
738
+
739
+ - `_setup/healthcare.md` — the inlined bootstrap that provisions
740
+ the tenant + datalake this cookbook starts from
741
+ - `contact-us-triage-with-llm.md` — the agent-driven healthcare
742
+ cookbook; same SMS shape, an LLM agent in the decision
743
+ - `.agent/tools.md` — SMS (`tool_body_type: sns`) and
744
+ manual-upload tool body shapes; intent classification
745
+ - `.agent/connected_apps.md` — connected-app registration + the
746
+ resolve-page / message-tracking reply loop
747
+ - `.agent/interoperability_contracts.md` — custom contract shape,
748
+ `template_config` vs `mdm_input_config`, the inverted
749
+ `filter_template` convention
750
+ - `.agent/data_activation_clients.md` — data source → tool →
751
+ interop contract → DAC ingestion chain
752
+ - `.agent/workflows.md` — standard workflow primitive (filter +
753
+ decision + actions), `context_datasets`, `workflows.run`
754
+ - `.agent/cookbook/_fixtures/healthcare/` — the vendored CAHPS
755
+ patient / appointment / MDM Liquid templates §006–§007 load
756
+ - `integration-tests/tests/healthcare/standard-workflow.test.ts` —
757
+ the anchor green test (§1–§6) these snippets are lifted from
758
+ - `integration-tests/tests/healthcare/interoperability-contracts.test.ts`,
759
+ `run-dac-single.test.ts`, `create-dac.test.ts`, `tools.test.ts`,
760
+ `data-sources.test.ts` — the per-resource create + ingest
761
+ snippets §004–§010 are lifted from