@alvera-ai/platform-sdk 0.19.2 → 0.20.0-next.gaafd87a
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 +10 -8
- package/.agent/account_management.md +14 -13
- package/.agent/action_status_updaters.md +5 -4
- package/.agent/ai_sandbox.md +3 -3
- package/.agent/authenticated_links.md +247 -0
- package/.agent/cookbook/organic-marketing.md +220 -126
- package/.agent/cookbook/payments-compliance.md +108 -63
- package/.agent/cookbook/primary-care.md +91 -52
- package/.agent/cookbook/subscription-saas.md +130 -99
- package/.agent/data_activation_clients.md +107 -4
- package/.agent/datalakes.md +1 -1
- package/.agent/interoperability_contracts.md +1 -1
- package/.agent/mdm.md +4 -4
- package/.agent/messages.md +9 -3
- package/.agent/mock-services.md +4 -5
- package/.agent/mutations.md +4 -4
- package/.agent/tool-call-configs.md +2 -2
- package/.agent/tools.md +10 -3
- package/.agent/type_naming.md +3 -4
- package/.agent/workflows.md +45 -41
- package/README.md +1 -1
- package/dist/index.d.mts +394 -606
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +124 -327
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
- package/.agent/connected_apps.md +0 -525
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
title: "Organic marketing: score, greet, campaign — on one raw lake"
|
|
3
|
-
summary: The whole organic-marketing surface in one walk. Stand up a tenant and a raw datalake, then run the scenarios end to end — an LLM agent banding inbound leads into a four-way SMS fan-out, a pure-Liquid birthday trigger that schedules a greeting a year out and closes the
|
|
3
|
+
summary: The whole organic-marketing surface in one walk. Stand up a tenant and a raw datalake, then run the scenarios end to end — an LLM agent banding inbound leads into a four-way SMS fan-out, a pure-Liquid birthday trigger that schedules a greeting a year out and closes the loop through its authenticated link, and an A/B campaign that gates on suppression, splits inside the workflow and dispatches on SMS and email in one run. Ends with delivery reconciliation and a natural-language read of the lake.
|
|
4
4
|
use_case: organic-marketing
|
|
5
5
|
slug: organic-marketing
|
|
6
6
|
vitest_source:
|
|
@@ -40,7 +40,7 @@ is one call away from here.
|
|
|
40
40
|
| Score Lead AI agent | Bands inbound leads |
|
|
41
41
|
| Three generic tables | Leads, the roster, and the audience |
|
|
42
42
|
| Three workflows | Band fan-out, birthday greeting, campaign |
|
|
43
|
-
|
|
|
43
|
+
| Authenticated links | The greeting's link and the campaign's link |
|
|
44
44
|
| Action status updater | Delivery outcomes, reconciled on a cron |
|
|
45
45
|
|
|
46
46
|
# Walkthrough
|
|
@@ -651,8 +651,8 @@ ctx.agentSlug = agent.slug!
|
|
|
651
651
|
|
|
652
652
|
## 012 — the Score Lead workflow
|
|
653
653
|
|
|
654
|
-
Standard shape — filter, decision, actions — with
|
|
655
|
-
closely.
|
|
654
|
+
Standard shape — filter, decision, actions — with three things worth
|
|
655
|
+
reading closely.
|
|
656
656
|
|
|
657
657
|
The agent is **nested in the create body** (`workflow_ai_agents`, a
|
|
658
658
|
`cast_assoc` rather than a separate attach call), which binds it into the
|
|
@@ -669,6 +669,13 @@ action and leaves the other three `:skipped`.
|
|
|
669
669
|
hyphens, and Liquid's dot parser would read `additional_context.score-lead`
|
|
670
670
|
as a subtraction.
|
|
671
671
|
|
|
672
|
+
**`link_prefix` is required on every SMS call, this one included.** These
|
|
673
|
+
bodies carry no link of the walk's own, but each echoes the lead's message,
|
|
674
|
+
and a URL a lead typed into the form goes out as `<link_prefix>/<token>`
|
|
675
|
+
like any other. A body with no URL in it is sent unchanged. Leave the
|
|
676
|
+
prefix off and the workflow is refused at create with a 422 on
|
|
677
|
+
`/actions/<n>/tool_call/link_prefix`.
|
|
678
|
+
|
|
672
679
|
```typescript
|
|
673
680
|
const BANDS = ['hot', 'warm', 'cold', 'spam'] as const
|
|
674
681
|
|
|
@@ -713,6 +720,7 @@ const scoreWorkflow = await ctx.ensure(
|
|
|
713
720
|
to: { type: 'custom', body: '+15551234567' },
|
|
714
721
|
body: { type: 'custom', body: `Lead band [${band}]: {{ event_dataset.message }}` },
|
|
715
722
|
sms_type: 'transactional',
|
|
723
|
+
link_prefix: 'https://go.leads.example.local/l',
|
|
716
724
|
},
|
|
717
725
|
})),
|
|
718
726
|
workflow_ai_agents: [
|
|
@@ -956,33 +964,25 @@ if (!bandBody.includes('[hot]')) {
|
|
|
956
964
|
}
|
|
957
965
|
```
|
|
958
966
|
|
|
959
|
-
## 019 — the
|
|
967
|
+
## 019 — where the greeting's link lands
|
|
968
|
+
|
|
969
|
+
The greeting carries a link so the recipient can do something with it —
|
|
970
|
+
upload a photo, RSVP, whatever the operator builds. Two addresses are in
|
|
971
|
+
play, and neither is registered with the platform.
|
|
972
|
+
|
|
973
|
+
The **page** is the URL you write into the SMS body, exactly as a person
|
|
974
|
+
should end up seeing it. The **link prefix** is the host you run in front
|
|
975
|
+
of it: at send the platform swaps every URL in the body for
|
|
976
|
+
`<link_prefix>/<token>`, a link minted for this one message, and a tap on
|
|
977
|
+
it reaches that host first. The host asks the platform what the link is,
|
|
978
|
+
then sends the person on to the page — §030 does exactly that.
|
|
960
979
|
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
a thin registration of that page's URL and mode; the page itself is hosted
|
|
964
|
-
outside the platform (`mode: 'self_hosted'`). Capture the server-derived
|
|
965
|
-
`slug` — §030 resolves a token against it.
|
|
980
|
+
A path is allowed in the prefix; a query, a fragment or a trailing slash is
|
|
981
|
+
not, because the platform appends `/<token>` itself.
|
|
966
982
|
|
|
967
983
|
```typescript
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
async () => {
|
|
971
|
-
const { data } = await api.connectedApps.list(tenantSlug, datalakeSlug)
|
|
972
|
-
return (data.data ?? []).find((a: { name?: string }) => a.name === 'Cookbook Birthday Greeting Form')
|
|
973
|
-
},
|
|
974
|
-
async () => {
|
|
975
|
-
const { data } = await api.connectedApps.create(tenantSlug, datalakeSlug, {
|
|
976
|
-
name: 'Cookbook Birthday Greeting Form',
|
|
977
|
-
description: 'Birthday-greeting form linked from the outbound SMS.',
|
|
978
|
-
mode: 'self_hosted',
|
|
979
|
-
urls: [{ url: 'https://birthday.example.local', is_primary: true, label: 'production' }],
|
|
980
|
-
})
|
|
981
|
-
return data
|
|
982
|
-
},
|
|
983
|
-
)
|
|
984
|
-
connectedAppId = birthdayApp.id!
|
|
985
|
-
ctx.birthdayAppSlug = birthdayApp.slug!
|
|
984
|
+
ctx.birthdayFormUrl = 'https://birthday.example.local/forms/birthday-greeting'
|
|
985
|
+
ctx.birthdayLinkPrefix = 'https://go.birthday.example.local/l'
|
|
986
986
|
```
|
|
987
987
|
|
|
988
988
|
## 020 — the Happy Birthday workflow, and the year-roll trigger
|
|
@@ -999,10 +999,12 @@ correct instead of nearly correct.
|
|
|
999
999
|
Two things that look optional and are not. The filter reads
|
|
1000
1000
|
`mdm_output.legal_entity.date_of_birth`, which requires
|
|
1001
1001
|
`skip_mdm_resolution: false` — the resolution is what puts the subject in
|
|
1002
|
-
scope for the filter at all
|
|
1003
|
-
`
|
|
1004
|
-
|
|
1005
|
-
|
|
1002
|
+
scope for the filter at all — and it is what fills the link's
|
|
1003
|
+
`legal_entity_id`, so a tap on it says who tapped. And the SMS call must
|
|
1004
|
+
carry `link_prefix`: it is required on every SMS, MMS and email call, and
|
|
1005
|
+
it is what turns the page URL in the body into a link §029 can find.
|
|
1006
|
+
`metadata_template` renders once per send and rides on the message, so the
|
|
1007
|
+
host behind the prefix gets it back with the link.
|
|
1006
1008
|
|
|
1007
1009
|
```typescript
|
|
1008
1010
|
const DECISION_KEY = 'send_happy_birthday_sms'
|
|
@@ -1041,17 +1043,16 @@ const birthdayWorkflow = await ctx.ensure(
|
|
|
1041
1043
|
position: 0,
|
|
1042
1044
|
trigger_template: TRIGGER_TEMPLATE,
|
|
1043
1045
|
idempotency_template: '{{ legal_entity.id }}-birthday-greeting',
|
|
1044
|
-
|
|
1045
|
-
connected_app_route: '/forms/birthday-greeting',
|
|
1046
|
-
connected_app_metadata_template: '{"legal_entity_id":"{{ legal_entity.id }}"}',
|
|
1046
|
+
metadata_template: '{"legal_entity_id":"{{ legal_entity.id }}"}',
|
|
1047
1047
|
tool_call: {
|
|
1048
1048
|
tool_call_type: 'sms_request',
|
|
1049
1049
|
to: { type: 'custom', body: '{{ mdm_output.legal_entity.phone_numbers | first | map: "phone_number" }}' },
|
|
1050
1050
|
body: {
|
|
1051
1051
|
type: 'custom',
|
|
1052
|
-
body:
|
|
1052
|
+
body: `Happy birthday {{ mdm_output.legal_entity.first_name }}! \u{1F382} Share a photo: ${ctx.birthdayFormUrl}`,
|
|
1053
1053
|
},
|
|
1054
1054
|
sms_type: 'transactional',
|
|
1055
|
+
link_prefix: ctx.birthdayLinkPrefix,
|
|
1055
1056
|
},
|
|
1056
1057
|
},
|
|
1057
1058
|
],
|
|
@@ -1376,12 +1377,13 @@ if (forcedStatus !== 'completed') {
|
|
|
1376
1377
|
}
|
|
1377
1378
|
```
|
|
1378
1379
|
|
|
1379
|
-
## 029 — the greeting exists, and it carries
|
|
1380
|
+
## 029 — the greeting exists, and it carries an authenticated link
|
|
1380
1381
|
|
|
1381
|
-
The end state: a birthday greeting exists for this contact,
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1382
|
+
The end state: a birthday greeting exists for this contact, and the page
|
|
1383
|
+
URL §019 wrote into its body went out as `<link_prefix>/<token>` — a link
|
|
1384
|
+
minted for this one message. True on the first run because the action just
|
|
1385
|
+
dispatched, and true on every run after because it dispatched once and the
|
|
1386
|
+
idempotency key has protected it since.
|
|
1385
1387
|
|
|
1386
1388
|
**Read the durable record, not the run you just made.** The obvious move is
|
|
1387
1389
|
to pull the rendered body off the execution log from §028 — and that works
|
|
@@ -1393,6 +1395,11 @@ asking. In local dev the publish itself lands in LocalStack's SNS records
|
|
|
1393
1395
|
at `/_aws/sns/sms-messages`, keyed by phone number — useful to eyeball, too
|
|
1394
1396
|
volatile to assert on.
|
|
1395
1397
|
|
|
1398
|
+
**The stored body is the body as sent**, so the page URL is not in it: in
|
|
1399
|
+
its place stand the link prefix and a fifteen-character token. Finding the
|
|
1400
|
+
page URL itself in the stored body would mean no link was minted, and the
|
|
1401
|
+
recipient was sent straight to the page with nothing to say who tapped.
|
|
1402
|
+
|
|
1396
1403
|
```typescript
|
|
1397
1404
|
const { data: bdMsgSearch } = await api.datasets.createUserSearch(tenantSlug, datalakeSlug, 'message', {
|
|
1398
1405
|
search_query: `m.idempotency_key LIKE '${ctx.birthdayLegalEntityId}-%'`,
|
|
@@ -1420,40 +1427,85 @@ if (bdMessages.length === 0) {
|
|
|
1420
1427
|
|
|
1421
1428
|
const greeting = bdMessages
|
|
1422
1429
|
.map((m) => String(m.body ?? ''))
|
|
1423
|
-
.find((b) => b.includes('Happy birthday')
|
|
1430
|
+
.find((b) => b.includes('Happy birthday'))
|
|
1424
1431
|
if (!greeting) {
|
|
1425
|
-
throw new Error(`no greeting body
|
|
1432
|
+
throw new Error(`no greeting body — bodies: ${JSON.stringify(bdMessages.map((m) => m.body))}`)
|
|
1433
|
+
}
|
|
1434
|
+
if (greeting.includes(ctx.birthdayFormUrl)) {
|
|
1435
|
+
throw new Error(`the page URL went out as written, so no link was minted: ${greeting}`)
|
|
1426
1436
|
}
|
|
1427
|
-
const
|
|
1428
|
-
|
|
1429
|
-
|
|
1437
|
+
const bdLinkStart = `${ctx.birthdayLinkPrefix}/`
|
|
1438
|
+
const bdToken = greeting.split(bdLinkStart)[1]?.match(/^[A-Za-z0-9_-]{15}/)?.[0]
|
|
1439
|
+
if (!bdToken) throw new Error(`no ${bdLinkStart}<token> in the greeting: ${greeting}`)
|
|
1440
|
+
ctx.birthdayLinkToken = bdToken
|
|
1430
1441
|
```
|
|
1431
1442
|
|
|
1432
|
-
## 030 —
|
|
1443
|
+
## 030 — the tap, the page, and the form
|
|
1444
|
+
|
|
1445
|
+
This step plays the host behind the link prefix. A tap on the link reaches
|
|
1446
|
+
it with the token in the path; the host hands that token to
|
|
1447
|
+
`authenticatedLinks.exchange` together with what the tap's request said
|
|
1448
|
+
about itself, and gets back where to send the person and whom the link was
|
|
1449
|
+
for. Both calls here need only the tenant's publishable key — the host is
|
|
1450
|
+
not a signed-in user.
|
|
1451
|
+
|
|
1452
|
+
**Forward the tap's own details, not your server's.** The platform judges
|
|
1453
|
+
from them whether a person tapped: a `HEAD`, a prefetch header, an empty
|
|
1454
|
+
user agent or one naming a bot or a link preview is recorded and moves
|
|
1455
|
+
nothing. A person's first tap stamps the link's `link_clicked_at` and the
|
|
1456
|
+
message's `clicked_at`; later taps keep both. That is why the user agent
|
|
1457
|
+
below is a phone's browser — the one the person actually used.
|
|
1458
|
+
|
|
1459
|
+
The answer carries `metadata_template`'s output on the **message**, as
|
|
1460
|
+
`message.metadata` — the link itself has no metadata field.
|
|
1433
1461
|
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1462
|
+
Then the page reports what the person did, with the same token. **The
|
|
1463
|
+
message status only moves forward**, and `opened` ranks *below* `clicked`:
|
|
1464
|
+
reporting `opened` after the tap leaves the status at `clicked`, while
|
|
1465
|
+
`opened_at` still lands. `form_submitted` ranks above both, so the message
|
|
1466
|
+
ends there.
|
|
1439
1467
|
|
|
1440
1468
|
```typescript
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1469
|
+
ctx.personOnAPhone =
|
|
1470
|
+
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 ' +
|
|
1471
|
+
'(KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1'
|
|
1472
|
+
|
|
1473
|
+
const { data: bdTap } = await api.authenticatedLinks.exchange(tenantSlug, datalakeSlug, {
|
|
1474
|
+
token: ctx.birthdayLinkToken,
|
|
1475
|
+
method: 'GET',
|
|
1476
|
+
user_agent: ctx.personOnAPhone,
|
|
1477
|
+
headers: { 'accept-language': 'en-US' },
|
|
1478
|
+
})
|
|
1479
|
+
if (bdTap.destination_url !== ctx.birthdayFormUrl) {
|
|
1480
|
+
throw new Error(`the link should lead to ${ctx.birthdayFormUrl}, got ${bdTap.destination_url}`)
|
|
1481
|
+
}
|
|
1482
|
+
if (bdTap.legal_entity_id !== ctx.birthdayLegalEntityId) {
|
|
1483
|
+
throw new Error(`the link should name contact ${ctx.birthdayLegalEntityId}, got ${bdTap.legal_entity_id}`)
|
|
1484
|
+
}
|
|
1485
|
+
if (bdTap.message.metadata?.legal_entity_id !== ctx.birthdayLegalEntityId) {
|
|
1486
|
+
throw new Error(`metadata_template did not reach the message: ${JSON.stringify(bdTap.message.metadata)}`)
|
|
1487
|
+
}
|
|
1488
|
+
if (!bdTap.link_clicked_at || !bdTap.message.clicked_at) {
|
|
1489
|
+
throw new Error("a person's tap should stamp link_clicked_at and the message's clicked_at")
|
|
1447
1490
|
}
|
|
1448
1491
|
|
|
1449
1492
|
const bdNow = new Date().toISOString()
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1493
|
+
await api.authenticatedLinks.updateMessageTracking(tenantSlug, datalakeSlug, {
|
|
1494
|
+
token: ctx.birthdayLinkToken,
|
|
1495
|
+
status: 'opened',
|
|
1496
|
+
opened_at: bdNow,
|
|
1497
|
+
})
|
|
1498
|
+
const { data: bdTracked } = await api.authenticatedLinks.updateMessageTracking(tenantSlug, datalakeSlug, {
|
|
1499
|
+
token: ctx.birthdayLinkToken,
|
|
1500
|
+
status: 'form_submitted',
|
|
1501
|
+
form_submitted_at: bdNow,
|
|
1502
|
+
})
|
|
1503
|
+
if (!bdTracked.message.opened_at || !bdTracked.message.form_submitted_at) {
|
|
1455
1504
|
throw new Error('message tracking did not persist opened_at + form_submitted_at')
|
|
1456
1505
|
}
|
|
1506
|
+
if (bdTracked.message.status !== 'form_submitted') {
|
|
1507
|
+
throw new Error(`the message should end at form_submitted, got ${bdTracked.message.status}`)
|
|
1508
|
+
}
|
|
1457
1509
|
```
|
|
1458
1510
|
|
|
1459
1511
|
## 031 — the roster and the audience, as two tables
|
|
@@ -1701,8 +1753,9 @@ Same shape, two differences that carry the campaign.
|
|
|
1701
1753
|
|
|
1702
1754
|
Each recipient resolves to an **individual** legal entity keyed on their
|
|
1703
1755
|
contact handle — phone when there is one, email otherwise. That subject is
|
|
1704
|
-
what
|
|
1705
|
-
|
|
1756
|
+
what every campaign message §039 sends — and every link minted with it —
|
|
1757
|
+
names as its `legal_entity_id`, which is what makes a tap attributable to
|
|
1758
|
+
one person rather than to the campaign.
|
|
1706
1759
|
|
|
1707
1760
|
And the generic-table contract assigns the **bucket**, derived from the last
|
|
1708
1761
|
two digits of the vendor's own id rather than from a random draw. Re-ingest
|
|
@@ -1914,8 +1967,7 @@ await ctx.waitForBatches(ctx.audienceDataDacSlug, audienceDataBatches)
|
|
|
1914
1967
|
|
|
1915
1968
|
Read the four rows back. Every one carries a `legal_entity_id`, and the four
|
|
1916
1969
|
are **distinct** — four recipients, four subjects. A shared subject would
|
|
1917
|
-
mean two people collapsed into one, and a
|
|
1918
|
-
customer.
|
|
1970
|
+
mean two people collapsed into one, and a link naming the wrong customer.
|
|
1919
1971
|
|
|
1920
1972
|
The buckets are checked here too, because a wrong bucket is the kind of
|
|
1921
1973
|
thing that stays invisible until someone asks why variant A outperformed B
|
|
@@ -1956,7 +2008,7 @@ if (subjects.size !== 4) {
|
|
|
1956
2008
|
ctx.adaSubjectId = String(byCustomerId.get(ctx.adaId)!.legal_entity_id)
|
|
1957
2009
|
```
|
|
1958
2010
|
|
|
1959
|
-
## 037 — two sender tools, and the booking
|
|
2011
|
+
## 037 — two sender tools, and the booking page
|
|
1960
2012
|
|
|
1961
2013
|
**Two sender tools, not one.** An SMS number and an email identity are
|
|
1962
2014
|
different assets, provisioned and billed separately, so §038 binds each
|
|
@@ -1969,7 +2021,16 @@ against any server without asking the deployment to be in "test mode", and
|
|
|
1969
2021
|
swapping `mock` for a real provider sends real email through the same
|
|
1970
2022
|
workflow.
|
|
1971
2023
|
|
|
2024
|
+
**The booking page is two addresses, as in §019**, and neither is a
|
|
2025
|
+
resource: the page the campaign points people at, and the host in front of
|
|
2026
|
+
it that answers the taps. Nothing is registered with the platform, so there
|
|
2027
|
+
is nothing to look up or create — the addresses are just values the
|
|
2028
|
+
workflow's actions carry.
|
|
2029
|
+
|
|
1972
2030
|
```typescript
|
|
2031
|
+
ctx.bookingPageUrl = 'https://campaign.example.com/forms/campaign'
|
|
2032
|
+
ctx.campaignLinkPrefix = 'https://go.campaign.example.com/l'
|
|
2033
|
+
|
|
1973
2034
|
const campaignSms = await ctx.ensure(
|
|
1974
2035
|
'campaign SMS sender',
|
|
1975
2036
|
async () => {
|
|
@@ -2022,25 +2083,6 @@ const campaignEmail = await ctx.ensure(
|
|
|
2022
2083
|
},
|
|
2023
2084
|
)
|
|
2024
2085
|
ctx.campaignEmailToolId = campaignEmail.id!
|
|
2025
|
-
|
|
2026
|
-
const bookingApp = await ctx.ensure(
|
|
2027
|
-
'campaign booking form',
|
|
2028
|
-
async () => {
|
|
2029
|
-
const { data } = await api.connectedApps.list(tenantSlug, datalakeSlug)
|
|
2030
|
-
return (data.data ?? []).find((a: { name?: string }) => a.name === 'Cookbook Campaign Booking Form')
|
|
2031
|
-
},
|
|
2032
|
-
async () => {
|
|
2033
|
-
const { data } = await api.connectedApps.create(tenantSlug, datalakeSlug, {
|
|
2034
|
-
name: 'Cookbook Campaign Booking Form',
|
|
2035
|
-
description: 'The booking form the campaign links to.',
|
|
2036
|
-
mode: 'self_hosted',
|
|
2037
|
-
urls: [{ url: 'https://campaign.example.com', is_primary: true, label: 'production' }],
|
|
2038
|
-
})
|
|
2039
|
-
return data
|
|
2040
|
-
},
|
|
2041
|
-
)
|
|
2042
|
-
ctx.bookingAppId = bookingApp.id!
|
|
2043
|
-
ctx.bookingAppSlug = bookingApp.slug!
|
|
2044
2086
|
```
|
|
2045
2087
|
|
|
2046
2088
|
## 038 — the campaign workflow: the gates, the split, both channels
|
|
@@ -2062,11 +2104,24 @@ than two workflows kept in step by hand.
|
|
|
2062
2104
|
|
|
2063
2105
|
**`skip_mdm_resolution: false` is set explicitly, and must be.** A
|
|
2064
2106
|
generic-table row is never its own subject; the subject is the one §034
|
|
2065
|
-
stamped onto it. With resolution on, the platform loads that subject and
|
|
2066
|
-
|
|
2067
|
-
`
|
|
2068
|
-
|
|
2069
|
-
|
|
2107
|
+
stamped onto it. With resolution on, the platform loads that subject, and
|
|
2108
|
+
the message — and every link minted with it — names that person as its
|
|
2109
|
+
`legal_entity_id`. Skip it and the subject is nil: the links still go out
|
|
2110
|
+
and still resolve, but none of them can say who tapped, and nothing
|
|
2111
|
+
anywhere reports a problem.
|
|
2112
|
+
|
|
2113
|
+
**Every message gets its own links.** The booking page's URL is written
|
|
2114
|
+
straight into the body, the same for everyone; the platform swaps it at
|
|
2115
|
+
send for `<link_prefix>/<token>`, with a token minted for that one message.
|
|
2116
|
+
The SMS body is plain text, so its bare URL is swapped. **The email body is
|
|
2117
|
+
HTML, and only an `href` is swapped** — a bare URL in an email's text goes
|
|
2118
|
+
out exactly as written, and a mail client that turns it into a link sends
|
|
2119
|
+
the person straight to the page, past the host and with nothing recorded.
|
|
2120
|
+
So the email variants carry their link as an `<a href>`.
|
|
2121
|
+
|
|
2122
|
+
`link_prefix` is required on every SMS and email call — the same host
|
|
2123
|
+
answers both channels' links. `metadata_template` gives that host what it
|
|
2124
|
+
needs about the recipient beyond who they are.
|
|
2070
2125
|
|
|
2071
2126
|
`output_schema` is a JSON-Schema **object**. The string form is refused 422.
|
|
2072
2127
|
|
|
@@ -2079,15 +2134,19 @@ const CAMPAIGN_FILTER =
|
|
|
2079
2134
|
const AB_DECISION =
|
|
2080
2135
|
'{% if event_dataset.bucket < 50 %}["sms_variant_a","email_variant_a"]' +
|
|
2081
2136
|
'{% else %}["sms_variant_b","email_variant_b"]{% endif %}'
|
|
2082
|
-
const
|
|
2083
|
-
const
|
|
2137
|
+
const SMS_VARIANT_A = `Hi {{ event_dataset.name }} — 20% off your next visit this week only. Book: ${ctx.bookingPageUrl}`
|
|
2138
|
+
const SMS_VARIANT_B = `Hi {{ event_dataset.name }} — your loyalty reward is waiting. Claim: ${ctx.bookingPageUrl}`
|
|
2139
|
+
const EMAIL_VARIANT_A =
|
|
2140
|
+
`<p>Hi {{ event_dataset.name }} — 20% off your next visit this week only.</p>` +
|
|
2141
|
+
`<p><a href="${ctx.bookingPageUrl}">Book your visit</a></p>`
|
|
2142
|
+
const EMAIL_VARIANT_B =
|
|
2143
|
+
`<p>Hi {{ event_dataset.name }} — your loyalty reward is waiting.</p>` +
|
|
2144
|
+
`<p><a href="${ctx.bookingPageUrl}">Claim your reward</a></p>`
|
|
2084
2145
|
|
|
2085
2146
|
const linkage = {
|
|
2086
2147
|
trigger_template: 'now',
|
|
2087
2148
|
idempotency_template: '{{ event_dataset.end_customer_id }}/{{ action_id }}',
|
|
2088
|
-
|
|
2089
|
-
connected_app_route: '/forms/campaign',
|
|
2090
|
-
connected_app_metadata_template:
|
|
2149
|
+
metadata_template:
|
|
2091
2150
|
'{"end_customer_id":"{{ event_dataset.end_customer_id }}","name":"{{ event_dataset.name }}"}',
|
|
2092
2151
|
}
|
|
2093
2152
|
|
|
@@ -2119,8 +2178,9 @@ const campaign = await ctx.ensure(
|
|
|
2119
2178
|
tool_call: {
|
|
2120
2179
|
tool_call_type: 'sms_request',
|
|
2121
2180
|
to: { type: 'custom', body: '{{ event_dataset.phone }}' },
|
|
2122
|
-
body: { type: 'custom', body:
|
|
2181
|
+
body: { type: 'custom', body: SMS_VARIANT_A },
|
|
2123
2182
|
sms_type: 'transactional',
|
|
2183
|
+
link_prefix: ctx.campaignLinkPrefix,
|
|
2124
2184
|
},
|
|
2125
2185
|
},
|
|
2126
2186
|
{
|
|
@@ -2129,8 +2189,9 @@ const campaign = await ctx.ensure(
|
|
|
2129
2189
|
tool_call: {
|
|
2130
2190
|
tool_call_type: 'sms_request',
|
|
2131
2191
|
to: { type: 'custom', body: '{{ event_dataset.phone }}' },
|
|
2132
|
-
body: { type: 'custom', body:
|
|
2192
|
+
body: { type: 'custom', body: SMS_VARIANT_B },
|
|
2133
2193
|
sms_type: 'transactional',
|
|
2194
|
+
link_prefix: ctx.campaignLinkPrefix,
|
|
2134
2195
|
},
|
|
2135
2196
|
},
|
|
2136
2197
|
{
|
|
@@ -2140,7 +2201,8 @@ const campaign = await ctx.ensure(
|
|
|
2140
2201
|
tool_call_type: 'email_request',
|
|
2141
2202
|
to: { type: 'custom', body: '{{ event_dataset.email }}' },
|
|
2142
2203
|
subject: { type: 'custom', body: 'Your 20% off is here' },
|
|
2143
|
-
body: { type: 'custom', body:
|
|
2204
|
+
body: { type: 'custom', body: EMAIL_VARIANT_A },
|
|
2205
|
+
link_prefix: ctx.campaignLinkPrefix,
|
|
2144
2206
|
},
|
|
2145
2207
|
},
|
|
2146
2208
|
{
|
|
@@ -2150,7 +2212,8 @@ const campaign = await ctx.ensure(
|
|
|
2150
2212
|
tool_call_type: 'email_request',
|
|
2151
2213
|
to: { type: 'custom', body: '{{ event_dataset.email }}' },
|
|
2152
2214
|
subject: { type: 'custom', body: 'Your loyalty reward is waiting' },
|
|
2153
|
-
body: { type: 'custom', body:
|
|
2215
|
+
body: { type: 'custom', body: EMAIL_VARIANT_B },
|
|
2216
|
+
link_prefix: ctx.campaignLinkPrefix,
|
|
2154
2217
|
},
|
|
2155
2218
|
},
|
|
2156
2219
|
],
|
|
@@ -2246,15 +2309,25 @@ for (const wel of sentWels) {
|
|
|
2246
2309
|
}
|
|
2247
2310
|
```
|
|
2248
2311
|
|
|
2249
|
-
## 040 —
|
|
2312
|
+
## 040 — every link belongs to one recipient
|
|
2250
2313
|
|
|
2251
|
-
|
|
2252
|
-
|
|
2253
|
-
|
|
2254
|
-
|
|
2314
|
+
Ada was sent one variant on both channels: two messages, each pointing at
|
|
2315
|
+
the same booking page. Neither carries a campaign link shared by everyone.
|
|
2316
|
+
Each URL went out as `<link_prefix>/<token>` with a token minted for that
|
|
2317
|
+
one message, and the message names the subject §034 stamped and §038
|
|
2318
|
+
resolved — so a tap is attributable to a person, and to the channel that
|
|
2319
|
+
reached them, rather than to a send.
|
|
2255
2320
|
|
|
2256
2321
|
Keyed on the idempotency key again, so this reads the durable record and
|
|
2257
|
-
answers on every run rather than only the one that dispatched.
|
|
2322
|
+
answers on every run rather than only the one that dispatched. The email is
|
|
2323
|
+
told apart by its `href`; the booking page itself must appear in neither
|
|
2324
|
+
body, or a link was never minted.
|
|
2325
|
+
|
|
2326
|
+
The tap is the SMS link, forwarded with the person's own request details as
|
|
2327
|
+
in §030. The answer names Ada as `legal_entity_id`, and the message
|
|
2328
|
+
carries what `metadata_template` rendered for her — her own
|
|
2329
|
+
`end_customer_id` — which is all a booking page needs to know whose visit
|
|
2330
|
+
it is booking.
|
|
2258
2331
|
|
|
2259
2332
|
```typescript
|
|
2260
2333
|
const { data: campaignMsgSearch } = await api.datasets.createUserSearch(tenantSlug, datalakeSlug, 'message', {
|
|
@@ -2265,33 +2338,54 @@ if (campaignMsgSearch.status !== 'completed') {
|
|
|
2265
2338
|
}
|
|
2266
2339
|
|
|
2267
2340
|
const linkDeadline = Date.now() + 120_000
|
|
2268
|
-
let
|
|
2269
|
-
while (Date.now() < linkDeadline &&
|
|
2341
|
+
let adaBodies: string[] = []
|
|
2342
|
+
while (Date.now() < linkDeadline && adaBodies.length < 2) {
|
|
2270
2343
|
const { data: found } = await api.datasets.search(tenantSlug, datalakeSlug, 'message', {
|
|
2271
2344
|
userSearchId: campaignMsgSearch.id!,
|
|
2272
2345
|
dataAccessMode: 'raw',
|
|
2273
2346
|
})
|
|
2274
|
-
|
|
2275
|
-
|
|
2276
|
-
|
|
2347
|
+
adaBodies = ((found.data ?? []) as Array<Record<string, unknown>>).map((m) => String(m.body ?? ''))
|
|
2348
|
+
if (adaBodies.length < 2) await new Promise((r) => setTimeout(r, 2_000))
|
|
2349
|
+
}
|
|
2350
|
+
const adaSms = adaBodies.find((b) => !b.includes('href='))
|
|
2351
|
+
const adaEmail = adaBodies.find((b) => b.includes('href='))
|
|
2352
|
+
if (!adaSms || !adaEmail) {
|
|
2353
|
+
throw new Error(`expected Ada's SMS and email within 120s, got: ${JSON.stringify(adaBodies)}`)
|
|
2354
|
+
}
|
|
2355
|
+
|
|
2356
|
+
const campaignLinkStart = `${ctx.campaignLinkPrefix}/`
|
|
2357
|
+
const tokenIn = (body: string) => body.split(campaignLinkStart)[1]?.match(/^[A-Za-z0-9_-]{15}/)?.[0]
|
|
2358
|
+
for (const body of [adaSms, adaEmail]) {
|
|
2359
|
+
if (body.includes(ctx.bookingPageUrl)) throw new Error(`the booking page went out as written: ${body}`)
|
|
2360
|
+
}
|
|
2361
|
+
const smsToken = tokenIn(adaSms)
|
|
2362
|
+
const emailToken = tokenIn(adaEmail)
|
|
2363
|
+
if (!smsToken) throw new Error(`no ${campaignLinkStart}<token> in Ada's SMS: ${adaSms}`)
|
|
2364
|
+
if (!emailToken) throw new Error(`no ${campaignLinkStart}<token> in Ada's email href: ${adaEmail}`)
|
|
2365
|
+
if (smsToken === emailToken) throw new Error('each message mints its own link, but the SMS and email share one')
|
|
2366
|
+
|
|
2367
|
+
const { data: campaignTap } = await api.authenticatedLinks.exchange(tenantSlug, datalakeSlug, {
|
|
2368
|
+
token: smsToken,
|
|
2369
|
+
method: 'GET',
|
|
2370
|
+
user_agent: ctx.personOnAPhone,
|
|
2371
|
+
headers: { 'accept-language': 'en-US' },
|
|
2372
|
+
})
|
|
2373
|
+
if (campaignTap.destination_url !== ctx.bookingPageUrl) {
|
|
2374
|
+
throw new Error(`the link should lead to ${ctx.bookingPageUrl}, got ${campaignTap.destination_url}`)
|
|
2277
2375
|
}
|
|
2278
|
-
if (
|
|
2279
|
-
throw new Error(`
|
|
2376
|
+
if (campaignTap.legal_entity_id !== ctx.adaSubjectId) {
|
|
2377
|
+
throw new Error(`the link should name Ada (${ctx.adaSubjectId}), got ${campaignTap.legal_entity_id}`)
|
|
2280
2378
|
}
|
|
2281
|
-
|
|
2282
|
-
|
|
2283
|
-
tenantSlug, datalakeSlug, ctx.bookingAppSlug,
|
|
2284
|
-
{ short_path: campaignShortPath, user_agent: 'cookbook-doctest/marketing-campaign-send' },
|
|
2285
|
-
)
|
|
2286
|
-
if (campaignResolved.route_path !== '/forms/campaign') {
|
|
2287
|
-
throw new Error(`resolvePage route_path mismatch: ${campaignResolved.route_path}`)
|
|
2379
|
+
if (campaignTap.message.metadata?.end_customer_id !== ctx.adaId) {
|
|
2380
|
+
throw new Error(`metadata_template did not reach the message: ${JSON.stringify(campaignTap.message.metadata)}`)
|
|
2288
2381
|
}
|
|
2289
2382
|
|
|
2290
|
-
const { data: campaignTracked } = await api.
|
|
2291
|
-
|
|
2292
|
-
|
|
2293
|
-
)
|
|
2294
|
-
|
|
2383
|
+
const { data: campaignTracked } = await api.authenticatedLinks.updateMessageTracking(tenantSlug, datalakeSlug, {
|
|
2384
|
+
token: smsToken,
|
|
2385
|
+
status: 'opened',
|
|
2386
|
+
opened_at: new Date().toISOString(),
|
|
2387
|
+
})
|
|
2388
|
+
if (!campaignTracked.message.opened_at) {
|
|
2295
2389
|
throw new Error('message tracking did not persist opened_at')
|
|
2296
2390
|
}
|
|
2297
2391
|
```
|