@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.
@@ -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 reply loop through a connected app, 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.
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
- | Connected app | The reply loop, twice |
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 two things worth reading
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 Birthday Greeting connected app
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
- The greeting carries a deep link so the recipient can do something with it
962
- — upload a photo, RSVP, whatever the operator wires up. A connected app is
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
- const birthdayApp = await ctx.ensure(
969
- 'Birthday Greeting connected app',
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. And the SMS body must reference
1003
- `{{ connected_app_form_url }}`: the platform mints the page token at action
1004
- time and injects that variable, so a body that never mentions it gets a
1005
- token minted and thrown away, and §031 has no link to resolve.
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
- connected_app_id: connectedAppId,
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: 'Happy birthday {{ mdm_output.legal_entity.first_name }}! \u{1F382} Share a photo: {{ connected_app_form_url }}',
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 a deep link
1380
+ ## 029 — the greeting exists, and it carries an authenticated link
1380
1381
 
1381
- The end state: a birthday greeting exists for this contact, with a
1382
- `/t/<token>` link in its body. True on the first run because the action
1383
- just dispatched, and true on every run after because it dispatched once and
1384
- the idempotency key has protected it since.
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') && b.includes('/t/'))
1430
+ .find((b) => b.includes('Happy birthday'))
1424
1431
  if (!greeting) {
1425
- throw new Error(`no greeting body with a /t/ link — bodies: ${JSON.stringify(bdMessages.map((m) => m.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 bdToken = greeting.match(/\/t\/([A-Za-z0-9_-]+)/)
1428
- if (!bdToken) throw new Error(`no /t/<token> in the rendered greeting: ${greeting}`)
1429
- ctx.birthdayShortPath = bdToken[1]!
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 — resolve the link, and close the loop
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
- The shortlink resolves through `connectedApps.resolvePage`, and the
1435
- `route_path` that comes back must match the action's `connected_app_route`.
1436
- Posting `opened_at` and `form_submitted_at` then mirrors exactly what the
1437
- form's frontend does when the recipient opens it — which closes the loop
1438
- from a scheduled greeting to a tracked reply.
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
- const { data: bdResolved } = await api.connectedApps.resolvePage(
1442
- tenantSlug, datalakeSlug, ctx.birthdayAppSlug,
1443
- { short_path: ctx.birthdayShortPath, user_agent: 'cookbook-doctest/birthday-greeting' },
1444
- )
1445
- if (bdResolved.route_path !== '/forms/birthday-greeting') {
1446
- throw new Error(`resolvePage route_path mismatch: ${bdResolved.route_path}`)
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
- const { data: bdTracked } = await api.connectedApps.updateMessageTracking(
1451
- tenantSlug, datalakeSlug, ctx.birthdayAppSlug,
1452
- { short_path: ctx.birthdayShortPath, opened_at: bdNow, form_submitted_at: bdNow },
1453
- )
1454
- if (!bdTracked.message?.opened_at || !bdTracked.message?.form_submitted_at) {
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 the per-recipient short link in §039 is minted against, which is what
1705
- makes a click attributable to one person rather than to the campaign.
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 short link pointing at the wrong
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 form
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
- mints a page token against it, which is what makes
2067
- `{{ connected_app_form_url }}` a *per-recipient* link. Skip it and the
2068
- subject is nil, no token is minted, and the link renders empty — with
2069
- nothing anywhere reporting a problem.
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 VARIANT_A = 'Hi {{ event_dataset.name }} — 20% off your next visit this week only. Book: {{ connected_app_form_url }}'
2083
- const VARIANT_B = 'Hi {{ event_dataset.name }} — your loyalty reward is waiting. Claim: {{ connected_app_form_url }}'
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
- connected_app_id: ctx.bookingAppId,
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: VARIANT_A },
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: VARIANT_B },
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: VARIANT_A },
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: VARIANT_B },
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 — the short link belongs to one recipient
2312
+ ## 040 — every link belongs to one recipient
2250
2313
 
2251
- Every message the campaign rendered carries a `/t/<hash>`. It is **not** one
2252
- campaign link shared by everyone: the token was minted against this
2253
- recipient's own subject — the one §034 stamped and §038 resolved — so a
2254
- click is attributable to a person rather than to a send.
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 campaignShortPath: string | undefined
2269
- while (Date.now() < linkDeadline && !campaignShortPath) {
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
- const bodies = ((found.data ?? []) as Array<Record<string, unknown>>).map((m) => String(m.body ?? ''))
2275
- campaignShortPath = bodies.find((b) => b.includes('/t/'))?.match(/\/t\/([A-Za-z0-9_-]+)/)?.[1]
2276
- if (!campaignShortPath) await new Promise((r) => setTimeout(r, 2_000))
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 (!campaignShortPath) {
2279
- throw new Error(`no campaign message for ${ctx.adaId} carried a /t/ short link within 120s`)
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
- const { data: campaignResolved } = await api.connectedApps.resolvePage(
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.connectedApps.updateMessageTracking(
2291
- tenantSlug, datalakeSlug, ctx.bookingAppSlug,
2292
- { short_path: campaignShortPath, opened_at: new Date().toISOString() },
2293
- )
2294
- if (!campaignTracked.message?.opened_at) {
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
  ```