@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,364 +0,0 @@
1
- ---
2
- title: Subscription industry bootstrap setup
3
- summary: Auth as root, sign up + confirm a fresh industry-admin user, create an subscription tenant + datalake. Shared by every subscription scenario cookbook.
4
- industry: subscription
5
- slug: subscription
6
- vitest_source:
7
- - integration-tests/tests/subscription/bootstrap.test.ts
8
- status: green
9
- ---
10
-
11
- # Problem
12
-
13
- Every subscription scenario cookbook needs the same
14
- starting point: a fresh tenant on the platform with an
15
- subscription-domain datalake attached, accessed by an
16
- industry-admin user. Authoring this prelude inline inside every
17
- scenario cookbook would duplicate the auth + tenant + datalake
18
- chain across two cookbooks (and six more across the other
19
- industries). The validator auto-discovers this file from each
20
- cookbook's `industry: subscription` front-matter and
21
- inlines its numbered steps ahead of the scenario's own, so the
22
- generated bun:test spec is hermetic without the cookbook author
23
- having to copy-paste this prelude.
24
-
25
- # Composition
26
-
27
- | Resource provisioned | Owner |
28
- |-------------------------------|-------------|
29
- | Root session | setup |
30
- | Industry-admin user (`sarah`) | setup |
31
- | Subscription tenant | setup |
32
- | Subscription datalake | setup |
33
-
34
- # Walkthrough
35
-
36
- ## 001 — root admin signs in and provisions a fresh industry-admin user
37
-
38
- Authenticate as the platform's root admin (`admin@dev.local` /
39
- `devpassword` in local dev) via the tenantless bootstrap login —
40
- keyless by structural necessity (no tenant exists yet to scope a
41
- key to), and a dev/test-only surface. Then sign up a per-run industry-admin
42
- user (`sarah`) under a unique email derived from `runSuffix`, and
43
- confirm them so they can sign in. Both the signup and the confirm
44
- endpoints require root scope.
45
-
46
- ```typescript
47
- ctx.rootSession = await createBootstrapSession({
48
- baseUrl: process.env.ALVERA_BASE_URL!,
49
- email: process.env.ALVERA_ROOT_EMAIL!,
50
- password: process.env.ALVERA_ROOT_PASSWORD!,
51
- })
52
- ctx.rootApi = createIsolatedPlatformApi({
53
- baseUrl: process.env.ALVERA_BASE_URL!,
54
- sessionToken: ctx.rootSession.sessionToken,
55
- apiKey: '',
56
- })
57
-
58
- ctx.sarahEmail = `cookbook-sarah-${runSuffix}@dev.local`
59
- ctx.sarahPassword = 'CookbookPass1!'
60
-
61
- const signUpResp = await ctx.rootApi.admin.signUp({
62
- email: ctx.sarahEmail,
63
- password: ctx.sarahPassword,
64
- first_name: 'Cookbook',
65
- last_name: 'Sarah',
66
- })
67
- ctx.sarahUserId = signUpResp.data.id
68
- await ctx.rootApi.admin.confirmUser(ctx.sarahUserId)
69
- ```
70
-
71
- ## 002 — sarah signs in (tenantless) and creates the subscription tenant
72
-
73
- Sarah signs in for the first time without a tenant scope (no
74
- tenant exists yet for her), then immediately creates a fresh
75
- subscription tenant. The tenant's server-derived `slug` is
76
- captured into the closure-scoped `tenantSlug` slot for downstream
77
- steps.
78
-
79
- ```typescript
80
- ctx.sarahTenantlessSession = await createBootstrapSession({
81
- baseUrl: process.env.ALVERA_BASE_URL!,
82
- email: ctx.sarahEmail,
83
- password: ctx.sarahPassword,
84
- })
85
- ctx.sarahTenantlessApi = createIsolatedPlatformApi({
86
- baseUrl: process.env.ALVERA_BASE_URL!,
87
- sessionToken: ctx.sarahTenantlessSession.sessionToken,
88
- apiKey: '',
89
- })
90
-
91
- const tenantResp = await ctx.sarahTenantlessApi.tenants.create({
92
- name: `Cookbook Subscription ${runSuffix}`,
93
- })
94
- tenantSlug = tenantResp.data.slug!
95
- ```
96
-
97
- ## 003 — sarah signs in tenant-scoped — the canonical client
98
-
99
- Re-mint sarah's session with the new tenant slug. This bearer is
100
- the canonical tenant-scoped client every subsequent step uses; it
101
- is assigned to the closure-scoped `api` slot so the scenario
102
- cookbook's steps inherit it via inlining.
103
-
104
- ```typescript
105
- // A tenant-scoped login requires X-API-Key — mint a public_api key for
106
- // the fresh tenant via the platform-admin side door (root Bearer). In the
107
- // web console this is Settings -> API Keys; any of the tenant's keys
108
- // satisfies the login gate, and the minted Bearer inherits this key's
109
- // origin policy.
110
- const { data: mintedKey } = await ctx.rootApi.admin.createTenantApiKey(tenantSlug, {
111
- name: `Cookbook Bootstrap Key ${runSuffix}`,
112
- data_access_mode: 'unregulated',
113
- })
114
- ctx.tenantApiKey = mintedKey.api_key
115
-
116
- const sarahTenantSession = await createSession({
117
- baseUrl: process.env.ALVERA_BASE_URL!,
118
- email: ctx.sarahEmail,
119
- password: ctx.sarahPassword,
120
- tenantSlug,
121
- apiKey: ctx.tenantApiKey,
122
- })
123
- api = createIsolatedPlatformApi({
124
- baseUrl: process.env.ALVERA_BASE_URL!,
125
- sessionToken: sarahTenantSession.sessionToken,
126
- apiKey: ctx.tenantApiKey,
127
- })
128
- ```
129
-
130
- ## 004 — sarah creates the subscription datalake
131
-
132
- Provision an subscription-domain datalake on the new
133
- tenant. The DB schemas are scoped to the cookbook run via
134
- `runSuffix` so parallel cookbook runs do not collide on schema
135
- names. Local-dev defaults (`postgres` on `localhost:5432`,
136
- `alvera_dev_subscription`) match the seeded `dev.exs`
137
- setup; LocalStack S3 (`localhost:4566`) serves the unregulated +
138
- regulated buckets. The datalake's server-derived `slug` is
139
- captured into `datalakeSlug`.
140
-
141
- ```typescript
142
- const DB_HOST = 'localhost'
143
- const DB_PORT = 5432
144
- const DB_USER = 'postgres'
145
- const DB_PASS = 'postgres'
146
- const DB_NAME = 'alvera_dev_subscription'
147
- const UNREG_SCHEMA = `cookbook_${runSuffix}_unreg`
148
- const REG_SCHEMA = `cookbook_${runSuffix}_reg`
149
-
150
- const S3 = {
151
- cloud_storage_type: 'aws' as const,
152
- region: 'us-east-1',
153
- access_key_id: 'test',
154
- secret_access_key: 'test',
155
- endpoint: 'http://localhost:4566',
156
- }
157
-
158
- const datalakeResp = await api.datalakes.create(tenantSlug, {
159
- name: `Cookbook Subscription Datalake ${runSuffix}`,
160
- description: 'Subscription datalake provisioned by cookbook doctest.',
161
- data_domain: 'subscription',
162
- timezone: 'America/New_York',
163
- pool_size: 5,
164
-
165
- unregulated_db_writer_host: DB_HOST,
166
- unregulated_db_writer_port: DB_PORT,
167
- unregulated_db_writer_name: DB_NAME,
168
- unregulated_db_writer_schema: UNREG_SCHEMA,
169
- unregulated_db_writer_auth_method: 'password',
170
- unregulated_db_writer_user: DB_USER,
171
- unregulated_db_writer_pass: DB_PASS,
172
- unregulated_db_writer_enable_ssl: false,
173
- unregulated_db_reader_host: DB_HOST,
174
- unregulated_db_reader_port: DB_PORT,
175
- unregulated_db_reader_name: DB_NAME,
176
- unregulated_db_reader_schema: UNREG_SCHEMA,
177
- unregulated_db_reader_auth_method: 'password',
178
- unregulated_db_reader_user: DB_USER,
179
- unregulated_db_reader_pass: DB_PASS,
180
- unregulated_db_reader_enable_ssl: false,
181
-
182
- regulated_data_db_writer_host: DB_HOST,
183
- regulated_data_db_writer_port: DB_PORT,
184
- regulated_data_db_writer_name: DB_NAME,
185
- regulated_data_db_writer_schema: REG_SCHEMA,
186
- regulated_data_db_writer_auth_method: 'password',
187
- regulated_data_db_writer_user: DB_USER,
188
- regulated_data_db_writer_pass: DB_PASS,
189
- regulated_data_db_writer_enable_ssl: false,
190
- regulated_data_db_reader_host: DB_HOST,
191
- regulated_data_db_reader_port: DB_PORT,
192
- regulated_data_db_reader_name: DB_NAME,
193
- regulated_data_db_reader_schema: REG_SCHEMA,
194
- regulated_data_db_reader_auth_method: 'password',
195
- regulated_data_db_reader_user: DB_USER,
196
- regulated_data_db_reader_pass: DB_PASS,
197
- regulated_data_db_reader_enable_ssl: false,
198
-
199
- unregulated_cloud_storage: { ...S3, bucket: 'subscription-lake-unregulated' },
200
- regulated_cloud_storage: { ...S3, bucket: 'subscription-lake-regulated' },
201
- })
202
- datalakeSlug = datalakeResp.data.slug!
203
- // `tools.create` and a few other endpoints accept the datalake by
204
- // UUID in the request body rather than only the slug in the URL,
205
- // so keep it on `ctx` for downstream steps that need it.
206
- ctx.datalakeId = datalakeResp.data.id!
207
- ```
208
-
209
- ## 005 — enqueue the datalake migrations
210
-
211
- `datalakes.create` persists the datalake row in `:new` status but
212
- does not itself run the schema migrations. Migration is a
213
- separately-triggered async job so the operator controls when the
214
- (potentially slow) per-industry DDL runs. `datalakes.migrate`
215
- enqueues the `DatalakeMigrationWorker` Oban job and returns
216
- immediately with `status: 'enqueued'` plus the Oban `job_id`. The
217
- poll in §006 then waits for that worker to finish — without this
218
- call the datalake would sit at `:new` forever.
219
-
220
- ```typescript
221
- const migrateResp = await api.datalakes.migrate(tenantSlug, datalakeSlug)
222
- if (migrateResp.data.status !== 'enqueued') {
223
- throw new Error(
224
- `datalake migration not enqueued (status: ${migrateResp.data.status})`,
225
- )
226
- }
227
- ```
228
-
229
- ## 006 — poll the datalake until status is ready
230
-
231
- Datalake migrations are async — the §005 migrate call enqueued a
232
- `DatalakeMigrationWorker` Oban job that runs the per-industry
233
- schema migrations (the regulated customer + invoice tables and
234
- their join tables), deploys PostgREST roles, and provisions the
235
- service-account bot user. Downstream resource creates (tools,
236
- workflows) only need `datalakeId` to be persisted (which it is
237
- the moment §004 returns), but any scenario step that ingests into
238
- the regulated/unregulated DBs or runs a dataset search requires
239
- `status: 'ready'`. Polling here makes every subscription
240
- scenario cookbook deterministic regardless of how long the cold
241
- migration takes on a given host; the loop exits the moment the
242
- datalake reaches `:ready`, so the 5-minute cap is paid only in
243
- failure mode.
244
-
245
- ```typescript
246
- const READY_TIMEOUT_MS = 5 * 60_000
247
- const READY_POLL_MS = 5_000
248
- const deadline = Date.now() + READY_TIMEOUT_MS
249
- let datalakeStatus: string | undefined
250
- while (Date.now() < deadline) {
251
- const { data } = await api.datalakes.get(tenantSlug, ctx.datalakeId)
252
- datalakeStatus = data.status
253
- if (datalakeStatus === 'ready') break
254
- await new Promise((r) => setTimeout(r, READY_POLL_MS))
255
- }
256
- if (datalakeStatus !== 'ready') {
257
- throw new Error(
258
- `datalake did not reach :ready within ${READY_TIMEOUT_MS}ms (last status: ${datalakeStatus})`,
259
- )
260
- }
261
- ```
262
-
263
- ## 007 — a reusable "wait until the run has fired" helper
264
-
265
- `workflows.run` only **schedules** a run. It records it and returns
266
- immediately with `workflow_run_id` / `status` / `scheduled_at`; the
267
- `workflow_run_log_id` and `batch_id` a scenario needs are written
268
- later, when the run actually fires, and are read back from
269
- `workflowRuns.get`.
270
-
271
- Two traps live in that gap, so every subscription scenario shares one
272
- helper rather than re-deriving it:
273
-
274
- - **Poll until `workflow_run_log_id` is a string — NOT until `status`
275
- leaves `'scheduled'`.** Those are different moments: the run reaches
276
- `processing` first and writes the log id a beat later. A predicate on
277
- status alone releases you to read a `null`, and because
278
- `typeof null === 'object'` the symptom is a baffling *"expected
279
- string, got object"* rather than an obvious nil.
280
- - **Raise on `failed` carrying `failure_reason` rather than polling to
281
- the deadline.** A scenario blocked on a run that will never fire
282
- should say why on the first read, not thirty seconds later behind a
283
- generic timeout.
284
-
285
- The helper is stored on the shared `ctx` bag rather than declared as a
286
- plain function because each numbered step compiles into its own `it()`
287
- block — a bare `function` here would not be in scope for the steps that
288
- call it.
289
-
290
- ```typescript
291
- const FIRED_TIMEOUT_MS = 60_000
292
- const FIRED_POLL_MS = 1_000
293
-
294
- ctx.waitForFiredRun = async (
295
- runDatalakeSlug: string,
296
- runId: string,
297
- timeoutMs: number = FIRED_TIMEOUT_MS,
298
- ): Promise<{ workflowRunLogId: string; batchId: string | null }> => {
299
- const deadline = Date.now() + timeoutMs
300
- let lastStatus: string | undefined
301
- while (Date.now() < deadline) {
302
- const { data } = await api.workflowRuns.get(tenantSlug, runDatalakeSlug, runId)
303
- lastStatus = data.status
304
- if (data.status === 'failed') {
305
- throw new Error(
306
- `workflow run ${runId} failed: ${data.failure_reason ?? 'no failure_reason given'}`,
307
- )
308
- }
309
- if (typeof data.workflow_run_log_id === 'string') {
310
- return { workflowRunLogId: data.workflow_run_log_id, batchId: data.batch_id ?? null }
311
- }
312
- await new Promise((r) => setTimeout(r, FIRED_POLL_MS))
313
- }
314
- throw new Error(
315
- `workflow run ${runId} never fired within ${timeoutMs}ms (last status: ${lastStatus})`,
316
- )
317
- }
318
- ```
319
-
320
- # Rollback
321
-
322
- The cookbook doctest harness does not currently tear down the
323
- created tenant / datalake / user — each run mints fresh names via
324
- `runSuffix` so reruns do not collide, and the seeded local DB is
325
- cheap to reset (`mix ecto.reset` on the platform). When the
326
- validator graduates to continuous integration at Milestone 18
327
- (running against the mockoon-backed mock env at `ALVERA_ENV=mock`),
328
- no real state is created at all.
329
-
330
- # Outcome
331
-
332
- After this setup runs, the closure-scoped slots are populated as:
333
-
334
- - `api` — a tenant-scoped `PlatformApi` client authenticated as
335
- the industry-admin user
336
- - `tenantSlug` — server-derived slug of the fresh
337
- subscription tenant
338
- - `datalakeSlug` — server-derived slug of the fresh
339
- subscription datalake
340
- - `ctx.sarahEmail` / `ctx.sarahPassword` — the per-run admin
341
- credentials (kept on `ctx` because no scenario cookbook needs
342
- to re-authenticate by default)
343
- - `ctx.tenantApiKey` — the tenant's publishable API key (minted in
344
- setup via `admin.createTenantApiKey`); thread it into any
345
- additional tenant-scoped `createSession` a scenario performs
346
- - `ctx.waitForFiredRun(datalakeSlug, runId)` — polls `workflowRuns.get`
347
- until a scheduled run has actually fired, then returns its
348
- `{ workflowRunLogId, batchId }`. Use it after every `workflows.run`;
349
- reading `workflow_run_log_id` off the run response returns `null`
350
- because run-workflow only schedules (see §007)
351
-
352
- Scenario cookbooks under `industry: subscription` start at
353
- their own `§001` with these slots already populated.
354
-
355
- # See also
356
-
357
- - `.agent/datalakes.md` — datalake create body shape (regulated +
358
- unregulated tier configuration)
359
- - `.agent/AGENTS.md` § SDK auth + client construction — root vs
360
- tenantless vs tenant-scoped session scopes
361
- - `integration-tests/tests/subscription/bootstrap.test.ts` —
362
- the green test these snippets are lifted from (the sibling
363
- datalake creation and the sanity probes are out of scope for
364
- this lean cookbook setup)
@@ -1,278 +0,0 @@
1
- ---
2
- title: "Capability: reconcile delivery status of the messages you send"
3
- summary: A capability walk for action status updaters. Register a poller tool that reads a delivery-log source (here CloudWatch via LocalStack) and an action status updater that runs on a cron, maps log events back to the messages that produced them, and updates their status (`api.actionStatusUpdaters.create`). So a "sent" SMS becomes "delivered" / "failed" without you polling by hand.
4
- industry: subscription
5
- slug: action-status-updaters
6
- vitest_source:
7
- - integration-tests/tests/subscription/action-status-updaters.test.ts
8
- - integration-tests/tests/subscription/bootstrap.test.ts
9
- status: green
10
- ---
11
-
12
- # Capability
13
-
14
- **What you get:** the messages you send (SMS, etc.) get their real delivery
15
- outcome written back automatically — "sent" becomes "delivered" or "failed" —
16
- on a schedule, with no manual polling.
17
-
18
- An **action status updater (ASU)** is a scheduled reconciler:
19
-
20
- - a **poller tool** reads a delivery-log source (CloudWatch log group here;
21
- LocalStack stands in locally),
22
- - the ASU runs on a `cron_expression`,
23
- - its `message_config` Liquid maps each log event back to the message it came
24
- from (by `external_id`) and the new `status`,
25
- - `sender_tool_ids` scopes it to the messages a given sender tool produced.
26
-
27
- This is **global** — the same shape reconciles any sender's delivery status. See
28
- `action_status_updaters.md` for the wire reference and `tools.md` for the
29
- poller-tool body.
30
-
31
- # Walkthrough
32
-
33
- The `_setup/subscription.md` bootstrap left `api`, `tenantSlug`,
34
- `datalakeSlug`, and `ctx.datalakeId` populated.
35
-
36
- ## 001 — register a data source for the tools
37
-
38
- Tools attach to a data source — the origin registration. Create one for the SMS
39
- gateway / log source.
40
-
41
- ```typescript
42
- const { data: ds } = await api.dataSources.create(tenantSlug, datalakeSlug, {
43
- name: `ASU Source ${runSuffix}`,
44
- uri: 'sns.local',
45
- description: 'SNS / CloudWatch origin for the dunning-SMS delivery reconciler.',
46
- status: 'active',
47
- is_default: false,
48
- })
49
- dataSourceId = ds.id!
50
- ```
51
-
52
- ## 002 — create the SMS sender tool
53
-
54
- The ASU reconciles the delivery status of messages a sender tool produced. Create
55
- an SNS-backed SMS tool (pointed at LocalStack locally) to be that sender.
56
-
57
- ```typescript
58
- const { data: smsTool } = await api.tools.create(tenantSlug, datalakeSlug, {
59
- name: `ASU SMS Sender ${runSuffix}`,
60
- description: 'SNS-backed SMS sender whose delivery status the ASU reconciles.',
61
- intent: 'sms',
62
- status: 'active',
63
- datalake_id: ctx.datalakeId,
64
- data_source_id: dataSourceId,
65
- body: {
66
- tool_body_type: 'sns',
67
- auth_method: 'access_key',
68
- region: 'us-east-1',
69
- phone_number: '+15551234567',
70
- endpoint_url: 'http://localhost:4566',
71
- access_key_id: 'test',
72
- secret_access_key: 'test',
73
- },
74
- })
75
- ctx.smsToolId = smsTool.id!
76
- ```
77
-
78
- ## 003 — create the CloudWatch poller tool
79
-
80
- The poller tool supplies the credentials + region for reading the delivery log
81
- group. `intent: 'status_poller'` tags it as a reconciler source (not a sender),
82
- and `tool_body_type: 'cloud_watch_log_group'` selects the CloudWatch reader.
83
-
84
- ```typescript
85
- const { data: cwTool } = await api.tools.create(tenantSlug, datalakeSlug, {
86
- name: `ASU CloudWatch Poller ${runSuffix}`,
87
- description: 'CloudWatch log-group poller — reads SNS delivery events for reconciliation.',
88
- intent: 'status_poller',
89
- status: 'active',
90
- datalake_id: ctx.datalakeId,
91
- data_source_id: dataSourceId,
92
- body: {
93
- tool_body_type: 'cloud_watch_log_group',
94
- auth_method: 'access_key',
95
- region: 'us-east-1',
96
- endpoint_url: 'http://localhost:4566',
97
- access_key_id: 'test',
98
- secret_access_key: 'test',
99
- },
100
- })
101
- ctx.cwToolId = cwTool.id!
102
- ```
103
-
104
- ## 004 — create the action status updater
105
-
106
- The ASU ties it together: a `cron_expression` schedule, the `updater_tool_id`
107
- (the poller), the `sender_tool_ids` it reconciles for, and an `updater_body`
108
- naming the log group + the time window. On `start_time`/`end_time`, use
109
- `now_msec` — the injected variable holding unix milliseconds — piped through
110
- `minutes_ago`; the bare `now` is a DateTime and raises here. The
111
- `message_config` Liquid is rendered ONCE PER EVENT, with the event itself as the
112
- assigns (there is no `events` list to loop), and emits a FLAT object: the
113
- `external_id` of the message to update plus the fields to set at the top level.
114
- `action_log_config` is REQUIRED alongside it — same per-event assigns, rendered
115
- into the action-log write shape: a JSON object with `external_id` plus at least
116
- one of `status` / `sent_at` / `metadata`. Omitting it is a 422 (`Missing field:
117
- action_log_config`).
118
-
119
- ```typescript
120
- const { data: asu } = await api.actionStatusUpdaters.create(tenantSlug, datalakeSlug, {
121
- name: `Dunning SMS Delivery Updater ${runSuffix}`,
122
- cron_expression: '*/30 * * * *',
123
- updater_type: 'cloud_watch',
124
- updater_tool_id: ctx.cwToolId,
125
- sender_tool_ids: [ctx.smsToolId],
126
- datalake_id: ctx.datalakeId,
127
- updater_body: {
128
- updater_body_type: 'cloud_watch_request',
129
- log_group_name: 'sns/us-east-1/000000000000/DirectPublishToPhoneNumber',
130
- start_time: '{{ now_msec | minutes_ago: 45 }}',
131
- end_time: '{{ now_msec }}',
132
- },
133
- message_config: {
134
- type: 'custom',
135
- body: '{"external_id": "{{ notification.messageId }}", "status": "delivered"}',
136
- },
137
- action_log_config: {
138
- type: 'custom',
139
- body: '{"external_id": "{{ notification.messageId }}", "status": "delivered"}',
140
- },
141
- })
142
- actionStatusUpdaterId = asu.id!
143
- ```
144
-
145
- ## 005 — verify it persisted (read-back + checksum parity)
146
-
147
- `get` returns the stored row; its fields echo what you sent. `checksum` recomputes
148
- the fingerprint from the same body without persisting — it must equal the stored
149
- row's `checksum`, which is how drift is detected.
150
-
151
- ```typescript
152
- const { data: row } = await api.actionStatusUpdaters.get(tenantSlug, datalakeSlug, actionStatusUpdaterId)
153
- if (row.cron_expression !== '*/30 * * * *') {
154
- throw new Error(`cron mismatch on read-back: ${row.cron_expression}`)
155
- }
156
-
157
- const { data: ck } = await api.actionStatusUpdaters.checksum(tenantSlug, datalakeSlug, {
158
- name: row.name,
159
- cron_expression: row.cron_expression,
160
- updater_type: 'cloud_watch',
161
- updater_tool_id: ctx.cwToolId,
162
- sender_tool_ids: [ctx.smsToolId],
163
- datalake_id: ctx.datalakeId,
164
- updater_body: {
165
- updater_body_type: 'cloud_watch_request',
166
- log_group_name: 'sns/us-east-1/000000000000/DirectPublishToPhoneNumber',
167
- start_time: '{{ now_msec | minutes_ago: 45 }}',
168
- end_time: '{{ now_msec }}',
169
- },
170
- message_config: {
171
- type: 'custom',
172
- body: '{"external_id": "{{ notification.messageId }}", "status": "delivered"}',
173
- },
174
- action_log_config: {
175
- type: 'custom',
176
- body: '{"external_id": "{{ notification.messageId }}", "status": "delivered"}',
177
- },
178
- })
179
- if (typeof ck.checksum !== 'string' || ck.checksum.length === 0) {
180
- throw new Error('expected a checksum for the ASU body')
181
- }
182
- ```
183
-
184
- ## 006 — discover it (list + metadata)
185
-
186
- The ASU shows up in the paginated list, and `metadata` / `metadataDetails`
187
- return the markdown catalog an agent reads to understand what reconcilers exist.
188
-
189
- ```typescript
190
- const { data: list } = await api.actionStatusUpdaters.list(tenantSlug, datalakeSlug)
191
- if (!(list.data ?? []).some((u) => u.id === actionStatusUpdaterId)) {
192
- throw new Error('created ASU not found in the list')
193
- }
194
-
195
- const { data: catalog } = await api.actionStatusUpdaters.metadata(tenantSlug, datalakeSlug)
196
- if (typeof catalog !== 'string' || catalog.length === 0) {
197
- throw new Error('expected a non-empty ASU metadata catalog')
198
- }
199
-
200
- const { data: detail } = await api.actionStatusUpdaters.metadataDetails(tenantSlug, datalakeSlug, actionStatusUpdaterId)
201
- if (typeof detail !== 'string' || detail.length === 0) {
202
- throw new Error('expected non-empty ASU metadata details')
203
- }
204
- ```
205
-
206
- ## 007 — write the integration test
207
-
208
- End the build with a test you keep: one block that re-reads the reconciler
209
- and asserts the facts the scenario depends on. This block runs live under
210
- `make validate-cookbook`.
211
-
212
- ```typescript
213
- // Re-GET — the stored row echoes what was authored.
214
- const { data: asuRow } = await api.actionStatusUpdaters.get(tenantSlug, datalakeSlug, actionStatusUpdaterId)
215
- if (asuRow.cron_expression !== '*/30 * * * *') {
216
- throw new Error(`cron mismatch on read-back: ${asuRow.cron_expression}`)
217
- }
218
- // last_run_* is the ONLY run surface (there is no per-run log). On a
219
- // fresh create it is legitimately null — assert the field EXISTS on the
220
- // wire, never a value.
221
- if (!('last_run_status' in asuRow)) {
222
- throw new Error('ASU response carries no last_run_status field')
223
- }
224
- // Behavioural probe — the metadata surface an agent reads must render.
225
- const { data: asuDetail } = await api.actionStatusUpdaters.metadataDetails(tenantSlug, datalakeSlug, actionStatusUpdaterId)
226
- if (typeof asuDetail !== 'string' || asuDetail.length === 0) {
227
- throw new Error('ASU metadataDetails came back empty')
228
- }
229
- ```
230
-
231
- If the test fails in production, escalate with the failing read's
232
- evidence (the response body, `last_run_error`) — don't rewrite the config
233
- blind.
234
-
235
- # Gotchas
236
-
237
- - **The poller tool's intent is `status_poller`, not the sender's intent.** The
238
- reconciler reads logs; it doesn't send. `sender_tool_ids` is the separate list
239
- of sender tools whose messages it updates.
240
- - **`message_config` renders once per event, and emits a FLAT object.** The event
241
- is the assigns — there is no `events` list to loop — and every top-level key
242
- that is not `external_id` IS the update (`status`, `delivered_at`, …). There is
243
- no `set_params` wrapper. Both mistakes are rejected at create.
244
- - **`action_log_config` is REQUIRED on both create and update (PUT), same as
245
- `message_config`.** Omitting it is a 422 (`Missing field: action_log_config`).
246
- It renders the same per-event assigns into the action-log write shape:
247
- `external_id` plus at least one of `status` / `sent_at` / `metadata`.
248
- - **The schedule is a cron, evaluated platform-side — and one poll cycle can
249
- be fired on demand.** Creating the ASU registers the schedule. The
250
- `updater_body` window (`{{ now_msec | minutes_ago: 45 }}` … `{{ now_msec }}`)
251
- bounds each run's log query, and must render to unix milliseconds — `now_msec`
252
- is the unix-ms variable, not the bare `now` (a DateTime) or the ISO-8601 `now`
253
- filter. To trigger a cycle without
254
- waiting for the cron tick, `api.actionStatusUpdaters.refresh(tenantSlug,
255
- datalakeSlug, id)` answers `202` with the updater row AS-IS (the poll runs
256
- async — the row still shows the PREVIOUS run's stamps; re-read later for
257
- the outcome). See `action_status_updaters.md` §7.
258
- - **If it doesn't reconcile, read `last_run_error` — refresh, don't guess.**
259
- There is no per-run log (known limitation): the run surface is the
260
- read-only `last_run_*` fields. Fire a `refresh`, re-read `last_run_status`
261
- / `last_run_events_found` / `last_run_error`, and escalate with that
262
- evidence — don't rewrite the config blind between cron ticks.
263
- - **`last_run_status` has three values — only `'ok'` means done.** `'ok'`
264
- is the run that read its whole time window; `'partial'` completed but its
265
- provider fetch was truncated, so the newest events may be missing and
266
- `last_run_error` says so (it is not null on a `partial`); `'error'` failed.
267
- Check `last_run_status === 'ok'` — a `!== 'error'` check silently accepts
268
- a truncated `partial` as if it had reconciled.
269
- - **`checksum` parity is your drift signal.** The fingerprint of the desired body
270
- must match the stored row's; a mismatch means the resource drifted.
271
-
272
- # See also
273
-
274
- - `action_status_updaters.md` — wire shape, `updater_body` variants, `message_config`
275
- - `tools.md` — `sns` sender body + `cloud_watch_log_group` poller body
276
- - `_setup/subscription.md` — the bootstrap this walk starts from
277
- - `integration-tests/tests/subscription/action-status-updaters.test.ts` —
278
- the green test these calls are lifted from