@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,647 +0,0 @@
1
- ---
2
- title: Send a welcome SMS to newly contracted customers with a billing-portal link
3
- summary: End-to-end standard workflow — provision a Welcome SMS workflow over the AR customer dataset, ingest two Stripe customer rows (one with a phone on file, one without) through a customer interop contract, run the workflow so the phone filter routes each row, read the rendered SMS back from the message dataset, and close the connected-app reply loop. No agent, no LLM.
4
- industry: subscription
5
- slug: welcome-sms-for-customers
6
- vitest_source:
7
- - integration-tests/tests/subscription/standard-workflow.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
- When a customer signs a contract, the billing team wants to send
20
- a short welcome SMS with a link to the self-serve billing portal —
21
- but only to customers the platform can actually reach by SMS. A
22
- customer with no phone number on file should be skipped, not
23
- errored.
24
-
25
- The classic implementation queries the customers table, branches
26
- on whether a phone exists, and hands the reachable ones to an SMS
27
- sender — with the reachability check living in application code.
28
-
29
- The Alvera platform's standard workflow primitive keeps that
30
- check inside the platform. An AR `customer` dataset is the
31
- workflow's event source; a pure-Liquid filter gates on
32
- `customer.phone`; a single SMS action carries a deep-link to a
33
- connected-app billing portal. The SMS-reachability decision is a
34
- one-line filter, not a code branch.
35
-
36
- This cookbook walks the **whole** scenario, not just the
37
- provisioning: it creates the workflow, ingests two Stripe-shaped
38
- customer rows through the production data-activation chain (one
39
- with a phone, one without), runs the workflow so the filter
40
- routes each row, reads the rendered welcome SMS back out of the
41
- message dataset, 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/subscription/standard-workflow.test.ts`
46
- — a green end-to-end test (§1–§8) that exercises this exact shape.
47
- This cookbook is a prose re-presentation of what that test walks.
48
- The setup file `_setup/subscription.md` already provisioned
49
- the tenant, datalake, and industry-admin client; this cookbook
50
- starts from there.
51
-
52
- # Composition
53
-
54
- | Resource provisioned | Owner |
55
- |----------------------------------|-------------|
56
- | SMS tool (SNS-backed) | build |
57
- | Welcome billing-portal connected app | build |
58
- | Welcome SMS workflow | build |
59
- | Stripe data source | build |
60
- | Manual Upload tool | build |
61
- | Stripe Customer interop contract | build |
62
- | Manual-upload DAC | build |
63
-
64
- The setup file `_setup/subscription.md` already
65
- provisioned the tenant + datalake + tenant-scoped client; this
66
- cookbook starts from there.
67
-
68
- # Walkthrough
69
-
70
- ## 001 — create the SMS tool
71
-
72
- The Welcome SMS workflow's action invokes an SMS tool. The tool's
73
- `body.tool_body_type: 'sns'` means it routes via AWS SNS; local
74
- dev points it at LocalStack on `http://localhost:4566` via
75
- `endpoint_url` so no real AWS credentials are needed. The
76
- `intent: 'sms'` tags this tool for workflow actions that send SMS
77
- (versus `data_exchange` for ingestion tools).
78
-
79
- ```typescript
80
- const smsToolResp = await api.tools.create(tenantSlug, datalakeSlug, {
81
- name: `Cookbook SMS Tool ${runSuffix}`,
82
- description: 'SNS-backed SMS dispatcher for the Welcome SMS workflow, wired to LocalStack.',
83
- intent: 'sms',
84
- status: 'active',
85
- datalake_id: ctx.datalakeId,
86
- body: {
87
- tool_body_type: 'sns',
88
- auth_method: 'access_key',
89
- region: 'us-east-1',
90
- phone_number: '+15551234567',
91
- endpoint_url: 'http://localhost:4566',
92
- access_key_id: 'test',
93
- secret_access_key: 'test',
94
- },
95
- })
96
- toolId = smsToolResp.data.id!
97
- ```
98
-
99
- ## 002 — create the Welcome billing-portal connected app
100
-
101
- The workflow's SMS action carries a deep-link to a connected-app
102
- page so the customer can open the self-serve billing portal. The
103
- connected app is a thin registration of the portal's URL and
104
- mode; the actual page is hosted outside the platform
105
- (`mode: 'self_hosted'`). The server-derived `slug` is captured for
106
- the resolve-page call in §013.
107
-
108
- ```typescript
109
- const connectedAppResp = await api.connectedApps.create(tenantSlug, datalakeSlug, {
110
- name: `Cookbook Welcome Billing Portal ${runSuffix}`,
111
- description: 'Self-serve billing portal linked from the outbound welcome SMS.',
112
- mode: 'self_hosted',
113
- urls: [
114
- {
115
- url: 'https://billing.example.local',
116
- is_primary: true,
117
- label: 'production',
118
- },
119
- ],
120
- })
121
- connectedAppId = connectedAppResp.data.id!
122
- ctx.connectedAppSlug = connectedAppResp.data.slug!
123
- ```
124
-
125
- ## 003 — create the Welcome SMS workflow
126
-
127
- The workflow has the standard shape: a filter (Liquid; passes
128
- customers whose `phone` is present), a decision (a literal Liquid
129
- array naming one decision key), and one SMS action. The SMS body
130
- references `{{ connected_app_form_url }}` — the platform injects
131
- that variable at action-execution time after minting the
132
- connected-app page token, so §012 can find the deep-link.
133
-
134
- Two details matter. `dataset_type: 'customer'` makes the AR
135
- customer dataset the event source; the workflow context injects
136
- the regulated customer row under the `customer` key, so the
137
- filter reads `customer.phone` directly. A `context_datasets` entry
138
- queries the `message` dataset for any welcome SMS already sent to
139
- this customer in the last six months — the platform's built-in
140
- guard against re-welcoming a customer. On a fresh tenant that
141
- lookup returns empty, which is harmless.
142
-
143
- ```typescript
144
- const FILTER_BODY = '{% if customer.phone %}true{% endif %}'
145
- const DECISION_KEY = 'send_welcome_sms'
146
- const DECISION_BODY = `["${DECISION_KEY}"]`
147
- const DECISION_OUTPUT_SCHEMA = { type: 'array', items: { type: 'string' } }
148
- const SMS_TO_TEMPLATE = '{{ mdm_output.regulated_customer.phone }}'
149
- const SMS_BODY_TEMPLATE =
150
- 'Hi {{ mdm_output.regulated_customer.name }}, welcome to Alvera Billing. ' +
151
- 'Self-serve billing portal: {{ connected_app_form_url }}'
152
-
153
- const workflowResp = await api.workflows.create(tenantSlug, datalakeSlug, {
154
- name: `Cookbook Welcome SMS Workflow ${runSuffix}`,
155
- description: 'Sends a welcome SMS to newly contracted customers with a self-serve billing link.',
156
- dataset_type: 'customer',
157
- status: 'live',
158
- tags: ['lifecycle', 'welcome'],
159
- filter_config: {
160
- type: 'custom',
161
- body: FILTER_BODY,
162
- output_schema: { type: 'boolean' },
163
- },
164
- decision_config: {
165
- type: 'custom',
166
- body: DECISION_BODY,
167
- output_schema: DECISION_OUTPUT_SCHEMA,
168
- },
169
- context_datasets: [
170
- {
171
- dataset_type: 'message',
172
- where_clause:
173
- `rm.customer_id = '{{ customer_id }}' AND rm.decision_key = '${DECISION_KEY}' ` +
174
- "AND rm.sent_at > NOW() - INTERVAL '6 months'",
175
- limit: 1,
176
- position: 0,
177
- },
178
- ],
179
- actions: [
180
- {
181
- action_type: 'sms',
182
- tool_id: toolId,
183
- decision_key: DECISION_KEY,
184
- position: 0,
185
- trigger_template: 'now',
186
- idempotency_template: '{{ customer_id }}-{{ decision_key }}',
187
- connected_app_id: connectedAppId,
188
- connected_app_route: '/portal/welcome',
189
- connected_app_metadata_template: '{"customer_id":"{{ mdm_output.customer.id }}"}',
190
- tool_call: {
191
- tool_call_type: 'sms_request',
192
- to: { type: 'custom', body: SMS_TO_TEMPLATE },
193
- body: { type: 'custom', body: SMS_BODY_TEMPLATE },
194
- sms_type: 'transactional',
195
- },
196
- },
197
- ],
198
- })
199
- workflowId = workflowResp.data.id!
200
- ctx.workflowSlug = workflowResp.data.slug!
201
- ```
202
-
203
- ## 004 — create the Stripe data source
204
-
205
- A workflow runs on rows; rows arrive through the data-activation
206
- chain. The chain's first link is a `DataSource` — a registration
207
- of where the rows originate. The `uri` is the system-of-record
208
- address; it flows into each ingested row's `source_uri`, which
209
- the customer template renders onto the MDM identifier `system`
210
- URN.
211
-
212
- ```typescript
213
- const dataSourceResp = await api.dataSources.create(tenantSlug, datalakeSlug, {
214
- name: `Cookbook Stripe Source ${runSuffix}`,
215
- uri: 'stripe.example.com',
216
- description: 'Stripe billing system — origin of the customer rows the welcome workflow runs on.',
217
- status: 'active',
218
- is_default: false,
219
- })
220
- dataSourceId = dataSourceResp.data.id!
221
- ```
222
-
223
- ## 005 — create the Manual Upload tool
224
-
225
- The Data Activation Client needs a tool. For inline-JSON ingest a
226
- `manual_upload` tool is the minimal choice — `intent:
227
- 'data_exchange'` distinguishes it from the SMS tool, and
228
- `tool_body_type: 'manual_upload'` needs no endpoint or credential
229
- wiring (the rows arrive in the ingest call body, not by the tool
230
- fetching them).
231
-
232
- ```typescript
233
- const manualUploadToolResp = await api.tools.create(tenantSlug, datalakeSlug, {
234
- name: `Cookbook Manual Upload Tool ${runSuffix}`,
235
- description: 'Manual-upload data-exchange tool — backs the DAC that ingests Stripe customer rows.',
236
- intent: 'data_exchange',
237
- status: 'active',
238
- datalake_id: ctx.datalakeId,
239
- data_source_id: dataSourceId,
240
- body: { tool_body_type: 'manual_upload' },
241
- })
242
- ctx.manualUploadToolId = manualUploadToolResp.data.id!
243
- ```
244
-
245
- ## 006 — create the Stripe Customer interoperability contract
246
-
247
- The interoperability contract is the row-shaping rule: a Liquid
248
- template that maps an inbound Stripe customer row into an AR
249
- `Customer` upsert. This cookbook loads the production Stripe
250
- customer + MDM templates from the vendored fixtures directory
251
- rather than inlining the Liquid.
252
-
253
- Two template slots matter. `template_config` shapes the Customer
254
- resource — including the nested `regulated_customer` object that
255
- carries the PII fields (`name`, `email`, `phone`, `tax_id`).
256
- `mdm_input_config` shapes the MDM input — the identifier
257
- (`customer_number`), name, and contact fields the platform's
258
- master-data resolution keys on to decide whether this is a new
259
- customer or an existing one.
260
-
261
- ```typescript
262
- const { readFileSync } = await import('node:fs')
263
- const { join } = await import('node:path')
264
- const customerTemplate = readFileSync(
265
- join(process.env.COOKBOOK_FIXTURES_DIR!, 'subscription/_customers_subscription_customer.liquid'),
266
- 'utf8',
267
- )
268
- const mdmTemplate = readFileSync(
269
- join(process.env.COOKBOOK_FIXTURES_DIR!, 'subscription/_customers_subscription_mdm.liquid'),
270
- 'utf8',
271
- )
272
-
273
- const contractResp = await api.interoperabilityContracts.create(tenantSlug, datalakeSlug, {
274
- name: `Cookbook Stripe Customer Contract ${runSuffix}`,
275
- description: 'Stripe customers → Subscription Customer (custom Liquid + MDM input).',
276
- resource_type: 'customer',
277
- template_config: { type: 'custom', body: customerTemplate },
278
- mdm_input_config: { type: 'custom', body: mdmTemplate },
279
- generic_table_id: null,
280
- })
281
- interopContractId = contractResp.data.id!
282
- ```
283
-
284
- ## 007 — create the manual-upload DAC
285
-
286
- The Data Activation Client binds the three preceding pieces — the
287
- manual-upload tool, the data source, and the interop contract —
288
- into one ingestion endpoint. `tool_call.tool_call_type:
289
- 'manual_upload'` selects the inline-JSON ingest path. The
290
- server-derived `slug` is the handle §008 ingests rows against.
291
-
292
- ```typescript
293
- const dacResp = await api.dataActivationClients.create(tenantSlug, datalakeSlug, {
294
- name: `Cookbook Stripe Customer DAC ${runSuffix}`,
295
- description: 'Manual-upload DAC — ingests Stripe customer rows into Customer via the interop contract.',
296
- tool_id: ctx.manualUploadToolId,
297
- data_source_id: dataSourceId,
298
- tool_call: { tool_call_type: 'manual_upload' },
299
- interoperability_contract_ids: [interopContractId],
300
- })
301
- dacId = dacResp.data.id!
302
- ctx.dacSlug = dacResp.data.slug!
303
- ```
304
-
305
- ## 008 — ingest two Stripe customer rows
306
-
307
- Two rows, ingested as inline JSON through the manual-upload DAC.
308
- They differ in one field the workflow filter cares about:
309
- `phone`. The first row carries a phone number — the filter will
310
- pass it. The second omits `phone` entirely — the filter must
311
- reject it. Each ingest call gets its own batch id; both are
312
- pinned for the run scope in §010.
313
-
314
- ```typescript
315
- const phone4 = String(Date.now()).slice(-4)
316
-
317
- const withPhoneRow = {
318
- customer_number: `CUST-WP-${runSuffix}`,
319
- customer_type: 'individual',
320
- status: 'contracted',
321
- name: 'Olivia Hartmann',
322
- email: `olivia-${runSuffix}@example.com`,
323
- phone: `+1202555${phone4}`,
324
- currency: 'USD',
325
- }
326
- const noPhoneRow = {
327
- customer_number: `CUST-NP-${runSuffix}`,
328
- customer_type: 'individual',
329
- status: 'contracted',
330
- name: 'Noah Pemberton',
331
- email: `noah-${runSuffix}@example.com`,
332
- currency: 'USD',
333
- // phone intentionally absent — the workflow filter must reject this row
334
- }
335
-
336
- const [withPhoneIngest, noPhoneIngest] = await Promise.all([
337
- api.dataActivationClients.ingest(tenantSlug, datalakeSlug, ctx.dacSlug, { data: withPhoneRow }),
338
- api.dataActivationClients.ingest(tenantSlug, datalakeSlug, ctx.dacSlug, { data: noPhoneRow }),
339
- ])
340
- ctx.batchWithPhone = withPhoneIngest.data.batch_id!
341
- ctx.batchNoPhone = noPhoneIngest.data.batch_id!
342
- ```
343
-
344
- ## 009 — wait for both ingest batches to reach steady-state
345
-
346
- Ingestion is async — the DAC enqueues per-row jobs that the
347
- `BatchMergeWorker` drains into the regulated customer table. Poll
348
- the DAC's activation logs until both batches show a row with
349
- `rows_ingested >= 1` and a non-empty `output_files` array (the
350
- merged Parquet landed in object storage). Only then is it safe to
351
- run the workflow against these rows.
352
-
353
- ```typescript
354
- const targetBatches = new Set([ctx.batchWithPhone, ctx.batchNoPhone])
355
- const deadline = Date.now() + 90_000
356
- let greenCount = 0
357
- while (Date.now() < deadline) {
358
- const { data } = await api.dataActivationClients.logs.list(tenantSlug, datalakeSlug, ctx.dacSlug)
359
- const green = new Set<string>()
360
- for (const row of (data.data ?? []) as Array<Record<string, unknown>>) {
361
- const b = row.batch_id
362
- if (typeof b !== 'string' || !targetBatches.has(b)) continue
363
- if (typeof row.rows_ingested !== 'number' || row.rows_ingested < 1) continue
364
- const files = row.output_files
365
- if (!Array.isArray(files) || files.length === 0) continue
366
- green.add(b)
367
- }
368
- greenCount = green.size
369
- if (greenCount === targetBatches.size) break
370
- await new Promise((r) => setTimeout(r, 1_000))
371
- }
372
- if (greenCount !== targetBatches.size) {
373
- throw new Error(`only ${greenCount}/2 customer batches reached steady-state within 90s`)
374
- }
375
- ```
376
-
377
- ## 010 — run the workflow against the two batches
378
-
379
- `workflows.run` with `manual_override: false` evaluates the
380
- filter, so the `customer.phone` filter genuinely routes each row.
381
- The SQL where-clause scopes the run to exactly the two batches
382
- §008 ingested (`ra` is the regulated-customer alias the run-query
383
- exposes).
384
-
385
- The SMS action's `trigger_template: 'now'` dispatches the action
386
- immediately rather than deferring it, so once the run fires it
387
- reaches a terminal status on its own — poll `batchLogs.refresh`
388
- until it leaves `:pending`. The run itself is still scheduled:
389
- `workflows.run` records it and returns, which is why the setup
390
- file's `ctx.waitForFiredRun` sits between the call and the log id. A `:partial` status is expected and fine here:
391
- one row passed and one was filtered.
392
-
393
- ```typescript
394
- const runResp = await api.workflows.run(tenantSlug, datalakeSlug, ctx.workflowSlug, {
395
- sql_where_clause: `ra.batch_id IN ('${ctx.batchWithPhone}', '${ctx.batchNoPhone}')`,
396
- mode: 'live',
397
- manual_override: false,
398
- })
399
- // run-workflow only SCHEDULES the run. The log id and batch id are
400
- // written when it fires, so read them back via workflowRuns.get.
401
- const fired = await ctx.waitForFiredRun(datalakeSlug, runResp.data.workflow_run_id)
402
- ctx.runLogId = fired.workflowRunLogId
403
- ctx.runBatchId = fired.batchId!
404
-
405
- const deadline = Date.now() + 120_000
406
- let status: string | null = null
407
- while (Date.now() < deadline) {
408
- const { data: log } = await api.workflows.batchLogs.refresh(tenantSlug, datalakeSlug, ctx.workflowSlug, ctx.runLogId)
409
- status = log.status ?? null
410
- if (status && status !== 'pending') break
411
- await new Promise((r) => setTimeout(r, 2_000))
412
- }
413
- if (status === 'failed') throw new Error('workflow run reached :failed')
414
- if (!status || status === 'pending') {
415
- throw new Error('workflow run did not leave :pending within 120s')
416
- }
417
- ```
418
-
419
- ## 011 — verify the filter routed: with-phone passes, no-phone is filtered
420
-
421
- Each row produced a Workflow Execution Log. The customer with a
422
- phone passes the filter, so its WEL is `:executing` or
423
- `:completed`. The customer with no phone fails the filter, so its
424
- WEL is `:filtered`. A run where both passed — or both were
425
- filtered — would mean the filter is not actually evaluating
426
- `customer.phone`.
427
-
428
- ```typescript
429
- const { data: wfLogs } = await api.workflows.workflowLogs.list(tenantSlug, datalakeSlug, ctx.workflowSlug)
430
- const ourWels = (wfLogs.data ?? []).filter(
431
- (w) => (w as { batch_id?: string }).batch_id === ctx.runBatchId,
432
- )
433
- if (ourWels.length !== 2) {
434
- throw new Error(`expected 2 WELs for the run, got ${ourWels.length}`)
435
- }
436
-
437
- const byStatus: Record<string, number> = {}
438
- for (const w of ourWels) {
439
- const st = (w as { status?: string }).status ?? 'unknown'
440
- byStatus[st] = (byStatus[st] ?? 0) + 1
441
- }
442
- const passCount = (byStatus.executing ?? 0) + (byStatus.completed ?? 0)
443
- if (passCount !== 1) {
444
- throw new Error(`expected 1 pass-branch WEL, got ${passCount} — distribution ${JSON.stringify(byStatus)}`)
445
- }
446
- if ((byStatus.filtered ?? 0) !== 1) {
447
- throw new Error(
448
- `expected 1 :filtered WEL (the no-phone customer) — distribution ${JSON.stringify(byStatus)}`,
449
- )
450
- }
451
- ```
452
-
453
- ## 012 — read the rendered welcome SMS back from the message dataset
454
-
455
- A `trigger_template: 'now'` action that passed the filter fires
456
- immediately and persists a row in the regulated `message`
457
- dataset, carrying the fully-rendered SMS body. Search that
458
- dataset scoped to this workflow, poll until the row appears, and
459
- extract the `/t/<token>` shortlink the platform baked into the
460
- body when it minted the connected-app page token.
461
-
462
- ```typescript
463
- const { data: userSearch } = await api.datasets.createUserSearch(tenantSlug, datalakeSlug, 'message', {
464
- search_query: `rm.workflow_id = '${workflowId}'`,
465
- })
466
- if (userSearch.status !== 'completed') {
467
- throw new Error(`message user-search status=${userSearch.status} error=${userSearch.error_message ?? '(none)'}`)
468
- }
469
-
470
- const deadline = Date.now() + 45_000
471
- let messages: Array<Record<string, unknown>> = []
472
- while (Date.now() < deadline && messages.length === 0) {
473
- const { data } = await api.datasets.search(tenantSlug, datalakeSlug, 'message', {
474
- userSearchId: userSearch.id!,
475
- dataAccessMode: 'regulated',
476
- })
477
- messages = (data.data ?? []) as Array<Record<string, unknown>>
478
- if (messages.length === 0) await new Promise((r) => setTimeout(r, 1_000))
479
- }
480
- if (messages.length === 0) {
481
- throw new Error('no welcome SMS message persisted for the workflow within 45s')
482
- }
483
-
484
- const withLink = messages
485
- .map((m) => String(m.body ?? ''))
486
- .find((body) => body.includes('/t/') && body.includes('welcome to Alvera Billing'))
487
- if (!withLink) {
488
- throw new Error(`no rendered welcome body with a /t/ link — bodies: ${JSON.stringify(messages.map((m) => m.body))}`)
489
- }
490
- const tokenMatch = withLink.match(/\/t\/([A-Za-z0-9_-]+)/)
491
- if (!tokenMatch) {
492
- throw new Error(`no /t/<token> in rendered body: ${withLink}`)
493
- }
494
- ctx.welcomeShortPath = tokenMatch[1]!
495
- ```
496
-
497
- ## 013 — resolve the connected-app deep-link and post tracking
498
-
499
- The `/t/<token>` shortlink the SMS carries resolves via
500
- `connectedApps.resolvePage` — the `route_path` must match the
501
- action's `connected_app_route`. Posting `opened_at` +
502
- `form_submitted_at` via `updateMessageTracking` then mirrors what
503
- the connected-app frontend does when the customer opens the
504
- billing portal, closing the SMS → reply loop end-to-end.
505
-
506
- ```typescript
507
- const { data: resolved } = await api.connectedApps.resolvePage(tenantSlug, datalakeSlug, ctx.connectedAppSlug, {
508
- short_path: ctx.welcomeShortPath,
509
- user_agent: 'cookbook-doctest/welcome-sms',
510
- })
511
- if (resolved.route_path !== '/portal/welcome') {
512
- throw new Error(`resolvePage route_path mismatch: ${resolved.route_path}`)
513
- }
514
- if (!(resolved.message?.body ?? '').includes('welcome to Alvera Billing')) {
515
- throw new Error('resolved page message body missing the welcome copy')
516
- }
517
-
518
- const now = new Date().toISOString()
519
- const { data: tracked } = await api.connectedApps.updateMessageTracking(tenantSlug, datalakeSlug, ctx.connectedAppSlug, {
520
- short_path: ctx.welcomeShortPath,
521
- opened_at: now,
522
- form_submitted_at: now,
523
- })
524
- if (!tracked.message?.opened_at || !tracked.message?.form_submitted_at) {
525
- throw new Error('message tracking did not persist opened_at + form_submitted_at')
526
- }
527
- ```
528
-
529
- ## 014 — write the integration test
530
-
531
- End the build with a test you keep: re-read the workflow and prove the
532
- pipeline still executes — without a side effect. `mode: 'dry_run'` with a
533
- never-matching selection runs the FULL pipeline (selection → filter →
534
- decision) and intercepts only the final action call, so no message
535
- leaves, yet the acknowledgement proves the workflow is runnable. This
536
- block runs live under `make validate-cookbook`.
537
-
538
- ```typescript
539
- // Re-GET — the workflow must still be live, or nothing will run.
540
- const { data: wfRow } = await api.workflows.get(tenantSlug, datalakeSlug, workflowId)
541
- if (wfRow.status !== 'live') {
542
- throw new Error(`workflow regressed from live: ${wfRow.status}`)
543
- }
544
- // Behavioural probe — a dry run against a selection no row can match:
545
- // the pipeline executes end-to-end, the final action call is
546
- // intercepted, and the acknowledgement carries the scheduled run id.
547
- const { data: probeRun } = await api.workflows.run(tenantSlug, datalakeSlug, ctx.workflowSlug, {
548
- sql_where_clause: "ra.batch_id = 'test-never-matching-batch'",
549
- mode: 'dry_run',
550
- manual_override: false,
551
- })
552
- // The run-log id does not exist until the run fires — wait, do not read a null.
553
- const probeFired = await ctx.waitForFiredRun(datalakeSlug, probeRun.workflow_run_id)
554
- if (probeFired.workflowRunLogId.length === 0) {
555
- throw new Error('dry-run probe never produced a workflow_run_log_id')
556
- }
557
- ```
558
-
559
- If the probe fails in production, escalate with the run response as
560
- evidence — don't flip the workflow's status or rewrite its configs to
561
- chase the error.
562
-
563
- # Branches
564
-
565
- - **The no-phone customer is filtered, not failed** — §011
566
- asserts the no-phone customer's WEL is `:filtered`, a distinct
567
- terminal status from `:failed`. A filtered row is a *correct*
568
- outcome: the workflow looked at it, the `customer.phone` filter
569
- rendered empty, and the platform recorded that the row was
570
- intentionally skipped. No SMS action runs for a filtered row —
571
- which is exactly the business rule (never try to SMS a customer
572
- with no number on file).
573
- - **The reject-everything filter** — the anchor vitest's §8
574
- PUT-updates the workflow with a filter gated on a sentinel
575
- phone value no customer can carry, then re-runs the same batch
576
- — every WEL records `:filtered`. That proves the filter is
577
- load-bearing in the opposite direction: a workflow whose filter
578
- never passes sends zero SMS. This cookbook walks the
579
- one-pass-one-filtered split instead; the all-filtered branch is
580
- the anchor test's own coverage.
581
- - **The six-month recency guard** — §003's `context_datasets`
582
- entry queries the `message` dataset for a prior welcome SMS to
583
- the same customer. On this fresh tenant it returns empty, so
584
- the action runs. On a tenant with history, a recent prior
585
- message would make the action skip — the platform's built-in
586
- anti-duplication guard, expressed as a context query rather
587
- than application code.
588
-
589
- # Rollback
590
-
591
- The cookbook doctest harness does not currently tear down created
592
- resources. The `_setup/subscription.md` setup file's
593
- runSuffix-scoped tenant / datalake / user names mean each run is
594
- naturally isolated; the seeded local DB is cheap to reset
595
- (`mix ecto.reset` on the platform repo).
596
-
597
- # Outcome
598
-
599
- After this cookbook's thirteen steps run green:
600
-
601
- - An subscription tenant exists with an
602
- subscription-domain datalake
603
- - An SMS tool, a Welcome billing-portal connected app, and a
604
- Welcome SMS standard workflow (status `live`, `customer`
605
- dataset) are registered
606
- - A Stripe data source, a Manual Upload tool, a Stripe Customer
607
- interop contract, and a manual-upload DAC form a working
608
- ingestion chain
609
- - Two customers have been ingested through that chain — one with
610
- a phone on file, one without
611
- - Running the workflow routed them correctly: the with-phone
612
- customer passed the filter and dispatched an SMS; the no-phone
613
- customer was `:filtered`
614
- - The rendered welcome SMS is readable from the `message`
615
- dataset, carrying the "welcome to Alvera Billing" copy and a
616
- `/t/` deep-link
617
- - The connected-app deep-link the SMS carried resolves, and
618
- message tracking records the open + form-submit timestamps
619
-
620
- The business outcome — a welcome SMS sent to a reachable
621
- customer, with a working billing-portal link — is demonstrated
622
- end-to-end, not merely provisioned.
623
-
624
- # See also
625
-
626
- - `_setup/subscription.md` — the inlined bootstrap that
627
- provisions the tenant + datalake this cookbook starts from
628
- - `dunning-sms-for-delinquent.md` — the sibling AR cookbook; same
629
- customer-dataset shape, a stricter phone + tax-id filter
630
- - `.agent/tools.md` — SMS (`tool_body_type: sns`) and
631
- manual-upload tool body shapes; intent classification
632
- - `.agent/connected_apps.md` — connected-app registration + the
633
- resolve-page / message-tracking reply loop
634
- - `.agent/interoperability_contracts.md` — custom contract shape,
635
- `template_config` vs `mdm_input_config`
636
- - `.agent/data_activation_clients.md` — data source → tool →
637
- interop contract → DAC ingestion chain
638
- - `.agent/workflows.md` — standard workflow primitive (filter +
639
- decision + actions), `context_datasets`, `workflows.run`
640
- - `.agent/cookbook/_fixtures/subscription/` — the vendored
641
- Stripe customer / MDM Liquid templates §006 loads
642
- - `integration-tests/tests/subscription/standard-workflow.test.ts` —
643
- the anchor green test (§1–§8) these snippets are lifted from
644
- - `integration-tests/tests/subscription/interoperability-contracts.test.ts`,
645
- `run-dac-single.test.ts`, `create-dac.test.ts`, `tools.test.ts`,
646
- `data-sources.test.ts` — the per-resource create + ingest
647
- snippets §004–§009 are lifted from