@alvera-ai/platform-sdk 0.19.2 → 0.20.0-next.gcd2f231

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.
@@ -877,7 +877,7 @@ await ctx.deployGenericTable(ctx.screeningsTableName)
877
877
 
878
878
  Filter, decision, actions — plus the agent **nested** in the create body
879
879
  via `workflow_ai_agents` (a `cast_assoc`, not a separate attach call).
880
- Three pieces are worth stopping on.
880
+ Four pieces are worth stopping on.
881
881
 
882
882
  The **filter** is the gray-zone band: it passes only screenings scoring at
883
883
  least `0.80` and below `0.95`. Below `0.80` is an auto-clear and at or
@@ -895,6 +895,13 @@ contains hyphens — into a single-element decision array. The two actions
895
895
  are keyed `decision_key: verified|blocked`; whichever verdict the agent
896
896
  emits picks the action that fires and leaves the other `:skipped`.
897
897
 
898
+ The **`link_prefix`** is required on every SMS call, these two included.
899
+ The verdict SMS carries no link of the walk's own, but its body echoes the
900
+ agent's free-text comments, and a URL the model writes there goes out as
901
+ `<link_prefix>/<token>` like any other. A body with no URL in it is sent
902
+ unchanged. Leave the prefix off and the workflow is refused at create with
903
+ a 422 on `/actions/<n>/tool_call/link_prefix`.
904
+
898
905
  ```typescript
899
906
  const VERDICTS = ['verified', 'blocked'] as const
900
907
 
@@ -948,6 +955,7 @@ const workflow = await ctx.ensure(
948
955
  body: `[${verdict}] {{ additional_context["${ctx.agentSlug}"].comments }}`,
949
956
  },
950
957
  sms_type: 'transactional',
958
+ link_prefix: 'https://go.compliance.example.local/l',
951
959
  },
952
960
  })),
953
961
  // Workflow joins omit output_schema — the server pins it from the
@@ -1420,32 +1428,25 @@ if (!verdictBody.includes('[verified]')) {
1420
1428
  }
1421
1429
  ```
1422
1430
 
1423
- ## 026 — the KYC portal, as a connected app
1431
+ ## 026 — the KYC portal, and the host in front of it
1432
+
1433
+ The second scenario's SMS carries a link to a self-serve KYC portal, hosted
1434
+ outside the platform. Two addresses are in play, and neither is registered
1435
+ with the platform — there is nothing to create here.
1436
+
1437
+ The **portal page** is the URL written into the SMS body, exactly as the
1438
+ holder should end up seeing it. The **link prefix** is the host you run in
1439
+ front of it: at send the platform swaps every URL in the body for
1440
+ `<link_prefix>/<token>`, a link minted for this one message, and a tap on
1441
+ it reaches that host first. The host asks the platform what the link is,
1442
+ then sends the holder on to the portal — §036 does exactly that.
1424
1443
 
1425
- The second scenario's SMS carries a deep link to a self-serve KYC portal.
1426
- The connected app is a thin registration of that portal's URL and mode —
1427
- the page itself is hosted outside the platform (`mode: 'self_hosted'`). The
1428
- server-derived slug is what §036 resolves the link against.
1444
+ A path is allowed in the prefix; a query, a fragment or a trailing slash is
1445
+ not, because the platform appends `/<token>` itself.
1429
1446
 
1430
1447
  ```typescript
1431
- const connectedApp = await ctx.ensure(
1432
- 'KYC portal connected app',
1433
- async () => {
1434
- const { data } = await api.connectedApps.list(tenantSlug, datalakeSlug)
1435
- return (data.data ?? []).find((c: { name?: string }) => c.name === 'Cookbook KYC Portal')
1436
- },
1437
- async () => {
1438
- const { data } = await api.connectedApps.create(tenantSlug, datalakeSlug, {
1439
- name: 'Cookbook KYC Portal',
1440
- description: 'Self-serve KYC portal linked from the outbound notification SMS.',
1441
- mode: 'self_hosted',
1442
- urls: [{ url: 'https://kyc.example.local', is_primary: true, label: 'production' }],
1443
- })
1444
- return data
1445
- },
1446
- )
1447
- connectedAppId = connectedApp.id!
1448
- ctx.connectedAppSlug = connectedApp.slug!
1448
+ ctx.kycPortalUrl = 'https://kyc.example.local/portal/kyc'
1449
+ ctx.kycLinkPrefix = 'https://go.kyc.example.local/l'
1449
1450
  ```
