@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.
- package/.agent/AGENTS.md +82 -144
- package/.agent/account_management.md +2 -2
- package/.agent/action_logs.md +4 -4
- package/.agent/ai_agents.md +28 -21
- package/.agent/ai_sandbox.md +49 -39
- package/.agent/connected_apps.md +3 -3
- package/.agent/cookbook/_fixtures/README.md +1 -1
- package/.agent/cookbook/_fixtures/{foundation → organic-marketing}/_lead_submissions_foundation_generic_table.liquid +1 -1
- package/.agent/cookbook/_fixtures/organic-marketing/_lead_submissions_foundation_legal_entity.liquid +80 -0
- package/.agent/cookbook/_fixtures/{foundation → organic-marketing}/_lead_submissions_foundation_mdm.liquid +2 -1
- package/.agent/cookbook/_fixtures/payments-compliance/_compliance_screenings_generic_table.liquid +57 -0
- package/.agent/cookbook/_fixtures/payments-compliance/_compliance_screenings_legal_entity.liquid +30 -0
- package/.agent/cookbook/_fixtures/payments-compliance/_compliance_screenings_mdm.liquid +44 -0
- package/.agent/cookbook/_fixtures/payments-compliance/_payment_accounts_generic_table.liquid +57 -0
- package/.agent/cookbook/_fixtures/payments-compliance/_payment_accounts_legal_entity.liquid +36 -0
- package/.agent/cookbook/_fixtures/payments-compliance/_payment_accounts_mdm.liquid +41 -0
- package/.agent/cookbook/_fixtures/primary-care-feedback/_cahps_appointments_generic_table.liquid +70 -0
- package/.agent/cookbook/_fixtures/primary-care-feedback/_cahps_appointments_legal_entity.liquid +52 -0
- package/.agent/cookbook/_fixtures/primary-care-feedback/_cahps_appointments_mdm.liquid +42 -0
- package/.agent/cookbook/_fixtures/subscription-saas/_customers_subscription_generic_table.liquid +38 -0
- package/.agent/cookbook/_fixtures/subscription-saas/_customers_subscription_legal_entity.liquid +48 -0
- package/.agent/cookbook/_fixtures/subscription-saas/_customers_subscription_mdm.liquid +49 -0
- package/.agent/cookbook/organic-marketing.md +2801 -0
- package/.agent/cookbook/payments-compliance.md +2180 -0
- package/.agent/cookbook/primary-care.md +2175 -0
- package/.agent/cookbook/subscription-saas.md +2403 -0
- package/.agent/data_activation_clients.md +65 -52
- package/.agent/datalakes.md +338 -171
- package/.agent/errors.md +3 -3
- package/.agent/generic_tables.md +151 -62
- package/.agent/interoperability_contracts.md +57 -22
- package/.agent/mdm.md +136 -153
- package/.agent/messages.md +36 -34
- package/.agent/mock-services.md +1 -1
- package/.agent/mutations.md +2 -2
- package/.agent/templates.md +14 -13
- package/.agent/tools.md +63 -21
- package/.agent/type_naming.md +13 -13
- package/.agent/workflows.md +99 -53
- package/README.md +2 -2
- package/dist/bin/platform-sdk.mjs +33 -47
- package/dist/bin/platform-sdk.mjs.map +1 -1
- package/dist/index.d.mts +565 -379
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +494 -59
- package/dist/index.mjs.map +1 -1
- package/package.json +4 -3
- package/.agent/cookbook/_fixtures/foundation/_lead_submissions_foundation_legal_entity.liquid +0 -88
- package/.agent/cookbook/_fixtures/healthcare/_cahps_appointments_healthcare_appointment.liquid +0 -47
- package/.agent/cookbook/_fixtures/healthcare/_cahps_appointments_healthcare_mdm.liquid +0 -24
- package/.agent/cookbook/_fixtures/healthcare/_cahps_appointments_healthcare_patient.liquid +0 -38
- package/.agent/cookbook/_fixtures/payments/_compliance_screenings_payments_compliance_screening.liquid +0 -59
- package/.agent/cookbook/_fixtures/payments/_compliance_screenings_payments_mdm.liquid +0 -36
- package/.agent/cookbook/_fixtures/payments/_payment_accounts_payments_mdm.liquid +0 -30
- package/.agent/cookbook/_fixtures/payments/_payment_accounts_payments_payment_account.liquid +0 -55
- package/.agent/cookbook/_fixtures/subscription/_customers_subscription_mdm.liquid +0 -20
- package/.agent/cookbook/_setup/foundation.md +0 -359
- package/.agent/cookbook/_setup/healthcare.md +0 -361
- package/.agent/cookbook/_setup/payments.md +0 -365
- package/.agent/cookbook/_setup/subscription.md +0 -364
- package/.agent/cookbook/action-status-updaters.md +0 -278
- package/.agent/cookbook/ai-agent-invoke.md +0 -279
- package/.agent/cookbook/appointment-review-sms-workflow.md +0 -801
- package/.agent/cookbook/birthday-greeting-sms-trigger.md +0 -696
- package/.agent/cookbook/bulk-ingest.md +0 -302
- package/.agent/cookbook/contact-us-triage-with-llm.md +0 -663
- package/.agent/cookbook/dunning-sms-for-delinquent.md +0 -659
- package/.agent/cookbook/generic-tables.md +0 -244
- package/.agent/cookbook/invite-team.md +0 -200
- package/.agent/cookbook/kyc-notification-on-account-activation.md +0 -661
- package/.agent/cookbook/marketing-campaign-send.md +0 -1044
- package/.agent/cookbook/paginated-restapi-poller.md +0 -383
- package/.agent/cookbook/rest-fetch.md +0 -273
- package/.agent/cookbook/sanctions-screening-with-agent-review.md +0 -773
- package/.agent/cookbook/score-leads-with-llm-categorization.md +0 -665
- package/.agent/cookbook/system-templates.md +0 -165
- package/.agent/cookbook/talk-to-data.md +0 -178
- package/.agent/cookbook/triage-prospects-by-priority.md +0 -571
- package/.agent/cookbook/welcome-sms-for-customers.md +0 -647
- /package/.agent/cookbook/_fixtures/{healthcare → primary-care-feedback}/memorandum-of-association-01.png +0 -0
- /package/.agent/cookbook/_fixtures/{healthcare → primary-care-feedback}/sample_two_page.pdf +0 -0
- /package/.agent/cookbook/_fixtures/{subscription → subscription-saas}/_customers_subscription_customer.liquid +0 -0
- /package/.agent/cookbook/_fixtures/{subscription → subscription-saas}/stripe_customers_batch1.csv +0 -0
|
@@ -1,696 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
title: Send a happy-birthday SMS on each contact's next birthday
|
|
3
|
-
summary: End-to-end standard workflow — provision a pure-Liquid year-roll trigger workflow, ingest legal-entity rows through a manual-upload DAC, run the workflow, fast-forward the scheduled SMS via trigger_override, and close the connected-app reply loop. No agent, no LLM.
|
|
4
|
-
industry: foundation
|
|
5
|
-
slug: birthday-greeting-sms-trigger
|
|
6
|
-
vitest_source:
|
|
7
|
-
- integration-tests/tests/foundation/standard-workflow.test.ts
|
|
8
|
-
- integration-tests/tests/foundation/data-sources.test.ts
|
|
9
|
-
- integration-tests/tests/foundation/tools.test.ts
|
|
10
|
-
- integration-tests/tests/foundation/interoperability-contracts.test.ts
|
|
11
|
-
- integration-tests/tests/foundation/create-dac.test.ts
|
|
12
|
-
- integration-tests/tests/foundation/bootstrap.test.ts
|
|
13
|
-
status: green
|
|
14
|
-
---
|
|
15
|
-
|
|
16
|
-
# Problem
|
|
17
|
-
|
|
18
|
-
Most outbound customer-engagement systems handle the "send a
|
|
19
|
-
greeting on the customer's birthday" use case with a cron job that
|
|
20
|
-
queries the contacts table every morning and emits one task per
|
|
21
|
-
match. That works but pulls the date-of-birth-matching logic into
|
|
22
|
-
application code, splits scheduling between the cron window and the
|
|
23
|
-
engagement system, and forces the operator to keep the cron in sync
|
|
24
|
-
with timezone and feature-flag changes.
|
|
25
|
-
|
|
26
|
-
The Alvera platform's standard workflow primitive flips this: the
|
|
27
|
-
workflow declares, in pure Liquid, when each contact's next birthday
|
|
28
|
-
SMS should fire, and the platform schedules the action to dispatch at
|
|
29
|
-
that moment. No cron, no application code computing the birthday
|
|
30
|
-
year-roll, no drift between the source of truth and the scheduler.
|
|
31
|
-
|
|
32
|
-
This cookbook walks the **whole** scenario, not just the provisioning:
|
|
33
|
-
it creates the workflow, ingests legal-entity rows through the
|
|
34
|
-
production data-activation chain, runs the workflow so the filter
|
|
35
|
-
routes each row, fast-forwards the future-scheduled SMS via
|
|
36
|
-
`trigger_override` so the dispatch is observable inside one sitting,
|
|
37
|
-
and finally resolves the connected-app deep-link the SMS carries —
|
|
38
|
-
closing the SMS → reply loop.
|
|
39
|
-
|
|
40
|
-
The scenario is anchored to
|
|
41
|
-
`platform/integration-tests/tests/foundation/standard-workflow.test.ts`
|
|
42
|
-
— a green end-to-end test (§1–§12) that exercises this exact shape.
|
|
43
|
-
This cookbook is a prose re-presentation of what that test walks.
|
|
44
|
-
The setup file `_setup/foundation.md` already provisioned the tenant,
|
|
45
|
-
datalake, and industry-admin client; this cookbook starts from there.
|
|
46
|
-
|
|
47
|
-
# Composition
|
|
48
|
-
|
|
49
|
-
| Resource provisioned | Owner |
|
|
50
|
-
|----------------------------------|-------------|
|
|
51
|
-
| SMS tool (SNS-backed) | build |
|
|
52
|
-
| Birthday Greeting connected app | build |
|
|
53
|
-
| Happy Birthday workflow | build |
|
|
54
|
-
| Lead-form data source | build |
|
|
55
|
-
| Manual Upload tool | build |
|
|
56
|
-
| LegalEntity interop contract | build |
|
|
57
|
-
| Manual-upload DAC | build |
|
|
58
|
-
|
|
59
|
-
The setup file `_setup/foundation.md` already provisioned the tenant +
|
|
60
|
-
datalake + tenant-scoped client; this cookbook starts from there.
|
|
61
|
-
|
|
62
|
-
# Walkthrough
|
|
63
|
-
|
|
64
|
-
## 001 — create the SMS tool
|
|
65
|
-
|
|
66
|
-
The Happy Birthday workflow's scheduled action invokes an SMS tool.
|
|
67
|
-
The tool's `body.tool_body_type: 'sns'` means it routes via AWS SNS;
|
|
68
|
-
local dev points it at LocalStack on `http://localhost:4566` via
|
|
69
|
-
`endpoint_url` so no real AWS credentials are needed. The
|
|
70
|
-
`intent: 'sms'` tags this tool for workflow actions that send SMS
|
|
71
|
-
(versus `data_exchange` for ingestion tools).
|
|
72
|
-
|
|
73
|
-
```typescript
|
|
74
|
-
const smsToolResp = await api.tools.create(tenantSlug, datalakeSlug, {
|
|
75
|
-
name: `Cookbook SMS Tool ${runSuffix}`,
|
|
76
|
-
description: 'SNS-backed SMS dispatcher for the Happy Birthday workflow, wired to LocalStack.',
|
|
77
|
-
intent: 'sms',
|
|
78
|
-
status: 'active',
|
|
79
|
-
datalake_id: ctx.datalakeId,
|
|
80
|
-
body: {
|
|
81
|
-
tool_body_type: 'sns',
|
|
82
|
-
auth_method: 'access_key',
|
|
83
|
-
region: 'us-east-1',
|
|
84
|
-
phone_number: '+15551234567',
|
|
85
|
-
endpoint_url: 'http://localhost:4566',
|
|
86
|
-
access_key_id: 'test',
|
|
87
|
-
secret_access_key: 'test',
|
|
88
|
-
},
|
|
89
|
-
})
|
|
90
|
-
toolId = smsToolResp.data.id!
|
|
91
|
-
```
|
|
92
|
-
|
|
93
|
-
## 002 — create the Birthday Greeting connected app
|
|
94
|
-
|
|
95
|
-
The workflow's SMS action carries a deep-link to a connected-app page
|
|
96
|
-
so the recipient can complete a follow-up form (a birthday photo
|
|
97
|
-
upload, an event RSVP — whichever the operator wires up). The
|
|
98
|
-
connected app is a thin registration of the form's URL and mode; the
|
|
99
|
-
actual page is hosted outside the platform (`mode: 'self_hosted'`).
|
|
100
|
-
The server-derived `slug` is captured for the resolve-page call in
|
|
101
|
-
§014.
|
|
102
|
-
|
|
103
|
-
```typescript
|
|
104
|
-
const connectedAppResp = await api.connectedApps.create(tenantSlug, datalakeSlug, {
|
|
105
|
-
name: `Cookbook Birthday Greeting Form ${runSuffix}`,
|
|
106
|
-
description: 'Birthday-greeting form linked from outbound SMS — used by the Happy Birthday workflow.',
|
|
107
|
-
mode: 'self_hosted',
|
|
108
|
-
urls: [
|
|
109
|
-
{
|
|
110
|
-
url: 'https://birthday.example.local',
|
|
111
|
-
is_primary: true,
|
|
112
|
-
label: 'production',
|
|
113
|
-
},
|
|
114
|
-
],
|
|
115
|
-
})
|
|
116
|
-
connectedAppId = connectedAppResp.data.id!
|
|
117
|
-
ctx.connectedAppSlug = connectedAppResp.data.slug!
|
|
118
|
-
```
|
|
119
|
-
|
|
120
|
-
## 003 — create the Happy Birthday workflow
|
|
121
|
-
|
|
122
|
-
The workflow has the standard shape: a filter (Liquid; passes
|
|
123
|
-
legal entities whose `date_of_birth` is non-null), a decision (a
|
|
124
|
-
literal Liquid array naming one decision key), and one SMS action
|
|
125
|
-
whose `trigger_template` computes the next birthday in pure Liquid via
|
|
126
|
-
year-roll math. The trigger template is the heart of the cookbook —
|
|
127
|
-
by branching on whether today's MM-DD has passed the contact's DoB's
|
|
128
|
-
MM-DD, it always renders the upcoming birthday whether it falls this
|
|
129
|
-
year or next.
|
|
130
|
-
|
|
131
|
-
The SMS body references `{{ connected_app_form_url }}` — the platform
|
|
132
|
-
injects that variable at action-execution time after minting the
|
|
133
|
-
connected-app page token. Without the reference the `/t/<token>`
|
|
134
|
-
deep-link is minted but never lands in the rendered body, so §014
|
|
135
|
-
(resolve the deep-link) could not close the SMS → reply loop.
|
|
136
|
-
|
|
137
|
-
```typescript
|
|
138
|
-
const FILTER_BODY =
|
|
139
|
-
'{% if mdm_output.regulated_legal_entity.date_of_birth %}true{% endif %}'
|
|
140
|
-
const DECISION_KEY = 'send_happy_birthday_sms'
|
|
141
|
-
const DECISION_BODY = `["${DECISION_KEY}"]`
|
|
142
|
-
const DECISION_OUTPUT_SCHEMA = { type: 'array', items: { type: 'string' } }
|
|
143
|
-
const TRIGGER_TEMPLATE =
|
|
144
|
-
'{% assign year = "" | now | date: "%Y" %}' +
|
|
145
|
-
'{% assign today_mmdd = "" | now | date: "%m-%d" %}' +
|
|
146
|
-
'{% assign dob_mmdd = mdm_output.regulated_legal_entity.date_of_birth | date: "%m-%d" %}' +
|
|
147
|
-
'{% if dob_mmdd >= today_mmdd %}{{ year }}-{{ dob_mmdd }} 00:00:00' +
|
|
148
|
-
'{% else %}{{ year | plus: 1 }}-{{ dob_mmdd }} 00:00:00{% endif %}'
|
|
149
|
-
const SMS_TO_TEMPLATE =
|
|
150
|
-
'{{ mdm_output.regulated_legal_entity.phone_numbers | first | map: "phone_number" }}'
|
|
151
|
-
const SMS_BODY_TEMPLATE =
|
|
152
|
-
'Happy birthday {{ mdm_output.regulated_legal_entity.first_name }}! \u{1F382}' +
|
|
153
|
-
' Share a photo: {{ connected_app_form_url }}'
|
|
154
|
-
|
|
155
|
-
const workflowResp = await api.workflows.create(tenantSlug, datalakeSlug, {
|
|
156
|
-
name: `Cookbook Happy Birthday Workflow ${runSuffix}`,
|
|
157
|
-
description: "Sends a happy-birthday SMS on each contact's next birthday — pure-Liquid trigger does the year-roll math.",
|
|
158
|
-
dataset_type: 'legal_entity',
|
|
159
|
-
status: 'live',
|
|
160
|
-
tags: ['lifecycle', 'birthday'],
|
|
161
|
-
skip_mdm_resolution: false,
|
|
162
|
-
filter_config: {
|
|
163
|
-
type: 'custom',
|
|
164
|
-
body: FILTER_BODY,
|
|
165
|
-
},
|
|
166
|
-
decision_config: {
|
|
167
|
-
type: 'custom',
|
|
168
|
-
body: DECISION_BODY,
|
|
169
|
-
output_schema: DECISION_OUTPUT_SCHEMA,
|
|
170
|
-
},
|
|
171
|
-
actions: [
|
|
172
|
-
{
|
|
173
|
-
action_type: 'sms',
|
|
174
|
-
tool_id: toolId,
|
|
175
|
-
decision_key: DECISION_KEY,
|
|
176
|
-
position: 0,
|
|
177
|
-
trigger_template: TRIGGER_TEMPLATE,
|
|
178
|
-
idempotency_template:
|
|
179
|
-
'{{ subject_id }}-{{ workflow_id }}-{{ action_id }}',
|
|
180
|
-
connected_app_id: connectedAppId,
|
|
181
|
-
connected_app_route: '/forms/birthday-greeting',
|
|
182
|
-
connected_app_metadata_template:
|
|
183
|
-
'{"legal_entity_id":"{{ legal_entity.id }}"}',
|
|
184
|
-
tool_call: {
|
|
185
|
-
tool_call_type: 'sms_request',
|
|
186
|
-
to: { type: 'custom', body: SMS_TO_TEMPLATE },
|
|
187
|
-
body: { type: 'custom', body: SMS_BODY_TEMPLATE },
|
|
188
|
-
sms_type: 'transactional',
|
|
189
|
-
},
|
|
190
|
-
},
|
|
191
|
-
],
|
|
192
|
-
})
|
|
193
|
-
workflowId = workflowResp.data.id!
|
|
194
|
-
ctx.workflowSlug = workflowResp.data.slug!
|
|
195
|
-
```
|
|
196
|
-
|
|
197
|
-
## 004 — create the lead-form data source
|
|
198
|
-
|
|
199
|
-
A workflow runs on rows; rows arrive through the data-activation
|
|
200
|
-
chain. The chain's first link is a `DataSource` — a registration of
|
|
201
|
-
where the rows originate. The `uri` is the system-of-record address;
|
|
202
|
-
it flows into each ingested row's `source_uri`, which the
|
|
203
|
-
legal-entity dedupe keys on alongside the contact's email.
|
|
204
|
-
|
|
205
|
-
```typescript
|
|
206
|
-
const dataSourceResp = await api.dataSources.create(tenantSlug, datalakeSlug, {
|
|
207
|
-
name: `Cookbook Lead Form Source ${runSuffix}`,
|
|
208
|
-
uri: 'https://app.alvera.ai',
|
|
209
|
-
description: 'Inbound lead-capture form — origin of the legal-entity rows the birthday workflow runs on.',
|
|
210
|
-
status: 'active',
|
|
211
|
-
is_default: false,
|
|
212
|
-
})
|
|
213
|
-
dataSourceId = dataSourceResp.data.id!
|
|
214
|
-
```
|
|
215
|
-
|
|
216
|
-
## 005 — create the Manual Upload tool
|
|
217
|
-
|
|
218
|
-
The Data Activation Client needs a tool. For inline-JSON ingest a
|
|
219
|
-
`manual_upload` tool is the minimal choice — `intent: 'data_exchange'`
|
|
220
|
-
distinguishes it from the SMS tool, and `tool_body_type: 'manual_upload'`
|
|
221
|
-
needs no endpoint or credential wiring (the rows arrive in the ingest
|
|
222
|
-
call body, not by the tool fetching them).
|
|
223
|
-
|
|
224
|
-
```typescript
|
|
225
|
-
const manualUploadToolResp = await api.tools.create(tenantSlug, datalakeSlug, {
|
|
226
|
-
name: `Cookbook Manual Upload Tool ${runSuffix}`,
|
|
227
|
-
description: 'Manual-upload data-exchange tool — backs the DAC that ingests legal-entity rows.',
|
|
228
|
-
intent: 'data_exchange',
|
|
229
|
-
status: 'active',
|
|
230
|
-
datalake_id: ctx.datalakeId,
|
|
231
|
-
data_source_id: dataSourceId,
|
|
232
|
-
body: { tool_body_type: 'manual_upload' },
|
|
233
|
-
})
|
|
234
|
-
ctx.manualUploadToolId = manualUploadToolResp.data.id!
|
|
235
|
-
```
|
|
236
|
-
|
|
237
|
-
## 006 — create the LegalEntity interoperability contract
|
|
238
|
-
|
|
239
|
-
The interoperability contract is the row-shaping rule: a Liquid
|
|
240
|
-
template that maps an inbound lead-form row into a `LegalEntity`
|
|
241
|
-
upsert. This cookbook loads the production Foundation Airflow
|
|
242
|
-
lead-form template from the vendored fixtures directory rather than
|
|
243
|
-
inlining several hundred lines of Liquid. `mdm_input_config: 'null'`
|
|
244
|
-
means the contract performs no MDM resolution of its own — the
|
|
245
|
-
legal entity is upserted directly, keyed by its email digital
|
|
246
|
-
identifier.
|
|
247
|
-
|
|
248
|
-
The template renders the optional `date_of_birth` and `phone_numbers`
|
|
249
|
-
fields when the inbound row carries them; that is what lets §003's
|
|
250
|
-
has-DoB filter and the year-roll trigger have something to read.
|
|
251
|
-
|
|
252
|
-
```typescript
|
|
253
|
-
const { readFileSync } = await import('node:fs')
|
|
254
|
-
const { join } = await import('node:path')
|
|
255
|
-
const leTemplate = readFileSync(
|
|
256
|
-
join(
|
|
257
|
-
process.env.COOKBOOK_FIXTURES_DIR!,
|
|
258
|
-
'foundation/_lead_submissions_foundation_legal_entity.liquid',
|
|
259
|
-
),
|
|
260
|
-
'utf8',
|
|
261
|
-
)
|
|
262
|
-
|
|
263
|
-
const contractResp = await api.interoperabilityContracts.create(tenantSlug, datalakeSlug, {
|
|
264
|
-
name: `Cookbook Lead Form to LegalEntity ${runSuffix}`,
|
|
265
|
-
description: 'Foundation Airflow lead-form row → LegalEntity (custom Liquid, no MDM input).',
|
|
266
|
-
resource_type: 'legal_entity',
|
|
267
|
-
template_config: { type: 'custom', body: leTemplate },
|
|
268
|
-
mdm_input_config: { type: 'null' },
|
|
269
|
-
generic_table_id: null,
|
|
270
|
-
})
|
|
271
|
-
interopContractId = contractResp.data.id!
|
|
272
|
-
```
|
|
273
|
-
|
|
274
|
-
## 007 — create the manual-upload DAC
|
|
275
|
-
|
|
276
|
-
The Data Activation Client binds the three preceding pieces — the
|
|
277
|
-
manual-upload tool, the data source, and the interop contract — into
|
|
278
|
-
one ingestion endpoint. `tool_call.tool_call_type: 'manual_upload'`
|
|
279
|
-
selects the inline-JSON ingest path. The server-derived `slug` is the
|
|
280
|
-
handle §008 ingests rows against.
|
|
281
|
-
|
|
282
|
-
```typescript
|
|
283
|
-
const dacResp = await api.dataActivationClients.create(tenantSlug, datalakeSlug, {
|
|
284
|
-
name: `Cookbook Lead Form DAC ${runSuffix}`,
|
|
285
|
-
description: 'Manual-upload DAC — ingests lead-form rows into LegalEntity via the interop contract.',
|
|
286
|
-
tool_id: ctx.manualUploadToolId,
|
|
287
|
-
data_source_id: dataSourceId,
|
|
288
|
-
tool_call: { tool_call_type: 'manual_upload' },
|
|
289
|
-
interoperability_contract_ids: [interopContractId],
|
|
290
|
-
})
|
|
291
|
-
dacId = dacResp.data.id!
|
|
292
|
-
ctx.dacSlug = dacResp.data.slug!
|
|
293
|
-
```
|
|
294
|
-
|
|
295
|
-
## 008 — ingest two legal-entity rows
|
|
296
|
-
|
|
297
|
-
Two rows, ingested as inline JSON through the manual-upload DAC. One
|
|
298
|
-
carries a `date_of_birth` (MM-DD of tomorrow, year stamped 30 years
|
|
299
|
-
back so the past-date guard on the LegalEntity changeset is
|
|
300
|
-
satisfied) — the workflow filter will pass it. The other omits
|
|
301
|
-
`date_of_birth` entirely — the filter must reject it. Each ingest
|
|
302
|
-
call gets its own batch id; both are pinned for the run scope in
|
|
303
|
-
§010.
|
|
304
|
-
|
|
305
|
-
Tomorrow's MM-DD is deliberate: §012 fast-forwards the SMS via
|
|
306
|
-
`trigger_override`, and a *future* scheduled time is what makes the
|
|
307
|
-
fast-forward observable (a birthday-today row renders a past time and
|
|
308
|
-
would dispatch regardless).
|
|
309
|
-
|
|
310
|
-
```typescript
|
|
311
|
-
const today = new Date()
|
|
312
|
-
const tomorrow = new Date(today.getTime() + 86_400_000)
|
|
313
|
-
const tomorrowMM = String(tomorrow.getUTCMonth() + 1).padStart(2, '0')
|
|
314
|
-
const tomorrowDD = String(tomorrow.getUTCDate()).padStart(2, '0')
|
|
315
|
-
const dobYear = today.getUTCFullYear() - 30
|
|
316
|
-
const phoneDigits = String(Date.now()).slice(-7).padStart(7, '0')
|
|
317
|
-
ctx.birthdayPhone = `+1555${phoneDigits}`
|
|
318
|
-
|
|
319
|
-
const birthdayRow = {
|
|
320
|
-
submission_id: `BD-${runSuffix}`,
|
|
321
|
-
name: 'Maria Birthday',
|
|
322
|
-
email: `maria-bd-${runSuffix}@example.com`,
|
|
323
|
-
phone: ctx.birthdayPhone,
|
|
324
|
-
company: '',
|
|
325
|
-
message: 'My birthday is tomorrow',
|
|
326
|
-
lead_source: 'cookbook_birthday',
|
|
327
|
-
source_uri: 'https://app.alvera.ai',
|
|
328
|
-
date_of_birth: `${dobYear}-${tomorrowMM}-${tomorrowDD}`,
|
|
329
|
-
}
|
|
330
|
-
const noDobRow = {
|
|
331
|
-
submission_id: `NODOB-${runSuffix}`,
|
|
332
|
-
name: 'Priya NoDob',
|
|
333
|
-
email: `priya-nodob-${runSuffix}@example.com`,
|
|
334
|
-
phone: `+1556${phoneDigits}`,
|
|
335
|
-
company: '',
|
|
336
|
-
message: 'I have no DoB on file',
|
|
337
|
-
lead_source: 'cookbook_birthday',
|
|
338
|
-
source_uri: 'https://app.alvera.ai',
|
|
339
|
-
// date_of_birth intentionally absent — the workflow filter must reject this row
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
const [birthdayIngest, noDobIngest] = await Promise.all([
|
|
343
|
-
api.dataActivationClients.ingest(tenantSlug, datalakeSlug, ctx.dacSlug, { data: birthdayRow }),
|
|
344
|
-
api.dataActivationClients.ingest(tenantSlug, datalakeSlug, ctx.dacSlug, { data: noDobRow }),
|
|
345
|
-
])
|
|
346
|
-
ctx.batchBirthday = birthdayIngest.data.batch_id!
|
|
347
|
-
ctx.batchNoDob = noDobIngest.data.batch_id!
|
|
348
|
-
```
|
|
349
|
-
|
|
350
|
-
## 009 — wait for both ingest batches to reach steady-state
|
|
351
|
-
|
|
352
|
-
Ingestion is async — the DAC enqueues per-row jobs that the
|
|
353
|
-
`BatchMergeWorker` drains into `legal_entities`. Poll the DAC's
|
|
354
|
-
activation logs until both batches show a `legal_entities` row with
|
|
355
|
-
`dataset_updated >= 1` and a non-empty `output_files` array (the
|
|
356
|
-
merged Parquet landed in object storage). Only then is it safe to run
|
|
357
|
-
the workflow against these rows.
|
|
358
|
-
|
|
359
|
-
```typescript
|
|
360
|
-
const targetBatches = new Set([ctx.batchBirthday, ctx.batchNoDob])
|
|
361
|
-
const deadline = Date.now() + 90_000
|
|
362
|
-
let greenCount = 0
|
|
363
|
-
while (Date.now() < deadline) {
|
|
364
|
-
const { data } = await api.dataActivationClients.logs.list(tenantSlug, datalakeSlug, ctx.dacSlug)
|
|
365
|
-
const green = new Set<string>()
|
|
366
|
-
for (const row of (data.data ?? []) as Array<Record<string, unknown>>) {
|
|
367
|
-
const b = row.batch_id
|
|
368
|
-
if (typeof b !== 'string' || !targetBatches.has(b)) continue
|
|
369
|
-
if (row.dataset_table !== 'legal_entities') continue
|
|
370
|
-
if (typeof row.dataset_updated !== 'number' || row.dataset_updated < 1) continue
|
|
371
|
-
const files = row.output_files
|
|
372
|
-
if (!Array.isArray(files) || files.length === 0) continue
|
|
373
|
-
green.add(b)
|
|
374
|
-
}
|
|
375
|
-
greenCount = green.size
|
|
376
|
-
if (greenCount === targetBatches.size) break
|
|
377
|
-
await new Promise((r) => setTimeout(r, 1_000))
|
|
378
|
-
}
|
|
379
|
-
if (greenCount !== targetBatches.size) {
|
|
380
|
-
throw new Error(`only ${greenCount}/2 lead-row batches reached steady-state within 90s`)
|
|
381
|
-
}
|
|
382
|
-
```
|
|
383
|
-
|
|
384
|
-
## 010 — run the workflow against the two batches
|
|
385
|
-
|
|
386
|
-
`workflows.run` with `manual_override: false` evaluates the filter,
|
|
387
|
-
so the has-DoB filter genuinely routes each row. The SQL where-clause
|
|
388
|
-
scopes the run to exactly the two batches §008 ingested (`rle` is the
|
|
389
|
-
regulated-legal-entities alias the run-query exposes).
|
|
390
|
-
|
|
391
|
-
The batch run-log never reaches a terminal status — the birthday row's
|
|
392
|
-
WEL holds a future-scheduled action and stays `:executing` for the
|
|
393
|
-
life of the run. So this step polls on `total_wels` (every per-row job
|
|
394
|
-
has produced a WEL) rather than waiting for the run to conclude.
|
|
395
|
-
|
|
396
|
-
```typescript
|
|
397
|
-
const runResp = await api.workflows.run(tenantSlug, datalakeSlug, ctx.workflowSlug, {
|
|
398
|
-
sql_where_clause: `rle.batch_id IN ('${ctx.batchBirthday}', '${ctx.batchNoDob}')`,
|
|
399
|
-
mode: 'live',
|
|
400
|
-
manual_override: false,
|
|
401
|
-
})
|
|
402
|
-
// run-workflow only SCHEDULES the run. The log id and batch id are
|
|
403
|
-
// written when it fires, so read them back via workflowRuns.get.
|
|
404
|
-
const fired = await ctx.waitForFiredRun(datalakeSlug, runResp.data.workflow_run_id)
|
|
405
|
-
ctx.runLogId = fired.workflowRunLogId
|
|
406
|
-
ctx.runBatchId = fired.batchId!
|
|
407
|
-
|
|
408
|
-
const deadline = Date.now() + 60_000
|
|
409
|
-
let totalWels = 0
|
|
410
|
-
while (Date.now() < deadline) {
|
|
411
|
-
const { data: log } = await api.workflows.batchLogs.refresh(tenantSlug, datalakeSlug, ctx.workflowSlug, ctx.runLogId)
|
|
412
|
-
if (log.status === 'failed') throw new Error('workflow run reached :failed')
|
|
413
|
-
totalWels = typeof log.total_wels === 'number' ? log.total_wels : 0
|
|
414
|
-
if (totalWels >= 2) break
|
|
415
|
-
await new Promise((r) => setTimeout(r, 1_500))
|
|
416
|
-
}
|
|
417
|
-
if (totalWels < 2) throw new Error(`workflow run produced only ${totalWels}/2 WELs within 60s`)
|
|
418
|
-
```
|
|
419
|
-
|
|
420
|
-
## 011 — verify the filter routed: one passes, one is filtered
|
|
421
|
-
|
|
422
|
-
Each row produced a Workflow Execution Log. The birthday row (has
|
|
423
|
-
DoB) passes the filter, so its WEL is `:executing` (a future SMS
|
|
424
|
-
action is scheduled) or `:completed`. The no-DoB row fails the
|
|
425
|
-
filter, so its WEL is `:filtered`. A run where both passed — or both
|
|
426
|
-
were filtered — would mean the filter is not actually evaluating; the
|
|
427
|
-
old shallow version of this cookbook could never catch that because
|
|
428
|
-
it never ran the workflow at all.
|
|
429
|
-
|
|
430
|
-
```typescript
|
|
431
|
-
const { data: wfLogs } = await api.workflows.workflowLogs.list(tenantSlug, datalakeSlug, ctx.workflowSlug)
|
|
432
|
-
const ourWels = (wfLogs.data ?? []).filter(
|
|
433
|
-
(w) => (w as { batch_id?: string }).batch_id === ctx.runBatchId,
|
|
434
|
-
)
|
|
435
|
-
if (ourWels.length !== 2) {
|
|
436
|
-
throw new Error(`expected 2 WELs for the run, got ${ourWels.length}`)
|
|
437
|
-
}
|
|
438
|
-
|
|
439
|
-
const byStatus: Record<string, number> = {}
|
|
440
|
-
for (const w of ourWels) {
|
|
441
|
-
const st = (w as { status?: string }).status ?? 'unknown'
|
|
442
|
-
byStatus[st] = (byStatus[st] ?? 0) + 1
|
|
443
|
-
}
|
|
444
|
-
const passCount = (byStatus.executing ?? 0) + (byStatus.completed ?? 0)
|
|
445
|
-
if (passCount !== 1) {
|
|
446
|
-
throw new Error(`expected 1 pass-branch WEL, got ${passCount} — distribution ${JSON.stringify(byStatus)}`)
|
|
447
|
-
}
|
|
448
|
-
if ((byStatus.filtered ?? 0) !== 1) {
|
|
449
|
-
throw new Error(`expected 1 :filtered WEL (the no-DoB row) — distribution ${JSON.stringify(byStatus)}`)
|
|
450
|
-
}
|
|
451
|
-
```
|
|
452
|
-
|
|
453
|
-
## 012 — fast-forward the scheduled SMS with trigger_override
|
|
454
|
-
|
|
455
|
-
The birthday row's SMS action is scheduled for tomorrow 00:00 UTC —
|
|
456
|
-
in the future, so it will not dispatch on its own inside this
|
|
457
|
-
sitting. `workflows.execute` with `trigger_override: true` tells the
|
|
458
|
-
platform to fast-forward the queued Oban job (`Oban.retry_job`
|
|
459
|
-
promotes it to `:available`) so the SMS dispatches now. The
|
|
460
|
-
`ActionExecutionLog` still records the year-roll-rendered
|
|
461
|
-
`scheduled_at` — only the job is moved, which localises the override's
|
|
462
|
-
blast radius.
|
|
463
|
-
|
|
464
|
-
`workflows.execute` is addressed by `dataset_id` (the unregulated
|
|
465
|
-
legal-entity id), so first resolve the birthday row's id via a
|
|
466
|
-
dataset search scoped to its batch.
|
|
467
|
-
|
|
468
|
-
```typescript
|
|
469
|
-
const { data: us } = await api.datasets.createUserSearch(tenantSlug, datalakeSlug, 'legal_entity', {
|
|
470
|
-
search_query: `rle.batch_id = '${ctx.batchBirthday}'`,
|
|
471
|
-
})
|
|
472
|
-
if (us.status !== 'completed') {
|
|
473
|
-
throw new Error(`legal_entity user-search status=${us.status} error=${us.error_message ?? '(none)'}`)
|
|
474
|
-
}
|
|
475
|
-
const { data: search } = await api.datasets.search(tenantSlug, datalakeSlug, 'legal_entity', {
|
|
476
|
-
userSearchId: us.id!,
|
|
477
|
-
dataAccessMode: 'unregulated',
|
|
478
|
-
})
|
|
479
|
-
const birthdayLegalEntityId = (search.data?.[0] as { id?: string } | undefined)?.id
|
|
480
|
-
if (!birthdayLegalEntityId) throw new Error('birthday row legal_entity not found via search')
|
|
481
|
-
|
|
482
|
-
const { data: execResp } = await api.workflows.execute(tenantSlug, datalakeSlug, ctx.workflowSlug, {
|
|
483
|
-
dataset_id: birthdayLegalEntityId,
|
|
484
|
-
decision_key: 'send_happy_birthday_sms',
|
|
485
|
-
manual_override: true,
|
|
486
|
-
trigger_override: true,
|
|
487
|
-
})
|
|
488
|
-
ctx.forceExecWelId = execResp.workflow_execution_log_id!
|
|
489
|
-
|
|
490
|
-
const deadline = Date.now() + 60_000
|
|
491
|
-
let welStatus: string | null = null
|
|
492
|
-
while (Date.now() < deadline) {
|
|
493
|
-
const { data: wel } = await api.workflows.workflowLogs.get(tenantSlug, datalakeSlug, ctx.workflowSlug, ctx.forceExecWelId)
|
|
494
|
-
welStatus = (wel as { status?: string }).status ?? null
|
|
495
|
-
if (welStatus && welStatus !== 'pending' && welStatus !== 'executing') break
|
|
496
|
-
await new Promise((r) => setTimeout(r, 2_000))
|
|
497
|
-
}
|
|
498
|
-
if (welStatus !== 'completed') {
|
|
499
|
-
throw new Error(`fast-forwarded WEL did not reach :completed (last status: ${welStatus})`)
|
|
500
|
-
}
|
|
501
|
-
```
|
|
502
|
-
|
|
503
|
-
## 013 — confirm the SMS landed in LocalStack SNS
|
|
504
|
-
|
|
505
|
-
The SMS tool publishes direct-to-phone-number via AWS SNS; LocalStack
|
|
506
|
-
records every direct publish under `/_aws/sns/sms-messages` keyed by
|
|
507
|
-
recipient phone. Poll the birthday row's phone for a record whose body
|
|
508
|
-
carries the rendered greeting — proof the workflow's stated business
|
|
509
|
-
outcome (a birthday SMS) actually happened, not just that the
|
|
510
|
-
resources exist.
|
|
511
|
-
|
|
512
|
-
```typescript
|
|
513
|
-
const LOCALSTACK = 'http://localhost:4566'
|
|
514
|
-
const deadline = Date.now() + 30_000
|
|
515
|
-
let matched: { Message: string; PhoneNumber: string } | undefined
|
|
516
|
-
while (Date.now() < deadline && !matched) {
|
|
517
|
-
const resp = await fetch(`${LOCALSTACK}/_aws/sns/sms-messages`)
|
|
518
|
-
if (resp.ok) {
|
|
519
|
-
const body = (await resp.json()) as {
|
|
520
|
-
sms_messages?: Record<string, Array<{ Message: string; PhoneNumber: string }>>
|
|
521
|
-
}
|
|
522
|
-
const records = body.sms_messages?.[ctx.birthdayPhone] ?? []
|
|
523
|
-
matched = records.find(
|
|
524
|
-
(m) => m.Message.includes('Happy birthday') && m.Message.includes('Maria'),
|
|
525
|
-
)
|
|
526
|
-
}
|
|
527
|
-
if (!matched) await new Promise((r) => setTimeout(r, 500))
|
|
528
|
-
}
|
|
529
|
-
if (!matched) {
|
|
530
|
-
throw new Error(`no "Happy birthday" SMS landed in LocalStack SNS for ${ctx.birthdayPhone} within 30s`)
|
|
531
|
-
}
|
|
532
|
-
```
|
|
533
|
-
|
|
534
|
-
## 014 — resolve the connected-app deep-link and post tracking
|
|
535
|
-
|
|
536
|
-
The dispatched SMS carries a `/t/<token>` shortlink minted from the
|
|
537
|
-
action's `connected_app_id` + `connected_app_route`. Read the executed
|
|
538
|
-
WEL in regulated mode to get the raw rendered `message_body`, extract
|
|
539
|
-
the token, and resolve it via `connectedApps.resolvePage` — the
|
|
540
|
-
`route_path` must match the action's `connected_app_route`. Posting
|
|
541
|
-
`opened_at` + `form_submitted_at` via `updateMessageTracking` then
|
|
542
|
-
mirrors what the connected-app frontend does when the recipient opens
|
|
543
|
-
the page, closing the SMS → reply loop end-to-end.
|
|
544
|
-
|
|
545
|
-
```typescript
|
|
546
|
-
const { data: regulatedWel } = await api.workflows.workflowLogs.get(
|
|
547
|
-
tenantSlug,
|
|
548
|
-
datalakeSlug,
|
|
549
|
-
ctx.workflowSlug,
|
|
550
|
-
ctx.forceExecWelId,
|
|
551
|
-
{ dataAccessMode: 'regulated' },
|
|
552
|
-
)
|
|
553
|
-
const aelWithBody = (regulatedWel.action_execution_logs ?? []).find(
|
|
554
|
-
(ael) => typeof ael.message_body === 'string' && ael.message_body.length > 0,
|
|
555
|
-
)
|
|
556
|
-
if (!aelWithBody?.message_body) {
|
|
557
|
-
throw new Error('no AEL message_body on the executed WEL')
|
|
558
|
-
}
|
|
559
|
-
const tokenMatch = aelWithBody.message_body.match(/\/t\/([A-Za-z0-9_-]+)/)
|
|
560
|
-
if (!tokenMatch) {
|
|
561
|
-
throw new Error(`no /t/<token> in rendered SMS body: ${aelWithBody.message_body}`)
|
|
562
|
-
}
|
|
563
|
-
const shortPath = tokenMatch[1]!
|
|
564
|
-
|
|
565
|
-
const { data: resolved } = await api.connectedApps.resolvePage(tenantSlug, datalakeSlug, ctx.connectedAppSlug, {
|
|
566
|
-
short_path: shortPath,
|
|
567
|
-
user_agent: 'cookbook-doctest/birthday-greeting',
|
|
568
|
-
})
|
|
569
|
-
if (resolved.route_path !== '/forms/birthday-greeting') {
|
|
570
|
-
throw new Error(`resolvePage route_path mismatch: ${resolved.route_path}`)
|
|
571
|
-
}
|
|
572
|
-
|
|
573
|
-
const now = new Date().toISOString()
|
|
574
|
-
const { data: tracked } = await api.connectedApps.updateMessageTracking(tenantSlug, datalakeSlug, ctx.connectedAppSlug, {
|
|
575
|
-
short_path: shortPath,
|
|
576
|
-
opened_at: now,
|
|
577
|
-
form_submitted_at: now,
|
|
578
|
-
})
|
|
579
|
-
if (!tracked.message?.opened_at || !tracked.message?.form_submitted_at) {
|
|
580
|
-
throw new Error('message tracking did not persist opened_at + form_submitted_at')
|
|
581
|
-
}
|
|
582
|
-
```
|
|
583
|
-
|
|
584
|
-
## 015 — write the integration test
|
|
585
|
-
|
|
586
|
-
End the build with a test you keep: re-read the workflow and prove the
|
|
587
|
-
pipeline still executes — without a side effect. `mode: 'dry_run'` with a
|
|
588
|
-
never-matching selection runs the FULL pipeline (selection → filter →
|
|
589
|
-
decision) and intercepts only the final action call, so no message
|
|
590
|
-
leaves, yet the acknowledgement proves the workflow is runnable. This
|
|
591
|
-
block runs live under `make validate-cookbook`.
|
|
592
|
-
|
|
593
|
-
```typescript
|
|
594
|
-
// Re-GET — the workflow must still be live, or nothing will run.
|
|
595
|
-
const { data: wfRow } = await api.workflows.get(tenantSlug, datalakeSlug, workflowId)
|
|
596
|
-
if (wfRow.status !== 'live') {
|
|
597
|
-
throw new Error(`workflow regressed from live: ${wfRow.status}`)
|
|
598
|
-
}
|
|
599
|
-
// Behavioural probe — a dry run against a selection no row can match:
|
|
600
|
-
// the pipeline executes end-to-end, the final action call is
|
|
601
|
-
// intercepted, and the acknowledgement carries the scheduled run id. The
|
|
602
|
-
// clause must speak this workflow's selection dialect — the dataset
|
|
603
|
-
// alias is `rle` here, the same alias the live run above uses.
|
|
604
|
-
const { data: probeRun } = await api.workflows.run(tenantSlug, datalakeSlug, ctx.workflowSlug, {
|
|
605
|
-
sql_where_clause: "rle.batch_id = 'test-never-matching-batch'",
|
|
606
|
-
mode: 'dry_run',
|
|
607
|
-
manual_override: false,
|
|
608
|
-
})
|
|
609
|
-
// The run-log id does not exist until the run fires — wait, do not read a null.
|
|
610
|
-
const probeFired = await ctx.waitForFiredRun(datalakeSlug, probeRun.workflow_run_id)
|
|
611
|
-
if (probeFired.workflowRunLogId.length === 0) {
|
|
612
|
-
throw new Error('dry-run probe never produced a workflow_run_log_id')
|
|
613
|
-
}
|
|
614
|
-
```
|
|
615
|
-
|
|
616
|
-
If the probe fails in production, escalate with the run response as
|
|
617
|
-
evidence — don't flip the workflow's status or rewrite its configs to
|
|
618
|
-
chase the error.
|
|
619
|
-
|
|
620
|
-
# Branches
|
|
621
|
-
|
|
622
|
-
- **The no-DoB row is filtered, not failed** — §011 asserts the no-DoB
|
|
623
|
-
row's WEL is `:filtered`, a distinct terminal status from `:failed`.
|
|
624
|
-
A filtered row is a *correct* outcome: the workflow looked at it,
|
|
625
|
-
the has-DoB filter rendered empty, and the platform recorded that
|
|
626
|
-
the row was intentionally skipped. No SMS action is scheduled for a
|
|
627
|
-
filtered row.
|
|
628
|
-
- **trigger_override deferred vs immediate** — §012 uses
|
|
629
|
-
`trigger_override: true` to fast-forward the SMS. With
|
|
630
|
-
`trigger_override: false` (the default) the same action stays
|
|
631
|
-
queued at its year-roll `scheduled_at` — the WEL settles at
|
|
632
|
-
`:executing` with a `:pending` action and no SMS dispatches inside
|
|
633
|
-
the test window. The anchor vitest's §8 covers that deferred branch
|
|
634
|
-
explicitly.
|
|
635
|
-
- **Trigger fires this calendar year vs next** — the Liquid trigger
|
|
636
|
-
branches on `dob_mmdd >= today_mmdd`. If the contact's birthday this
|
|
637
|
-
year has already passed, the trigger renders the same month-day in
|
|
638
|
-
the next calendar year. Year-roll is implicit in the template;
|
|
639
|
-
there is no application code to audit.
|
|
640
|
-
|
|
641
|
-
# Rollback
|
|
642
|
-
|
|
643
|
-
The cookbook doctest harness does not currently tear down created
|
|
644
|
-
resources. The `_setup/foundation.md` setup file's runSuffix-scoped
|
|
645
|
-
tenant / datalake / user names mean each run is naturally isolated;
|
|
646
|
-
the seeded local DB is cheap to reset (`mix ecto.reset` on the
|
|
647
|
-
platform repo).
|
|
648
|
-
|
|
649
|
-
# Outcome
|
|
650
|
-
|
|
651
|
-
After this cookbook's fourteen steps run green:
|
|
652
|
-
|
|
653
|
-
- A foundation tenant exists with a foundation-domain datalake
|
|
654
|
-
- An SMS tool, a Birthday Greeting connected app, and a Happy Birthday
|
|
655
|
-
standard workflow (status `live`, year-roll trigger) are registered
|
|
656
|
-
- A data source, a Manual Upload tool, a LegalEntity interop contract,
|
|
657
|
-
and a manual-upload DAC form a working ingestion chain
|
|
658
|
-
- Two legal entities have been ingested through that chain — one with
|
|
659
|
-
a date of birth, one without
|
|
660
|
-
- Running the workflow routed them correctly: the dated row passed the
|
|
661
|
-
filter and scheduled an SMS; the undated row was `:filtered`
|
|
662
|
-
- The scheduled SMS, fast-forwarded via `trigger_override`, **actually
|
|
663
|
-
dispatched** — it is observable in LocalStack SNS with the rendered
|
|
664
|
-
"Happy birthday" greeting
|
|
665
|
-
- The connected-app deep-link the SMS carried resolves, and message
|
|
666
|
-
tracking records the open + form-submit timestamps
|
|
667
|
-
|
|
668
|
-
The business outcome — a birthday SMS sent to a contact, with a
|
|
669
|
-
working reply link — is demonstrated end-to-end, not merely
|
|
670
|
-
provisioned.
|
|
671
|
-
|
|
672
|
-
# See also
|
|
673
|
-
|
|
674
|
-
- `_setup/foundation.md` — the inlined bootstrap that provisions the
|
|
675
|
-
tenant + datalake this cookbook starts from
|
|
676
|
-
- `score-leads-with-llm-categorization.md` — the agent-driven
|
|
677
|
-
foundation cookbook; same SMS + ingestion shape, LLM in the decision
|
|
678
|
-
- `.agent/tools.md` — SMS (`tool_body_type: sns`) and manual-upload
|
|
679
|
-
tool body shapes; intent classification
|
|
680
|
-
- `.agent/connected_apps.md` — connected-app registration + the
|
|
681
|
-
resolve-page / message-tracking reply loop
|
|
682
|
-
- `.agent/data_activation_clients.md` — data source → tool → interop
|
|
683
|
-
contract → DAC ingestion chain
|
|
684
|
-
- `.agent/workflows.md` — standard workflow primitive (filter +
|
|
685
|
-
decision + actions), `workflows.run` vs `workflows.execute`, and
|
|
686
|
-
`trigger_override`
|
|
687
|
-
- `.agent/ai_sandbox.md` — bounds on the Liquid sandbox the filter and
|
|
688
|
-
trigger templates execute inside
|
|
689
|
-
- `.agent/cookbook/_fixtures/foundation/` — the vendored
|
|
690
|
-
lead-form LegalEntity Liquid template §006 loads
|
|
691
|
-
- `integration-tests/tests/foundation/standard-workflow.test.ts` — the
|
|
692
|
-
anchor green test (§1–§12) these snippets are lifted from
|
|
693
|
-
- `integration-tests/tests/foundation/data-sources.test.ts`,
|
|
694
|
-
`tools.test.ts`, `interoperability-contracts.test.ts`,
|
|
695
|
-
`create-dac.test.ts` — the per-resource create snippets §004–§007 are
|
|
696
|
-
lifted from
|