@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,607 @@
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: accounts_receivable
5
+ slug: welcome-sms-for-customers
6
+ vitest_source:
7
+ - integration-tests/tests/accounts_receivable/standard-workflow.test.ts
8
+ - integration-tests/tests/accounts_receivable/interoperability-contracts.test.ts
9
+ - integration-tests/tests/accounts_receivable/run-dac-single.test.ts
10
+ - integration-tests/tests/accounts_receivable/create-dac.test.ts
11
+ - integration-tests/tests/accounts_receivable/tools.test.ts
12
+ - integration-tests/tests/accounts_receivable/data-sources.test.ts
13
+ - integration-tests/tests/accounts_receivable/bootstrap.test.ts
14
+ status: green
15
+ ---
16
+
17
+ # Problem
18
+
19
+ 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/accounts_receivable/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/accounts_receivable.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/accounts_receivable.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
+ filter_config: {
159
+ type: 'custom',
160
+ body: FILTER_BODY,
161
+ output_schema: '{"type":"boolean"}',
162
+ },
163
+ decision_config: {
164
+ type: 'custom',
165
+ body: DECISION_BODY,
166
+ output_schema: DECISION_OUTPUT_SCHEMA,
167
+ },
168
+ context_datasets: [
169
+ {
170
+ dataset_type: 'message',
171
+ where_clause:
172
+ `rm.customer_id = '{{ customer_id }}' AND rm.decision_key = '${DECISION_KEY}' ` +
173
+ "AND rm.sent_at > NOW() - INTERVAL '6 months'",
174
+ limit: 1,
175
+ position: 0,
176
+ },
177
+ ],
178
+ actions: [
179
+ {
180
+ action_type: 'sms',
181
+ tool_id: toolId,
182
+ decision_key: DECISION_KEY,
183
+ position: 0,
184
+ trigger_template: 'now',
185
+ idempotency_template: '{{ customer_id }}-{{ decision_key }}-{{ "" | uuid }}',
186
+ connected_app_id: connectedAppId,
187
+ connected_app_route: '/portal/welcome',
188
+ connected_app_metadata_template: '{"customer_id":"{{ mdm_output.customer.id }}"}',
189
+ tool_call: {
190
+ tool_call_type: 'sms_request',
191
+ to: { type: 'custom', body: SMS_TO_TEMPLATE },
192
+ body: { type: 'custom', body: SMS_BODY_TEMPLATE },
193
+ sms_type: 'transactional',
194
+ },
195
+ },
196
+ ],
197
+ })
198
+ workflowId = workflowResp.data.id!
199
+ ctx.workflowSlug = workflowResp.data.slug!
200
+ ```
201
+
202
+ ## 004 — create the Stripe data source
203
+
204
+ A workflow runs on rows; rows arrive through the data-activation
205
+ chain. The chain's first link is a `DataSource` — a registration
206
+ of where the rows originate. The `uri` is the system-of-record
207
+ address; it flows into each ingested row's `source_uri`, which
208
+ the customer template renders onto the MDM identifier `system`
209
+ URN.
210
+
211
+ ```typescript
212
+ const dataSourceResp = await api.dataSources.create(tenantSlug, datalakeSlug, {
213
+ name: `Cookbook Stripe Source ${runSuffix}`,
214
+ uri: 'stripe.example.com',
215
+ description: 'Stripe billing system — origin of the customer rows the welcome workflow runs on.',
216
+ status: 'active',
217
+ is_default: false,
218
+ })
219
+ dataSourceId = dataSourceResp.data.id!
220
+ ```
221
+
222
+ ## 005 — create the Manual Upload tool
223
+
224
+ The Data Activation Client needs a tool. For inline-JSON ingest a
225
+ `manual_upload` tool is the minimal choice — `intent:
226
+ 'data_exchange'` distinguishes it from the SMS tool, and
227
+ `tool_body_type: 'manual_upload'` needs no endpoint or credential
228
+ wiring (the rows arrive in the ingest call body, not by the tool
229
+ fetching them).
230
+
231
+ ```typescript
232
+ const manualUploadToolResp = await api.tools.create(tenantSlug, datalakeSlug, {
233
+ name: `Cookbook Manual Upload Tool ${runSuffix}`,
234
+ description: 'Manual-upload data-exchange tool — backs the DAC that ingests Stripe customer rows.',
235
+ intent: 'data_exchange',
236
+ status: 'active',
237
+ datalake_id: ctx.datalakeId,
238
+ data_source_id: dataSourceId,
239
+ body: { tool_body_type: 'manual_upload' },
240
+ })
241
+ ctx.manualUploadToolId = manualUploadToolResp.data.id!
242
+ ```
243
+
244
+ ## 006 — create the Stripe Customer interoperability contract
245
+
246
+ The interoperability contract is the row-shaping rule: a Liquid
247
+ template that maps an inbound Stripe customer row into an AR
248
+ `Customer` upsert. This cookbook loads the production Stripe
249
+ customer + MDM templates from the vendored fixtures directory
250
+ rather than inlining the Liquid.
251
+
252
+ Two template slots matter. `template_config` shapes the Customer
253
+ resource — including the nested `regulated_customer` object that
254
+ carries the PII fields (`name`, `email`, `phone`, `tax_id`).
255
+ `mdm_input_config` shapes the MDM input — the identifier
256
+ (`customer_number`), name, and contact fields the platform's
257
+ master-data resolution keys on to decide whether this is a new
258
+ customer or an existing one.
259
+
260
+ ```typescript
261
+ const { readFileSync } = await import('node:fs')
262
+ const { join } = await import('node:path')
263
+ const customerTemplate = readFileSync(
264
+ join(process.env.COOKBOOK_FIXTURES_DIR!, 'accounts_receivable/_customers_accounts_receivable_customer.liquid'),
265
+ 'utf8',
266
+ )
267
+ const mdmTemplate = readFileSync(
268
+ join(process.env.COOKBOOK_FIXTURES_DIR!, 'accounts_receivable/_customers_accounts_receivable_mdm.liquid'),
269
+ 'utf8',
270
+ )
271
+
272
+ const contractResp = await api.interoperabilityContracts.create(tenantSlug, datalakeSlug, {
273
+ name: `Cookbook Stripe Customer Contract ${runSuffix}`,
274
+ description: 'Stripe customers → AccountsReceivable Customer (custom Liquid + MDM input).',
275
+ resource_type: 'customer',
276
+ template_config: { type: 'custom', body: customerTemplate },
277
+ mdm_input_config: { type: 'custom', body: mdmTemplate },
278
+ generic_table_id: null,
279
+ })
280
+ interopContractId = contractResp.data.id!
281
+ ```
282
+
283
+ ## 007 — create the manual-upload DAC
284
+
285
+ The Data Activation Client binds the three preceding pieces — the
286
+ manual-upload tool, the data source, and the interop contract —
287
+ into one ingestion endpoint. `tool_call.tool_call_type:
288
+ 'manual_upload'` selects the inline-JSON ingest path. The
289
+ server-derived `slug` is the handle §008 ingests rows against.
290
+
291
+ ```typescript
292
+ const dacResp = await api.dataActivationClients.create(tenantSlug, datalakeSlug, {
293
+ name: `Cookbook Stripe Customer DAC ${runSuffix}`,
294
+ description: 'Manual-upload DAC — ingests Stripe customer rows into Customer via the interop contract.',
295
+ tool_id: ctx.manualUploadToolId,
296
+ data_source_id: dataSourceId,
297
+ tool_call: { tool_call_type: 'manual_upload' },
298
+ interoperability_contract_ids: [interopContractId],
299
+ })
300
+ dacId = dacResp.data.id!
301
+ ctx.dacSlug = dacResp.data.slug!
302
+ ```
303
+
304
+ ## 008 — ingest two Stripe customer rows
305
+
306
+ Two rows, ingested as inline JSON through the manual-upload DAC.
307
+ They differ in one field the workflow filter cares about:
308
+ `phone`. The first row carries a phone number — the filter will
309
+ pass it. The second omits `phone` entirely — the filter must
310
+ reject it. Each ingest call gets its own batch id; both are
311
+ pinned for the run scope in §010.
312
+
313
+ ```typescript
314
+ const phone4 = String(Date.now()).slice(-4)
315
+
316
+ const withPhoneRow = {
317
+ customer_number: `CUST-WP-${runSuffix}`,
318
+ customer_type: 'individual',
319
+ status: 'contracted',
320
+ name: 'Olivia Hartmann',
321
+ email: `olivia-${runSuffix}@example.com`,
322
+ phone: `+1202555${phone4}`,
323
+ currency: 'USD',
324
+ }
325
+ const noPhoneRow = {
326
+ customer_number: `CUST-NP-${runSuffix}`,
327
+ customer_type: 'individual',
328
+ status: 'contracted',
329
+ name: 'Noah Pemberton',
330
+ email: `noah-${runSuffix}@example.com`,
331
+ currency: 'USD',
332
+ // phone intentionally absent — the workflow filter must reject this row
333
+ }
334
+
335
+ const [withPhoneIngest, noPhoneIngest] = await Promise.all([
336
+ api.dataActivationClients.ingest(tenantSlug, datalakeSlug, ctx.dacSlug, { data: withPhoneRow }),
337
+ api.dataActivationClients.ingest(tenantSlug, datalakeSlug, ctx.dacSlug, { data: noPhoneRow }),
338
+ ])
339
+ ctx.batchWithPhone = withPhoneIngest.data.batch_id!
340
+ ctx.batchNoPhone = noPhoneIngest.data.batch_id!
341
+ ```
342
+
343
+ ## 009 — wait for both ingest batches to reach steady-state
344
+
345
+ Ingestion is async — the DAC enqueues per-row jobs that the
346
+ `BatchMergeWorker` drains into the regulated customer table. Poll
347
+ the DAC's activation logs until both batches show a row with
348
+ `rows_ingested >= 1` and a non-empty `output_files` array (the
349
+ merged Parquet landed in object storage). Only then is it safe to
350
+ run the workflow against these rows.
351
+
352
+ ```typescript
353
+ const targetBatches = new Set([ctx.batchWithPhone, ctx.batchNoPhone])
354
+ const deadline = Date.now() + 90_000
355
+ let greenCount = 0
356
+ while (Date.now() < deadline) {
357
+ const { data } = await api.dataActivationClients.logs.list(tenantSlug, datalakeSlug, ctx.dacSlug)
358
+ const green = new Set<string>()
359
+ for (const row of (data.data ?? []) as Array<Record<string, unknown>>) {
360
+ const b = row.batch_id
361
+ if (typeof b !== 'string' || !targetBatches.has(b)) continue
362
+ if (typeof row.rows_ingested !== 'number' || row.rows_ingested < 1) continue
363
+ const files = row.output_files
364
+ if (!Array.isArray(files) || files.length === 0) continue
365
+ green.add(b)
366
+ }
367
+ greenCount = green.size
368
+ if (greenCount === targetBatches.size) break
369
+ await new Promise((r) => setTimeout(r, 1_000))
370
+ }
371
+ if (greenCount !== targetBatches.size) {
372
+ throw new Error(`only ${greenCount}/2 customer batches reached steady-state within 90s`)
373
+ }
374
+ ```
375
+
376
+ ## 010 — run the workflow against the two batches
377
+
378
+ `workflows.run` with `manual_override: false` evaluates the
379
+ filter, so the `customer.phone` filter genuinely routes each row.
380
+ The SQL where-clause scopes the run to exactly the two batches
381
+ §008 ingested (`ra` is the regulated-customer alias the run-query
382
+ exposes).
383
+
384
+ The SMS action's `trigger_template: 'now'` dispatches the action
385
+ immediately rather than scheduling it, so the run reaches a
386
+ terminal status on its own — poll `batchLogs.refresh` until it
387
+ leaves `:pending`. A `:partial` status is expected and fine here:
388
+ one row passed and one was filtered.
389
+
390
+ ```typescript
391
+ const runResp = await api.workflows.run(tenantSlug, datalakeSlug, ctx.workflowSlug, {
392
+ sql_where_clause: `ra.batch_id IN ('${ctx.batchWithPhone}', '${ctx.batchNoPhone}')`,
393
+ mode: 'live',
394
+ manual_override: false,
395
+ })
396
+ ctx.runLogId = runResp.data.workflow_run_log_id!
397
+ ctx.runBatchId = runResp.data.batch_id!
398
+
399
+ const deadline = Date.now() + 120_000
400
+ let status: string | null = null
401
+ while (Date.now() < deadline) {
402
+ const { data: log } = await api.workflows.batchLogs.refresh(tenantSlug, datalakeSlug, ctx.workflowSlug, ctx.runLogId)
403
+ status = log.status ?? null
404
+ if (status && status !== 'pending') break
405
+ await new Promise((r) => setTimeout(r, 2_000))
406
+ }
407
+ if (status === 'failed') throw new Error('workflow run reached :failed')
408
+ if (!status || status === 'pending') {
409
+ throw new Error('workflow run did not leave :pending within 120s')
410
+ }
411
+ ```
412
+
413
+ ## 011 — verify the filter routed: with-phone passes, no-phone is filtered
414
+
415
+ Each row produced a Workflow Execution Log. The customer with a
416
+ phone passes the filter, so its WEL is `:executing` or
417
+ `:completed`. The customer with no phone fails the filter, so its
418
+ WEL is `:filtered`. A run where both passed — or both were
419
+ filtered — would mean the filter is not actually evaluating
420
+ `customer.phone`.
421
+
422
+ ```typescript
423
+ const { data: wfLogs } = await api.workflows.workflowLogs.list(tenantSlug, datalakeSlug, ctx.workflowSlug)
424
+ const ourWels = (wfLogs.data ?? []).filter(
425
+ (w) => (w as { batch_id?: string }).batch_id === ctx.runBatchId,
426
+ )
427
+ if (ourWels.length !== 2) {
428
+ throw new Error(`expected 2 WELs for the run, got ${ourWels.length}`)
429
+ }
430
+
431
+ const byStatus: Record<string, number> = {}
432
+ for (const w of ourWels) {
433
+ const st = (w as { status?: string }).status ?? 'unknown'
434
+ byStatus[st] = (byStatus[st] ?? 0) + 1
435
+ }
436
+ const passCount = (byStatus.executing ?? 0) + (byStatus.completed ?? 0)
437
+ if (passCount !== 1) {
438
+ throw new Error(`expected 1 pass-branch WEL, got ${passCount} — distribution ${JSON.stringify(byStatus)}`)
439
+ }
440
+ if ((byStatus.filtered ?? 0) !== 1) {
441
+ throw new Error(
442
+ `expected 1 :filtered WEL (the no-phone customer) — distribution ${JSON.stringify(byStatus)}`,
443
+ )
444
+ }
445
+ ```
446
+
447
+ ## 012 — read the rendered welcome SMS back from the message dataset
448
+
449
+ A `trigger_template: 'now'` action that passed the filter fires
450
+ immediately and persists a row in the regulated `message`
451
+ dataset, carrying the fully-rendered SMS body. Search that
452
+ dataset scoped to this workflow, poll until the row appears, and
453
+ extract the `/t/<token>` shortlink the platform baked into the
454
+ body when it minted the connected-app page token.
455
+
456
+ ```typescript
457
+ const { data: userSearch } = await api.datasets.createUserSearch(tenantSlug, datalakeSlug, 'message', {
458
+ search_query: `rm.workflow_id = '${workflowId}'`,
459
+ })
460
+ if (userSearch.status !== 'completed') {
461
+ throw new Error(`message user-search status=${userSearch.status} error=${userSearch.error_message ?? '(none)'}`)
462
+ }
463
+
464
+ const deadline = Date.now() + 45_000
465
+ let messages: Array<Record<string, unknown>> = []
466
+ while (Date.now() < deadline && messages.length === 0) {
467
+ const { data } = await api.datasets.search(tenantSlug, datalakeSlug, 'message', {
468
+ userSearchId: userSearch.id!,
469
+ dataAccessMode: 'regulated',
470
+ })
471
+ messages = (data.data ?? []) as Array<Record<string, unknown>>
472
+ if (messages.length === 0) await new Promise((r) => setTimeout(r, 1_000))
473
+ }
474
+ if (messages.length === 0) {
475
+ throw new Error('no welcome SMS message persisted for the workflow within 45s')
476
+ }
477
+
478
+ const withLink = messages
479
+ .map((m) => String(m.body ?? ''))
480
+ .find((body) => body.includes('/t/') && body.includes('welcome to Alvera Billing'))
481
+ if (!withLink) {
482
+ throw new Error(`no rendered welcome body with a /t/ link — bodies: ${JSON.stringify(messages.map((m) => m.body))}`)
483
+ }
484
+ const tokenMatch = withLink.match(/\/t\/([A-Za-z0-9_-]+)/)
485
+ if (!tokenMatch) {
486
+ throw new Error(`no /t/<token> in rendered body: ${withLink}`)
487
+ }
488
+ ctx.welcomeShortPath = tokenMatch[1]!
489
+ ```
490
+
491
+ ## 013 — resolve the connected-app deep-link and post tracking
492
+
493
+ The `/t/<token>` shortlink the SMS carries resolves via
494
+ `connectedApps.resolvePage` — the `route_path` must match the
495
+ action's `connected_app_route`. Posting `opened_at` +
496
+ `form_submitted_at` via `updateMessageTracking` then mirrors what
497
+ the connected-app frontend does when the customer opens the
498
+ billing portal, closing the SMS → reply loop end-to-end.
499
+
500
+ ```typescript
501
+ const { data: resolved } = await api.connectedApps.resolvePage(tenantSlug, datalakeSlug, ctx.connectedAppSlug, {
502
+ short_path: ctx.welcomeShortPath,
503
+ user_agent: 'cookbook-doctest/welcome-sms',
504
+ })
505
+ if (resolved.route_path !== '/portal/welcome') {
506
+ throw new Error(`resolvePage route_path mismatch: ${resolved.route_path}`)
507
+ }
508
+ if (!(resolved.message?.body ?? '').includes('welcome to Alvera Billing')) {
509
+ throw new Error('resolved page message body missing the welcome copy')
510
+ }
511
+
512
+ const now = new Date().toISOString()
513
+ const { data: tracked } = await api.connectedApps.updateMessageTracking(tenantSlug, datalakeSlug, ctx.connectedAppSlug, {
514
+ short_path: ctx.welcomeShortPath,
515
+ opened_at: now,
516
+ form_submitted_at: now,
517
+ })
518
+ if (!tracked.message?.opened_at || !tracked.message?.form_submitted_at) {
519
+ throw new Error('message tracking did not persist opened_at + form_submitted_at')
520
+ }
521
+ ```
522
+
523
+ # Branches
524
+
525
+ - **The no-phone customer is filtered, not failed** — §011
526
+ asserts the no-phone customer's WEL is `:filtered`, a distinct
527
+ terminal status from `:failed`. A filtered row is a *correct*
528
+ outcome: the workflow looked at it, the `customer.phone` filter
529
+ rendered empty, and the platform recorded that the row was
530
+ intentionally skipped. No SMS action runs for a filtered row —
531
+ which is exactly the business rule (never try to SMS a customer
532
+ with no number on file).
533
+ - **The reject-everything filter** — the anchor vitest's §8
534
+ PUT-updates the workflow with a filter gated on a sentinel
535
+ phone value no customer can carry, then re-runs the same batch
536
+ — every WEL records `:filtered`. That proves the filter is
537
+ load-bearing in the opposite direction: a workflow whose filter
538
+ never passes sends zero SMS. This cookbook walks the
539
+ one-pass-one-filtered split instead; the all-filtered branch is
540
+ the anchor test's own coverage.
541
+ - **The six-month recency guard** — §003's `context_datasets`
542
+ entry queries the `message` dataset for a prior welcome SMS to
543
+ the same customer. On this fresh tenant it returns empty, so
544
+ the action runs. On a tenant with history, a recent prior
545
+ message would make the action skip — the platform's built-in
546
+ anti-duplication guard, expressed as a context query rather
547
+ than application code.
548
+
549
+ # Rollback
550
+
551
+ The cookbook doctest harness does not currently tear down created
552
+ resources. The `_setup/accounts_receivable.md` setup file's
553
+ runSuffix-scoped tenant / datalake / user names mean each run is
554
+ naturally isolated; the seeded local DB is cheap to reset
555
+ (`mix ecto.reset` on the platform repo).
556
+
557
+ # Outcome
558
+
559
+ After this cookbook's thirteen steps run green:
560
+
561
+ - An accounts-receivable tenant exists with an
562
+ accounts-receivable-domain datalake
563
+ - An SMS tool, a Welcome billing-portal connected app, and a
564
+ Welcome SMS standard workflow (status `live`, `customer`
565
+ dataset) are registered
566
+ - A Stripe data source, a Manual Upload tool, a Stripe Customer
567
+ interop contract, and a manual-upload DAC form a working
568
+ ingestion chain
569
+ - Two customers have been ingested through that chain — one with
570
+ a phone on file, one without
571
+ - Running the workflow routed them correctly: the with-phone
572
+ customer passed the filter and dispatched an SMS; the no-phone
573
+ customer was `:filtered`
574
+ - The rendered welcome SMS is readable from the `message`
575
+ dataset, carrying the "welcome to Alvera Billing" copy and a
576
+ `/t/` deep-link
577
+ - The connected-app deep-link the SMS carried resolves, and
578
+ message tracking records the open + form-submit timestamps
579
+
580
+ The business outcome — a welcome SMS sent to a reachable
581
+ customer, with a working billing-portal link — is demonstrated
582
+ end-to-end, not merely provisioned.
583
+
584
+ # See also
585
+
586
+ - `_setup/accounts_receivable.md` — the inlined bootstrap that
587
+ provisions the tenant + datalake this cookbook starts from
588
+ - `dunning-sms-for-delinquent.md` — the sibling AR cookbook; same
589
+ customer-dataset shape, a stricter phone + tax-id filter
590
+ - `.agent/tools.md` — SMS (`tool_body_type: sns`) and
591
+ manual-upload tool body shapes; intent classification
592
+ - `.agent/connected_apps.md` — connected-app registration + the
593
+ resolve-page / message-tracking reply loop
594
+ - `.agent/interoperability_contracts.md` — custom contract shape,
595
+ `template_config` vs `mdm_input_config`
596
+ - `.agent/data_activation_clients.md` — data source → tool →
597
+ interop contract → DAC ingestion chain
598
+ - `.agent/workflows.md` — standard workflow primitive (filter +
599
+ decision + actions), `context_datasets`, `workflows.run`
600
+ - `.agent/cookbook/_fixtures/accounts_receivable/` — the vendored
601
+ Stripe customer / MDM Liquid templates §006 loads
602
+ - `integration-tests/tests/accounts_receivable/standard-workflow.test.ts` —
603
+ the anchor green test (§1–§8) these snippets are lifted from
604
+ - `integration-tests/tests/accounts_receivable/interoperability-contracts.test.ts`,
605
+ `run-dac-single.test.ts`, `create-dac.test.ts`, `tools.test.ts`,
606
+ `data-sources.test.ts` — the per-resource create + ingest
607
+ snippets §004–§009 are lifted from