1450
1451
 
1451
1452
  ## 027 — the payment-accounts table
@@ -1504,12 +1505,17 @@ await ctx.deployGenericTable(ctx.accountsTableName)
1504
1505
  Standard shape: a filter passing accounts whose `status` is `active`, a
1505
1506
  decision naming one key, and one SMS action. The body references the
1506
1507
  account's external id so the recipient knows which account activated, and
1507
- `{{ connected_app_form_url }}` so the platform bakes in the `/t/<token>`
1508
- portal link.
1508
+ the portal URL itself — the platform swaps it at send for an authenticated
1509
+ link, because the action carries §026's `link_prefix`. `link_prefix` is
1510
+ required on every SMS call; `metadata_template` renders once per send and
1511
+ rides on the message, so the host behind the prefix gets the account's
1512
+ external id back with the link.
1509
1513
 
1510
1514
  `skip_mdm_resolution: false` is set explicitly and **must be**. The account
1511
- holder is the subject the portal token is minted against, and it reaches
1512
- the workflow only through the `legal_entity_id` stamped on the row.
1515
+ holder reaches the workflow only through the `legal_entity_id` stamped on
1516
+ the row, and resolution is what makes the message — and the link minted
1517
+ with it — name that holder. Skip it and the link still resolves, but it
1518
+ cannot say whose portal session it opens.
1513
1519
 
1514
1520
  A `context_datasets` entry queries the `message` dataset for any
1515
1521
  KYC notification already sent to the same legal entity in the last six
@@ -1529,7 +1535,7 @@ const KYC_DECISION_OUTPUT_SCHEMA = { type: 'array', items: { type: 'string' } }
1529
1535
  const SMS_BODY_TEMPLATE =
1530
1536
  'Alvera PR notification — payment_account ' +
1531
1537
  '{{ event_dataset.payment_account_external_id }} has been activated. ' +
1532
- 'Manage KYC: {{ connected_app_form_url }}'
1538
+ `Manage KYC: ${ctx.kycPortalUrl}`
1533
1539
 
1534
1540
  const kycWorkflow = await ctx.ensure(
1535
1541
  'KYC notification workflow',
@@ -1571,15 +1577,14 @@ const kycWorkflow = await ctx.ensure(
1571
1577
  position: 0,
1572
1578
  trigger_template: 'now',
1573
1579
  idempotency_template: '{{ event_dataset.payment_account_external_id }}-{{ decision_key }}',
1574
- connected_app_id: connectedAppId,
1575
- connected_app_route: '/portal/kyc',
1576
- connected_app_metadata_template:
1580
+ metadata_template:
1577
1581
  '{"payment_account_external_id":"{{ event_dataset.payment_account_external_id }}"}',
1578
1582
  tool_call: {
1579
1583
  tool_call_type: 'sms_request',
1580
1584
  to: { type: 'custom', body: '+15551234567' },
1581
1585
  body: { type: 'custom', body: SMS_BODY_TEMPLATE },
1582
1586
  sms_type: 'transactional',
1587
+ link_prefix: ctx.kycLinkPrefix,
1583
1588
  },
1584
1589
  },
1585
1590
  ],
@@ -1836,11 +1841,13 @@ if ((kycByStatus.filtered ?? 0) !== 1) {
1836
1841
  }
