@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
|
@@ -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
|
-
|
|
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,
|
|
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
|
-
|
|
1426
|
-
|
|
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
|
-
|
|
1432
|
-
|
|
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
|
-
|
|
1508
|
-
|
|
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
|
|
1512
|
-
the
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
1843
|
-
|
|
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
|
|
1883
|
+
const notification = kycMessages
|
|
1877
1884
|
.map((m) => String(m.body ?? ''))
|
|
1878
|
-
.find((body) => body.includes('
|
|
1879
|
-
if (!
|
|
1880
|
-
throw new Error(`no
|
|
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
|
-
|
|
1883
|
-
|
|
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
|
-
|
|
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 —
|
|
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
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
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:
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
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 (!
|
|
1906
|
-
throw new Error('
|
|
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
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
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
|
-
|
|
|
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
|
|
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
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
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
|
-
|
|
546
|
-
|
|
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
|
|
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
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1224
|
-
the
|
|
1225
|
-
|
|
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('
|
|
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
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
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
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
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: [
|