1837
1842
  ```
1838
1843
 
1839
- ## 035 — read the notification SMS back, and pull its portal token
1844
+ ## 035 — read the notification SMS back, and pull its link token
1840
1845
 
1841
1846
  The passing action fired immediately and persisted a `message` row with
1842
- the fully-rendered body. Extract the `/t/<token>` shortlink the platform
1843
- baked in when it minted the connected-app page token.
1847
+ the body as sent. The portal URL is not in it: in its place stand §026's
1848
+ link prefix and a fifteen-character token. Finding the portal URL itself in
1849
+ the stored body would mean no link was minted, and the holder was sent
1850
+ straight to the portal with nothing to say who tapped.
1844
1851
 
1845
1852
  End-state assertion again, keyed the same way §025 is and for the same
1846
1853
  reason: on a repeat run the SMS was not re-sent, and the row from the first
@@ -1873,50 +1880,88 @@ if (kycMessages.length === 0) {
1873
1880
  )
1874
1881
  }
1875
1882
 
1876
- const withLink = kycMessages
1883
+ const notification = kycMessages
1877
1884
  .map((m) => String(m.body ?? ''))
1878
- .find((body) => body.includes('/t/') && body.includes('Alvera PR notification'))
1879
- if (!withLink) {
1880
- throw new Error(`no rendered body with a /t/ link — bodies: ${JSON.stringify(kycMessages.map((m) => m.body))}`)
1885
+ .find((body) => body.includes('Alvera PR notification'))
1886
+ if (!notification) {
1887
+ throw new Error(`no notification body — bodies: ${JSON.stringify(kycMessages.map((m) => m.body))}`)
1881
1888
  }
1882
- const tokenMatch = withLink.match(/\/t\/([A-Za-z0-9_-]+)/)
1883
- if (!tokenMatch) {
1884
- throw new Error(`no /t/<token> in rendered body: ${withLink}`)
1889
+ if (notification.includes(ctx.kycPortalUrl)) {
1890
+ throw new Error(`the portal URL went out as written, so no link was minted: ${notification}`)
1885
1891
  }
1886
- ctx.kycShortPath = tokenMatch[1]!
1892
+ const kycLinkStart = `${ctx.kycLinkPrefix}/`
1893
+ const kycToken = notification.split(kycLinkStart)[1]?.match(/^[A-Za-z0-9_-]{15}/)?.[0]
1894
+ if (!kycToken) throw new Error(`no ${kycLinkStart}<token> in the notification: ${notification}`)
1895
+ ctx.kycLinkToken = kycToken
1887
1896
  ```
1888
1897
 
1889
- ## 036 — resolve the deep link, and close the loop with tracking
1898
+ ## 036 — the tap, the portal, and the submitted form
1899
+
1900
+ This step plays the host behind the link prefix. A tap on the link reaches
1901
+ it with the token in the path; the host hands that token to
1902
+ `authenticatedLinks.exchange` together with what the tap's request said
1903
+ about itself, and gets back where to send the holder and whom the link was
1904
+ for. Both calls here need only the tenant's publishable key — the host is
1905
+ not a signed-in user.
1890
1906
 
1891
- The `/t/<token>` shortlink resolves through `connectedApps.resolvePage`,
1892
- and the `route_path` must match the action's `connected_app_route`.
1893
- Posting `opened_at` and `form_submitted_at` through
1894
- `updateMessageTracking` then mirrors exactly what the portal frontend does
1895
- when the holder opens it — closing the SMS-to-reply loop end to end.
1907
+ **Forward the tap's own details, not your server's.** The platform judges
1908
+ from them whether a person tapped: a `HEAD`, a prefetch header, an empty
1909
+ user agent or one naming a bot or a link preview is recorded and moves
1910
+ nothing. A person's first tap stamps the link's `link_clicked_at` and the
1911
+ message's `clicked_at`; later taps keep both.
1912
+
1913
+ The answer names the holder §028 resolved as `legal_entity_id`, and carries
1914
+ `metadata_template`'s output on the **message**, as `message.metadata` — the
1915
+ account's external id, which is all the portal needs to open the right
1916
+ account.
1917
+
1918
+ Then the portal reports what the holder did, with the same token. **The
1919
+ message status only moves forward**, and `opened` ranks *below* `clicked`:
1920
+ reporting `opened` after the tap leaves the status at `clicked`, while
1921
+ `opened_at` still lands. `form_submitted` ranks above both, so the message
1922
+ ends there.
1896
1923
 
1897
1924
  ```typescript
1898
- const { data: resolved } = await api.connectedApps.resolvePage(
1899
- tenantSlug, datalakeSlug, ctx.connectedAppSlug,
1900
- { short_path: ctx.kycShortPath, user_agent: 'cookbook-doctest/kyc-notification' },
1901
- )
1902
- if (resolved.route_path !== '/portal/kyc') {
1903
- throw new Error(`resolvePage route_path mismatch: ${resolved.route_path}`)
1925
+ const { data: kycTap } = await api.authenticatedLinks.exchange(tenantSlug, datalakeSlug, {
1926
+ token: ctx.kycLinkToken,
1927
+ method: 'GET',
1928
+ user_agent:
1929
+ 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 ' +
1930
+ '(KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1',
1931
+ headers: { 'accept-language': 'en-US' },
1932
+ })
1933
+ if (kycTap.destination_url !== ctx.kycPortalUrl) {
1934
+ throw new Error(`the link should lead to ${ctx.kycPortalUrl}, got ${kycTap.destination_url}`)
1935
+ }
1936
+ if (!kycTap.legal_entity_id) {
1937
+ throw new Error('the link names no holder — MDM resolution did not reach the message')
1938
+ }
1939
+ if (kycTap.message.metadata?.payment_account_external_id !== ctx.activeExternalId) {
1940
+ throw new Error(`metadata_template did not reach the message: ${JSON.stringify(kycTap.message.metadata)}`)
1904
1941
  }
1905
- if (!(resolved.message?.body ?? '').includes('Alvera PR notification')) {
1906
- throw new Error('resolved page message body missing the notification copy')
1942
+ if (!kycTap.link_clicked_at || !kycTap.message.clicked_at) {
1943
+ throw new Error("a person's tap should stamp link_clicked_at and the message's clicked_at")
1907
1944
  }
1908
1945
 
1909
1946
  const now = new Date().toISOString()
1910
- const { data: tracked } = await api.connectedApps.updateMessageTracking(
1911
- tenantSlug, datalakeSlug, ctx.connectedAppSlug,
1912
- { short_path: ctx.kycShortPath, opened_at: now, form_submitted_at: now },
1913
- )
1914
- if (!tracked.message?.opened_at || !tracked.message?.form_submitted_at) {
1947
+ await api.authenticatedLinks.updateMessageTracking(tenantSlug, datalakeSlug, {
1948
+ token: ctx.kycLinkToken,
1949
+ status: 'opened',
1950
+ opened_at: now,
1951
+ })
1952
+ const { data: tracked } = await api.authenticatedLinks.updateMessageTracking(tenantSlug, datalakeSlug, {
1953
+ token: ctx.kycLinkToken,
1954
+ status: 'form_submitted',
1955
+ form_submitted_at: now,
1956
+ })
1957
+ if (!tracked.message.opened_at || !tracked.message.form_submitted_at) {
1915
1958
  throw new Error('message tracking did not persist opened_at + form_submitted_at')
1916
1959
  }
1960
+ if (tracked.message.status !== 'form_submitted') {
1961
+ throw new Error(`the message should end at form_submitted, got ${tracked.message.status}`)
1962
+ }
1917
1963
  ```
1918
1964
 
1919
-
1920
1965
  ## 037 — read one row back through all three lakes
1921
1966
 
1922
1967
  This is what §007 was for. The same row, the same SQL, three modes — and
@@ -47,7 +47,7 @@ needs to see that a message came in without seeing whose it was.
47
47
  | SMS tool, LLM tool, vision tool | One dispatcher, two models |
48
48
  | Two AI agents | Triage, and document extraction |
49
49
  | Two workflows | Review request, and category fan-out |
50
- | Connected app | The review form the SMS links to |
50
+ | Authenticated link | The review form the SMS links to |
51
51
 
52
52
  # Walkthrough
53
53
 
@@ -499,7 +499,7 @@ ctx.deployGenericTable = async (tableName: string, timeoutMs = 180_000): Promise
499
499
  }
500
500
  ```
501
501
 
502
- ## 009 — the SMS tool and the review connected app
502
+ ## 009 — the SMS tool and the review page
503
503
 
504
504
  One SMS tool dispatches every outbound message in this walk. **The
505
505
  per-message body lives on the workflow action, never on the tool**, which is
@@ -507,10 +507,15 @@ what lets one tool serve both the review request and the triage fan-out.
507
507
  Local dev points at LocalStack's SNS on `:4566`, so nothing leaves the
508
508
  machine.
509
509
 
510
- The connected app is the review form the SMS links to — a thin registration
511
- of the page's URL and hosting mode, with the page itself living outside the
512
- platform. Capture the server-derived slug; resolving the link in §019 is
513
- addressed by it.
510
+ The review form the SMS links to lives outside the platform, and it is two
511
+ addresses, neither registered with the platform. The **page** is the URL
512
+ written into the SMS body, exactly as the patient should end up seeing it.
513
+ The **link prefix** is the host you run in front of it: at send the platform
514
+ swaps every URL in the body for `<link_prefix>/<token>`, a link minted for
515
+ this one message, and a tap reaches that host first. The host asks the
516
+ platform what the link is, then sends the patient on to the form — §019
517
+ does exactly that. A path is allowed in the prefix; a query, a fragment or a
518
+ trailing slash is not, because the platform appends `/<token>` itself.
514
519
 
515
520
  ```typescript
516
521
  const smsTool = await ctx.ensure(
@@ -542,25 +547,8 @@ const smsTool = await ctx.ensure(
542
547
  toolId = smsTool.id!
543
548
  ctx.smsToolId = smsTool.id!
544
549
 
545
- const reviewApp = await ctx.ensure(
546
- 'review connected app',
547
- async () => {
548
- const { data } = await api.connectedApps.list(tenantSlug, datalakeSlug, { page_size: 100 })
549
- return ctx.firstNamed(data.data, 'Cookbook Visit Review Form')
550
- },
551
- async () => {
552
- const { data } = await api.connectedApps.create(tenantSlug, datalakeSlug, {
553
- name: 'Cookbook Visit Review Form',
554
- description: 'Post-visit review form linked from the outbound review SMS.',
555
- mode: 'self_hosted',
556
- urls: [{ url: 'https://review.example.local', is_primary: true, label: 'production' }],
557
- })
558
- return data
559
- },
560
- )
561
- connectedAppId = reviewApp.id!
562
- ctx.reviewAppId = reviewApp.id!
563
- ctx.reviewAppSlug = reviewApp.slug!
550
+ ctx.reviewFormUrl = 'https://review.example.local/review/visit'
551
+ ctx.reviewLinkPrefix = 'https://go.review.example.local/l'
564
552
  ```
565
553
 
566
554
  ## 010 — the appointments table, and the one column that exercises everything
@@ -902,7 +890,9 @@ The **row count** catches an ingest that reported green over nothing. The
902
890
  **status values** catch a contract whose mapping silently fell through to the
903
891
  `pending` default — which would leave the filter in §018 with nothing to
904
892
  route and make the whole scenario a false green. The **stamp** catches a
905
- resolve that never ran, which would leave every link minted against nobody.
893
+ resolve that never ran, which would leave every link naming nobody — so the
894
+ fulfilled visit's stamp is kept, and §019 checks the review link names that
895
+ same patient.
906
896
 
907
897
  And **`patient_mobile` must not be null**, which is the one that catches a
908
898
  misspelled vendor key. A template reading a field the payload does not carry
@@ -954,6 +944,7 @@ for (const [id, row] of byId) {
954
944
  )
955
945
  }
956
946
  }
947
+ ctx.fulfilledPatientId = String(byId.get(ctx.fulfilledId)!.stamp)
957
948
  ```
958
949
 
959
950
  ## 016 — a populated masked array survives the copy
@@ -1064,10 +1055,15 @@ Standard shape — filter, decision, one action — targeting §010's table.
1064
1055
 
1065
1056
  `skip_mdm_resolution: false` is set explicitly, and must be. A generic-table
1066
1057
  row is never its own subject; the subject is the patient §012's pair stamped
1067
- onto it. With resolution on, the platform loads that subject and mints a page
1068
- token against it, which is what makes `{{ connected_app_form_url }}` *this
1069
- patient's* review link. Skip it and the token is never minted and the link
1070
- renders empty.
1058
+ onto it. With resolution on, the platform loads that subject, and the message
1059
+ — and the link minted with it — names *this patient* as its
1060
+ `legal_entity_id`. Skip it and the link still resolves, but it cannot say
1061
+ whose review it is.
1062
+
1063
+ The body carries §009's form URL itself; the action's `link_prefix` is what
1064
+ turns it into this message's own link at send, and it is required on every
1065
+ SMS call. `metadata_template` renders once per send and rides on the
1066
+ message, so the host gets the appointment back with the link.
1071
1067
 
1072
1068
  The filter is one comparison — `status == "fulfilled"` — and it carries the
1073
1069
  whole clinical rule this scenario exists for: never ask someone to review a
@@ -1120,9 +1116,7 @@ const reviewWorkflow = await ctx.ensure(
1120
1116
  position: 0,
1121
1117
  trigger_template: 'now',
1122
1118
  idempotency_template: `{{ event_dataset.appointment_id }}-${REVIEW_KEY}`,
1123
- connected_app_id: ctx.reviewAppId,
1124
- connected_app_route: '/review/visit',
1125
- connected_app_metadata_template: '{"appointment_id":"{{ event_dataset.appointment_id }}"}',
1119
+ metadata_template: '{"appointment_id":"{{ event_dataset.appointment_id }}"}',
1126
1120
  tool_call: {
1127
1121
  tool_call_type: 'sms_request',
1128
1122
  to: { type: 'custom', body: '{{ event_dataset.patient_mobile }}' },
@@ -1131,9 +1125,10 @@ const reviewWorkflow = await ctx.ensure(
1131
1125
  body:
1132
1126
  'Hi {{ event_dataset.patient_name }}, thanks for visiting ' +
1133
1127
  '{{ event_dataset.department }}. How did we do? ' +
1134
- '{{ connected_app_form_url }}',
1128
+ ctx.reviewFormUrl,
1135
1129
  },
1136
1130
  sms_type: 'transactional',
1131
+ link_prefix: ctx.reviewLinkPrefix,
1137
1132
  },
1138
1133
  },
1139
1134
  ],
@@ -1220,9 +1215,22 @@ Search on the **idempotency key**, never on `workflow_id`. The key is
1220
1215
  names the same message forever. A workflow id is stable only as long as the
1221
1216
  workflow resource is.
1222
1217
 
1223
- Then resolve the `/t/<token>` the platform baked into the body when it minted
1224
- the page token, and post the tracking a connected-app frontend would post
1225
- when the patient opens the form.
1218
+ The stored body is the body as sent, so the form URL is not in it: in its
1219
+ place stand the link prefix and a fifteen-character token. Finding the form
1220
+ URL itself would mean no link was minted.
1221
+
1222
+ Then play the host behind the prefix. Hand the token to
1223
+ `authenticatedLinks.exchange` with what the tap's request said about itself
1224
+ — a phone's browser, because the platform judges from those details whether
1225
+ a person tapped, and only a person's tap stamps `link_clicked_at` and the
1226
+ message's `clicked_at`. The answer names the patient and carries the
1227
+ appointment on `message.metadata`. Both calls need only the tenant's
1228
+ publishable key.
1229
+
1230
+ Then the form reports what the patient did, with the same token. The message
1231
+ status only moves forward, and `opened` ranks below `clicked`, so reporting
1232
+ `opened` after the tap leaves the status at `clicked` while `opened_at`
1233
+ still lands; `form_submitted` ranks above both, so the message ends there.
1226
1234
 
1227
1235
  ```typescript
1228
1236
  const { data: search } = await api.datasets.createUserSearch(tenantSlug, datalakeSlug, 'message', {
@@ -1241,32 +1249,57 @@ while (Date.now() < msgDeadline && !reviewBody) {
1241
1249
  })
1242
1250
  reviewBody = ((data.data ?? []) as Array<Record<string, unknown>>)
1243
1251
  .map((m) => String(m.body ?? ''))
1244
- .find((b) => b.includes('/t/') && b.includes('How did we do?'))
1252
+ .find((b) => b.includes('How did we do?'))
1245
1253
  if (!reviewBody) await new Promise((r) => setTimeout(r, 2_000))
1246
1254
  }
1247
1255
  if (!reviewBody) {
1248
1256
  throw new Error(`no rendered review SMS for ${ctx.fulfilledId} within 90s`)
1249
1257
  }
1258
+ if (reviewBody.includes(ctx.reviewFormUrl)) {
1259
+ throw new Error(`the form URL went out as written, so no link was minted: ${reviewBody}`)
1260
+ }
1261
+ const reviewLinkStart = `${ctx.reviewLinkPrefix}/`
1262
+ const reviewToken = reviewBody.split(reviewLinkStart)[1]?.match(/^[A-Za-z0-9_-]{15}/)?.[0]
1263
+ if (!reviewToken) throw new Error(`no ${reviewLinkStart}<token> in the review body: ${reviewBody}`)
1250
1264
 
1251
- const token = reviewBody.match(/\/t\/([A-Za-z0-9_-]+)/)
1252
- if (!token) throw new Error(`no /t/<token> in the review body: ${reviewBody}`)
1253
-
1254
- const { data: resolved } = await api.connectedApps.resolvePage(
1255
- tenantSlug, datalakeSlug, ctx.reviewAppSlug,
1256
- { short_path: token[1]!, user_agent: 'cookbook-doctest/primary-care' },
1257
- )
1258
- if (resolved.route_path !== '/review/visit') {
1259
- throw new Error(`the review link resolved to the wrong route: ${resolved.route_path}`)
1265
+ const { data: tap } = await api.authenticatedLinks.exchange(tenantSlug, datalakeSlug, {
1266
+ token: reviewToken,
1267
+ method: 'GET',
1268
+ user_agent:
1269
+ 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 ' +
1270
+ '(KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1',
1271
+ headers: { 'accept-language': 'en-US' },
1272
+ })
1273
+ if (tap.destination_url !== ctx.reviewFormUrl) {
1274
+ throw new Error(`the review link should lead to ${ctx.reviewFormUrl}, got ${tap.destination_url}`)
1275
+ }
1276
+ if (tap.legal_entity_id !== ctx.fulfilledPatientId) {
1277
+ throw new Error(`the review link should name patient ${ctx.fulfilledPatientId}, got ${tap.legal_entity_id}`)
1278
+ }
1279
+ if (tap.message.metadata?.appointment_id !== ctx.fulfilledId) {
1280
+ throw new Error(`metadata_template did not reach the message: ${JSON.stringify(tap.message.metadata)}`)
1281
+ }
1282
+ if (!tap.link_clicked_at || !tap.message.clicked_at) {
1283
+ throw new Error("a person's tap should stamp link_clicked_at and the message's clicked_at")
1260
1284
  }
1261
1285
 
1262
1286
  const now = new Date().toISOString()
1263
- const { data: tracked } = await api.connectedApps.updateMessageTracking(
1264
- tenantSlug, datalakeSlug, ctx.reviewAppSlug,
1265
- { short_path: token[1]!, opened_at: now, form_submitted_at: now },
1266
- )
1267
- if (!tracked.message?.opened_at || !tracked.message?.form_submitted_at) {
1287
+ await api.authenticatedLinks.updateMessageTracking(tenantSlug, datalakeSlug, {
1288
+ token: reviewToken,
1289
+ status: 'opened',
1290
+ opened_at: now,
1291
+ })
1292
+ const { data: tracked } = await api.authenticatedLinks.updateMessageTracking(tenantSlug, datalakeSlug, {
1293
+ token: reviewToken,
1294
+ status: 'form_submitted',
1295
+ form_submitted_at: now,
1296
+ })
1297
+ if (!tracked.message.opened_at || !tracked.message.form_submitted_at) {
1268
1298
  throw new Error('message tracking did not persist opened_at + form_submitted_at')
1269
1299
  }
1300
+ if (tracked.message.status !== 'form_submitted') {
1301
+ throw new Error(`the message should end at form_submitted, got ${tracked.message.status}`)
1302
+ }
1270
1303
  ```
1271
1304
 
1272
1305
  ## 020 — the Contact Us table, and its client you did not create
@@ -1469,6 +1502,11 @@ query, and obvious solicitation — so the classification is not a coin toss
1469
1502
  and the assertion in §023 means something. `category` is left unset on
1470
1503
  ingest; assigning it is the agent's job.
1471
1504
 
1505
+ `link_prefix` is required on every SMS call, these routing alerts
1506
+ included, though their bodies carry no URL — a body with none is sent
1507
+ unchanged. Leave it off and the workflow is refused at create with a 422 on
1508
+ `/actions/<n>/tool_call/link_prefix`.
1509
+
1472
1510
  ```typescript
1473
1511
  const CATEGORIES = ['clinical', 'billing', 'appointment', 'spam'] as const
1474
1512
 
@@ -1508,6 +1546,7 @@ const triageWorkflow = await ctx.ensure(
1508
1546
  body: `[${category}] contact-us submission {{ event_dataset.submission_id }} needs routing.`,
1509
1547
  },
1510
1548
  sms_type: 'transactional' as const,
1549
+ link_prefix: 'https://go.triage.example.local/l',
1511
1550
  },
1512
1551
  })),
1513
1552
  workflow_ai_agents: [