@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
|
@@ -54,7 +54,7 @@ shows.
|
|
|
54
54
|
| SMS tool, LLM tool | Shared by every workflow below |
|
|
55
55
|
| Contract pair, on two clients | Identity and table row, deliberately split|
|
|
56
56
|
| Three workflows | Welcome, dunning, priority triage |
|
|
57
|
-
|
|
|
57
|
+
| Authenticated links | The portal link, and the pay link |
|
|
58
58
|
| Manual-upload, bulk and REST paths| Three ways into the same table |
|
|
59
59
|
| An invitation | A second person on the tenant |
|
|
60
60
|
|
|
@@ -897,38 +897,30 @@ if (unstamped.length > 0) {
|
|
|
897
897
|
}
|
|
898
898
|
```
|
|
899
899
|
|
|
900
|
-
## 015 — the billing
|
|
900
|
+
## 015 — the billing portal, and the host in front of it
|
|
901
901
|
|
|
902
|
-
The welcome SMS carries a
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
902
|
+
The welcome SMS carries a link to the self-serve billing portal, which lives
|
|
903
|
+
outside the platform. Two addresses are in play, and neither is registered
|
|
904
|
+
with the platform — there is nothing to create here.
|
|
905
|
+
|
|
906
|
+
The **portal page** is the URL written into the SMS body, exactly as the
|
|
907
|
+
customer should end up seeing it. The **link prefix** is the host you run in
|
|
908
|
+
front of it: at send the platform swaps every URL in the body for
|
|
909
|
+
`<link_prefix>/<token>`, a link minted for this one message, and a tap on it
|
|
910
|
+
reaches that host first. The host asks the platform what the link is, then
|
|
911
|
+
sends the customer on to the portal — §019 does exactly that.
|
|
912
|
+
|
|
913
|
+
A path is allowed in the prefix; a query, a fragment or a trailing slash is
|
|
914
|
+
not, because the platform appends `/<token>` itself.
|
|
906
915
|
|
|
907
916
|
```typescript
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
async () => {
|
|
911
|
-
const { data } = await api.connectedApps.list(tenantSlug, datalakeSlug, { page_size: 100 })
|
|
912
|
-
return ctx.firstNamed(data.data, 'Cookbook Billing Portal')
|
|
913
|
-
},
|
|
914
|
-
async () => {
|
|
915
|
-
const { data } = await api.connectedApps.create(tenantSlug, datalakeSlug, {
|
|
916
|
-
name: 'Cookbook Billing Portal',
|
|
917
|
-
description: 'Self-serve billing portal linked from the outbound welcome SMS.',
|
|
918
|
-
mode: 'self_hosted',
|
|
919
|
-
urls: [{ url: 'https://billing.example.local', is_primary: true, label: 'production' }],
|
|
920
|
-
})
|
|
921
|
-
return data
|
|
922
|
-
},
|
|
923
|
-
)
|
|
924
|
-
connectedAppId = portalApp.id!
|
|
925
|
-
ctx.portalAppId = portalApp.id!
|
|
926
|
-
ctx.portalAppSlug = portalApp.slug!
|
|
917
|
+
ctx.billingPortalUrl = 'https://billing.example.local/portal/welcome'
|
|
918
|
+
ctx.billingLinkPrefix = 'https://go.billing.example.local/l'
|
|
927
919
|
```
|
|
928
920
|
|
|
929
921
|
## 016 — the Welcome SMS workflow
|
|
930
922
|
|
|
931
|
-
Standard shape — a filter, a decision, one action — with
|
|
923
|
+
Standard shape — a filter, a decision, one action — with four things worth
|
|
932
924
|
reading closely.
|
|
933
925
|
|
|
934
926
|
`dataset_type: 'generic_table'` with `generic_table_id` makes §009's table
|
|
@@ -937,10 +929,15 @@ filter reads `event_dataset.phone` directly.
|
|
|
937
929
|
|
|
938
930
|
`skip_mdm_resolution: false` is set explicitly, and must be. A generic-table
|
|
939
931
|
row is never its own subject — the subject is the legal entity §011's pair
|
|
940
|
-
stamped onto it. With resolution on, the platform loads that subject and
|
|
941
|
-
|
|
942
|
-
`
|
|
943
|
-
|
|
932
|
+
stamped onto it. With resolution on, the platform loads that subject, and
|
|
933
|
+
the message — and the link minted with it — names *this customer* as its
|
|
934
|
+
`legal_entity_id`. Skip it and the link still resolves, but it cannot say
|
|
935
|
+
whose portal session it opens.
|
|
936
|
+
|
|
937
|
+
The body carries §015's portal URL itself, and the action's `link_prefix`
|
|
938
|
+
turns it into this message's own link at send — `link_prefix` is required
|
|
939
|
+
on every SMS call. `metadata_template` renders once per send and rides on
|
|
940
|
+
the message, so the host gets the customer number back with the link.
|
|
944
941
|
|
|
945
942
|
The `context_datasets` entry queries the `message` dataset for a welcome
|
|
946
943
|
already sent to this legal entity in the last six months — the platform's
|
|
@@ -997,9 +994,7 @@ const welcomeWorkflow = await ctx.ensure(
|
|
|
997
994
|
position: 0,
|
|
998
995
|
trigger_template: 'now',
|
|
999
996
|
idempotency_template: `{{ event_dataset.customer_number }}-${WELCOME_KEY}`,
|
|
1000
|
-
|
|
1001
|
-
connected_app_route: '/portal/welcome',
|
|
1002
|
-
connected_app_metadata_template: '{"customer_number":"{{ event_dataset.customer_number }}"}',
|
|
997
|
+
metadata_template: '{"customer_number":"{{ event_dataset.customer_number }}"}',
|
|
1003
998
|
tool_call: {
|
|
1004
999
|
tool_call_type: 'sms_request',
|
|
1005
1000
|
to: { type: 'custom', body: '{{ event_dataset.phone }}' },
|
|
@@ -1007,9 +1002,10 @@ const welcomeWorkflow = await ctx.ensure(
|
|
|
1007
1002
|
type: 'custom',
|
|
1008
1003
|
body:
|
|
1009
1004
|
'Hi {{ event_dataset.name }}, welcome to Alvera Billing. ' +
|
|
1010
|
-
|
|
1005
|
+
`Self-serve billing portal: ${ctx.billingPortalUrl}`,
|
|
1011
1006
|
},
|
|
1012
1007
|
sms_type: 'transactional',
|
|
1008
|
+
link_prefix: ctx.billingLinkPrefix,
|
|
1013
1009
|
},
|
|
1014
1010
|
},
|
|
1015
1011
|
],
|
|
@@ -1130,11 +1126,24 @@ workflow resource is: recreate it against a lake that still holds its rows
|
|
|
1130
1126
|
and the id changes while the key does not — the action then correctly refuses
|
|
1131
1127
|
to fire, and a search for the new id finds nothing at all.
|
|
1132
1128
|
|
|
1133
|
-
|
|
1134
|
-
the
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1129
|
+
The stored body is the body as sent, so the portal URL is not in it: in its
|
|
1130
|
+
place stand the link prefix and a fifteen-character token. Finding the portal
|
|
1131
|
+
URL itself would mean no link was minted.
|
|
1132
|
+
|
|
1133
|
+
Then play the host behind the prefix. Hand the token to
|
|
1134
|
+
`authenticatedLinks.exchange` with what the tap's request said about itself —
|
|
1135
|
+
a phone's browser, because the platform judges from those details whether a
|
|
1136
|
+
person tapped, and only a person's tap stamps `link_clicked_at` and the
|
|
1137
|
+
message's `clicked_at`. The answer names the customer and carries the
|
|
1138
|
+
customer number on `message.metadata`. Both calls need only the tenant's
|
|
1139
|
+
publishable key.
|
|
1140
|
+
|
|
1141
|
+
Then the portal reports what the customer did, with the same token. The
|
|
1142
|
+
message status only moves forward, and `opened` ranks below `clicked`, so
|
|
1143
|
+
reporting `opened` after the tap leaves the status at `clicked` while
|
|
1144
|
+
`opened_at` still lands; `form_submitted` ranks above both, so the message
|
|
1145
|
+
ends there. That closes the loop: the message went out, the link in it leads
|
|
1146
|
+
to this customer's page, and the open and the submission were recorded.
|
|
1138
1147
|
|
|
1139
1148
|
```typescript
|
|
1140
1149
|
const { data: search } = await api.datasets.createUserSearch(tenantSlug, datalakeSlug, 'message', {
|
|
@@ -1153,7 +1162,7 @@ while (Date.now() < msgDeadline && !welcomeBody) {
|
|
|
1153
1162
|
})
|
|
1154
1163
|
welcomeBody = ((data.data ?? []) as Array<Record<string, unknown>>)
|
|
1155
1164
|
.map((m) => String(m.body ?? ''))
|
|
1156
|
-
.find((body) => body.includes('
|
|
1165
|
+
.find((body) => body.includes('welcome to Alvera Billing'))
|
|
1157
1166
|
if (!welcomeBody) await new Promise((r) => setTimeout(r, 2_000))
|
|
1158
1167
|
}
|
|
1159
1168
|
if (!welcomeBody) {
|
|
@@ -1162,60 +1171,67 @@ if (!welcomeBody) {
|
|
|
1162
1171
|
'the action never fired, or it fired under a different idempotency key',
|
|
1163
1172
|
)
|
|
1164
1173
|
}
|
|
1165
|
-
|
|
1166
|
-
|
|
1174
|
+
if (welcomeBody.includes(ctx.billingPortalUrl)) {
|
|
1175
|
+
throw new Error(`the portal URL went out as written, so no link was minted: ${welcomeBody}`)
|
|
1176
|
+
}
|
|
1177
|
+
const welcomeLinkStart = `${ctx.billingLinkPrefix}/`
|
|
1178
|
+
const welcomeToken = welcomeBody.split(welcomeLinkStart)[1]?.match(/^[A-Za-z0-9_-]{15}/)?.[0]
|
|
1179
|
+
if (!welcomeToken) throw new Error(`no ${welcomeLinkStart}<token> in the welcome body: ${welcomeBody}`)
|
|
1167
1180
|
|
|
1168
|
-
const { data:
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1181
|
+
const { data: tap } = await api.authenticatedLinks.exchange(tenantSlug, datalakeSlug, {
|
|
1182
|
+
token: welcomeToken,
|
|
1183
|
+
method: 'GET',
|
|
1184
|
+
user_agent:
|
|
1185
|
+
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 ' +
|
|
1186
|
+
'(KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1',
|
|
1187
|
+
headers: { 'accept-language': 'en-US' },
|
|
1188
|
+
})
|
|
1189
|
+
if (tap.destination_url !== ctx.billingPortalUrl) {
|
|
1190
|
+
throw new Error(`the welcome link should lead to ${ctx.billingPortalUrl}, got ${tap.destination_url}`)
|
|
1191
|
+
}
|
|
1192
|
+
if (!tap.legal_entity_id) {
|
|
1193
|
+
throw new Error('the welcome link names no customer — MDM resolution did not reach the message')
|
|
1174
1194
|
}
|
|
1175
|
-
if (
|
|
1176
|
-
throw new Error(
|
|
1195
|
+
if (tap.message.metadata?.customer_number !== ctx.reachNumber) {
|
|
1196
|
+
throw new Error(`metadata_template did not reach the message: ${JSON.stringify(tap.message.metadata)}`)
|
|
1177
1197
|
}
|
|
1198
|
+
if (!tap.link_clicked_at || !tap.message.clicked_at) {
|
|
1199
|
+
throw new Error("a person's tap should stamp link_clicked_at and the message's clicked_at")
|
|
1200
|
+
}
|
|
1201
|
+
ctx.reachCustomerId = tap.legal_entity_id
|
|
1178
1202
|
|
|
1179
1203
|
const now = new Date().toISOString()
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1204
|
+
await api.authenticatedLinks.updateMessageTracking(tenantSlug, datalakeSlug, {
|
|
1205
|
+
token: welcomeToken,
|
|
1206
|
+
status: 'opened',
|
|
1207
|
+
opened_at: now,
|
|
1208
|
+
})
|
|
1209
|
+
const { data: tracked } = await api.authenticatedLinks.updateMessageTracking(tenantSlug, datalakeSlug, {
|
|
1210
|
+
token: welcomeToken,
|
|
1211
|
+
status: 'form_submitted',
|
|
1212
|
+
form_submitted_at: now,
|
|
1213
|
+
})
|
|
1214
|
+
if (!tracked.message.opened_at || !tracked.message.form_submitted_at) {
|
|
1185
1215
|
throw new Error('message tracking did not persist opened_at + form_submitted_at')
|
|
1186
1216
|
}
|
|
1217
|
+
if (tracked.message.status !== 'form_submitted') {
|
|
1218
|
+
throw new Error(`the message should end at form_submitted, got ${tracked.message.status}`)
|
|
1219
|
+
}
|
|
1187
1220
|
```
|
|
1188
1221
|
|
|
1189
|
-
## 020 — the
|
|
1222
|
+
## 020 — the payment page, behind the same host
|
|
1190
1223
|
|
|
1191
1224
|
The dunning SMS carries a different link to a different page: not the
|
|
1192
|
-
welcome portal but the one where an overdue invoice gets paid.
|
|
1193
|
-
connected app, registered the same way.
|
|
1225
|
+
welcome portal but the one where an overdue invoice gets paid.
|
|
1194
1226
|
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1227
|
+
**One host answers both.** The link prefix is only where taps arrive; where a
|
|
1228
|
+
link leads is fixed at send, from the URL written into its own message, and
|
|
1229
|
+
comes back from the exchange. So the payment page reuses §015's prefix, and a
|
|
1230
|
+
link minted for the payment page can never be made to open the welcome page
|
|
1231
|
+
— there is no route on the link for anyone to change.
|
|
1199
1232
|
|
|
1200
1233
|
```typescript
|
|
1201
|
-
|
|
1202
|
-
'pay-portal connected app',
|
|
1203
|
-
async () => {
|
|
1204
|
-
const { data } = await api.connectedApps.list(tenantSlug, datalakeSlug, { page_size: 100 })
|
|
1205
|
-
return ctx.firstNamed(data.data, 'Cookbook Payment Portal')
|
|
1206
|
-
},
|
|
1207
|
-
async () => {
|
|
1208
|
-
const { data } = await api.connectedApps.create(tenantSlug, datalakeSlug, {
|
|
1209
|
-
name: 'Cookbook Payment Portal',
|
|
1210
|
-
description: 'Self-serve payment page linked from the outbound dunning SMS.',
|
|
1211
|
-
mode: 'self_hosted',
|
|
1212
|
-
urls: [{ url: 'https://pay.example.local', is_primary: true, label: 'production' }],
|
|
1213
|
-
})
|
|
1214
|
-
return data
|
|
1215
|
-
},
|
|
1216
|
-
)
|
|
1217
|
-
ctx.payAppId = payApp.id!
|
|
1218
|
-
ctx.payAppSlug = payApp.slug!
|
|
1234
|
+
ctx.paymentPageUrl = 'https://pay.example.local/portal/pay'
|
|
1219
1235
|
```
|
|
1220
1236
|
|
|
1221
1237
|
## 021 — the Dunning SMS workflow: two gates, not one
|
|
@@ -1282,9 +1298,7 @@ const dunningWorkflow = await ctx.ensure(
|
|
|
1282
1298
|
position: 0,
|
|
1283
1299
|
trigger_template: 'now',
|
|
1284
1300
|
idempotency_template: `{{ event_dataset.customer_number }}-${DUNNING_KEY}`,
|
|
1285
|
-
|
|
1286
|
-
connected_app_route: '/portal/pay',
|
|
1287
|
-
connected_app_metadata_template: '{"customer_number":"{{ event_dataset.customer_number }}"}',
|
|
1301
|
+
metadata_template: '{"customer_number":"{{ event_dataset.customer_number }}"}',
|
|
1288
1302
|
tool_call: {
|
|
1289
1303
|
tool_call_type: 'sms_request',
|
|
1290
1304
|
to: { type: 'custom', body: '{{ event_dataset.phone }}' },
|
|
@@ -1293,9 +1307,10 @@ const dunningWorkflow = await ctx.ensure(
|
|
|
1293
1307
|
body:
|
|
1294
1308
|
'Hi {{ event_dataset.name }}, an invoice on account ' +
|
|
1295
1309
|
'{{ event_dataset.customer_number }} needs your attention. ' +
|
|
1296
|
-
|
|
1310
|
+
`Pay now: ${ctx.paymentPageUrl}`,
|
|
1297
1311
|
},
|
|
1298
1312
|
sms_type: 'transactional',
|
|
1313
|
+
link_prefix: ctx.billingLinkPrefix,
|
|
1299
1314
|
},
|
|
1300
1315
|
},
|
|
1301
1316
|
],
|
|
@@ -1372,12 +1387,12 @@ if ((byStatus.filtered ?? 0) !== 1) {
|
|
|
1372
1387
|
}
|
|
1373
1388
|
```
|
|
1374
1389
|
|
|
1375
|
-
## 023 — the reminder went out, and it is a different message
|
|
1390
|
+
## 023 — the reminder went out, and it is a different message to a different page
|
|
1376
1391
|
|
|
1377
1392
|
Same read-back idiom as §019, and worth doing twice for one reason: it proves
|
|
1378
1393
|
the two workflows are genuinely independent. The same customer received two
|
|
1379
|
-
messages, under two idempotency keys, carrying two links
|
|
1380
|
-
two
|
|
1394
|
+
messages, under two idempotency keys, carrying two links with two different
|
|
1395
|
+
tokens that lead to two different pages — and both name the same customer.
|
|
1381
1396
|
|
|
1382
1397
|
The key here is `<customer number>-send_dunning_sms`, and it names a row that
|
|
1383
1398
|
persists in the lake long after the run that made it. That is what a
|
|
@@ -1400,7 +1415,7 @@ while (Date.now() < msgDeadline && !dunningBody) {
|
|
|
1400
1415
|
})
|
|
1401
1416
|
dunningBody = ((data.data ?? []) as Array<Record<string, unknown>>)
|
|
1402
1417
|
.map((m) => String(m.body ?? ''))
|
|
1403
|
-
.find((body) => body.includes('
|
|
1418
|
+
.find((body) => body.includes('needs your attention'))
|
|
1404
1419
|
if (!dunningBody) await new Promise((r) => setTimeout(r, 2_000))
|
|
1405
1420
|
}
|
|
1406
1421
|
if (!dunningBody) {
|
|
@@ -1410,15 +1425,25 @@ if (!dunningBody.includes(ctx.reachNumber)) {
|
|
|
1410
1425
|
throw new Error(`the dunning body does not name the overdue account: ${dunningBody}`)
|
|
1411
1426
|
}
|
|
1412
1427
|
|
|
1413
|
-
|
|
1414
|
-
|
|
1428
|
+
if (dunningBody.includes(ctx.paymentPageUrl)) {
|
|
1429
|
+
throw new Error(`the payment page went out as written, so no link was minted: ${dunningBody}`)
|
|
1430
|
+
}
|
|
1431
|
+
const dunningToken = dunningBody.split(`${ctx.billingLinkPrefix}/`)[1]?.match(/^[A-Za-z0-9_-]{15}/)?.[0]
|
|
1432
|
+
if (!dunningToken) throw new Error(`no ${ctx.billingLinkPrefix}/<token> in the dunning body: ${dunningBody}`)
|
|
1415
1433
|
|
|
1416
|
-
const { data:
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1434
|
+
const { data: tap } = await api.authenticatedLinks.exchange(tenantSlug, datalakeSlug, {
|
|
1435
|
+
token: dunningToken,
|
|
1436
|
+
method: 'GET',
|
|
1437
|
+
user_agent:
|
|
1438
|
+
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 ' +
|
|
1439
|
+
'(KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1',
|
|
1440
|
+
headers: { 'accept-language': 'en-US' },
|
|
1441
|
+
})
|
|
1442
|
+
if (tap.destination_url !== ctx.paymentPageUrl) {
|
|
1443
|
+
throw new Error(`the dunning link should lead to ${ctx.paymentPageUrl}, got ${tap.destination_url}`)
|
|
1444
|
+
}
|
|
1445
|
+
if (tap.legal_entity_id !== ctx.reachCustomerId) {
|
|
1446
|
+
throw new Error(`both links should name the same customer (${ctx.reachCustomerId}), got ${tap.legal_entity_id}`)
|
|
1422
1447
|
}
|
|
1423
1448
|
```
|
|
1424
1449
|
|
|
@@ -1596,6 +1621,11 @@ band. Whichever band comes back picks one and leaves the other two
|
|
|
1596
1621
|
hyphens, and Liquid's dot parser would read
|
|
1597
1622
|
`additional_context.cookbook-priority-triage-agent` as a subtraction.
|
|
1598
1623
|
|
|
1624
|
+
`link_prefix` is required on every SMS call, these three included, though
|
|
1625
|
+
their bodies carry no URL — a body with none is sent unchanged. Leave it off
|
|
1626
|
+
and the workflow is refused at create with a 422 on
|
|
1627
|
+
`/actions/<n>/tool_call/link_prefix`.
|
|
1628
|
+
|
|
1599
1629
|
```typescript
|
|
1600
1630
|
const BANDS = ['priority_high', 'priority_medium', 'priority_low'] as const
|
|
1601
1631
|
|
|
@@ -1646,6 +1676,7 @@ const triageWorkflow = await ctx.ensure(
|
|
|
1646
1676
|
body: `[${band}] {{ event_dataset.name }}, a note about account {{ event_dataset.customer_number }}.`,
|
|
1647
1677
|
},
|
|
1648
1678
|
sms_type: 'transactional' as const,
|
|
1679
|
+
link_prefix: ctx.billingLinkPrefix,
|
|
1649
1680
|
},
|
|
1650
1681
|
})),
|
|
1651
1682
|
workflow_ai_agents: [
|
|
@@ -2369,8 +2400,8 @@ After all forty-one steps run green, on one tenant and **one raw datalake**:
|
|
|
2369
2400
|
reachability *and* KYC, and the triage workflow hands the question to a
|
|
2370
2401
|
model and fans out on its answer.
|
|
2371
2402
|
- **Three outbound messages** are readable from the `message` dataset, each
|
|
2372
|
-
under its own idempotency key, two carrying
|
|
2373
|
-
two
|
|
2403
|
+
under its own idempotency key, two carrying authenticated links — one host,
|
|
2404
|
+
two tokens, two pages, one customer named by both.
|
|
2374
2405
|
- **A second person** is on the tenant with a `member` role, invited and
|
|
2375
2406
|
accepted entirely through the SDK.
|
|
2376
2407
|
|
|
@@ -2401,4 +2432,4 @@ Four things this walk teaches that are easy to learn the expensive way:
|
|
|
2401
2432
|
and `workflows.run`
|
|
2402
2433
|
- `../data_activation_clients.md` — data source → tool → contract → client
|
|
2403
2434
|
- `../interoperability_contracts.md` — `template_config` vs `mdm_input_config`
|
|
2404
|
-
- `../
|
|
2435
|
+
- `../authenticated_links.md` — `link_prefix`, `exchange`, message tracking
|
|
@@ -377,7 +377,7 @@ Three run shapes exist, selected by the bound tool's
|
|
|
377
377
|
All three produce **log rows** observable through
|
|
378
378
|
`api.dataActivationClients.logs.*` (§6.4), and the canonical way
|
|
379
379
|
to **verify** that ingestion worked is the two-step
|
|
380
|
-
dataset-search pattern in §6.
|
|
380
|
+
dataset-search pattern in §6.6.
|
|
381
381
|
|
|
382
382
|
### 6.1 Inline JSON ingestion — `.ingest`
|
|
383
383
|
|
|
@@ -397,7 +397,7 @@ const { data } = await api.dataActivationClients.ingest(
|
|
|
397
397
|
The response returns **immediately after enqueueing**, not
|
|
398
398
|
after ingestion completes. To know when the row has actually
|
|
399
399
|
been written, poll the logs (§6.4) or use the verification
|
|
400
|
-
pattern (§6.
|
|
400
|
+
pattern (§6.6).
|
|
401
401
|
|
|
402
402
|
The `batch_id` is the most important field on the response —
|
|
403
403
|
every downstream log row and dataset row carries the same
|
|
@@ -534,7 +534,110 @@ merge step has committed the ndjson archive. Use
|
|
|
534
534
|
`output_files.length > 0` as the "this batch's rows are fully
|
|
535
535
|
durable" signal — not just the presence of the log row.
|
|
536
536
|
|
|
537
|
-
### 6.5
|
|
537
|
+
### 6.5 Runs subresource — `.batches.list` / `.batches.get`
|
|
538
|
+
|
|
539
|
+
A **run** is one record; the log rows of §6.4 are one record per
|
|
540
|
+
dataset table that run wrote into. Read the run to answer *"is this
|
|
541
|
+
over, and how did it end"*; read the logs for the per-table detail,
|
|
542
|
+
filtered on the same `batch_id`.
|
|
543
|
+
|
|
544
|
+
Before this record existed, the only way to ask whether a run had
|
|
545
|
+
finished was to count log rows and guess — which cannot tell a run
|
|
546
|
+
still going from one that produced nothing and never will. A run
|
|
547
|
+
that was skipped or that died looked exactly like a slow one until
|
|
548
|
+
the caller's timeout expired.
|
|
549
|
+
|
|
550
|
+
```
|
|
551
|
+
batch_id UUID — the run identifier, the same one `.ingest`,
|
|
552
|
+
`.ingestFile` and `.runManually` hand back, and the
|
|
553
|
+
one stamped on every per-table log row of this run
|
|
554
|
+
client_id UUID — the owning activation client
|
|
555
|
+
closed_at ISO timestamp | null — when the run was attempted and
|
|
556
|
+
exhausted. Null while it is still going. It does NOT
|
|
557
|
+
mean rows landed and it does NOT mean the archive was
|
|
558
|
+
merged — read `status` for the outcome, and only once
|
|
559
|
+
this is set.
|
|
560
|
+
status 'processing' | 'succeeded' | 'partial' | 'skipped' | 'failed'
|
|
561
|
+
rows_total number — source rows enqueued for this run. The run's own
|
|
562
|
+
count; each per-table log keeps its own.
|
|
563
|
+
inserted_at ISO timestamp — when the run started
|
|
564
|
+
```
|
|
565
|
+
|
|
566
|
+
**`status` means nothing until `closed_at` is set.** A run in flight
|
|
567
|
+
always reads `processing`. Once it is closed:
|
|
568
|
+
|
|
569
|
+
| status | what happened |
|
|
570
|
+
|---|---|
|
|
571
|
+
| `succeeded` | it ran and nothing was refused |
|
|
572
|
+
| `partial` | it ran and at least one row was refused on the way in; the rows that landed are still there |
|
|
573
|
+
| `skipped` | it ran and there was nothing to do — empty file, every row filtered out, or the client has no contracts |
|
|
574
|
+
| `failed` | it died before writing anything |
|
|
575
|
+
|
|
576
|
+
```typescript
|
|
577
|
+
// Paginated list of runs for an activation client. Takes the full Flop
|
|
578
|
+
// query (page / page_size / order_by / order_directions / filters —
|
|
579
|
+
// type_naming.md); without it, page 1 only.
|
|
580
|
+
const { data } = await api.dataActivationClients.batches.list(
|
|
581
|
+
tenantSlug, datalakeSlug, activationClientSlug,
|
|
582
|
+
{ page: 1, page_size: 50, order_by: ['inserted_at'], order_directions: ['desc'] },
|
|
583
|
+
)
|
|
584
|
+
// data.data — array of run records; data.meta — pagination
|
|
585
|
+
|
|
586
|
+
// Fetch one run by its batch_id.
|
|
587
|
+
const { data: run } = await api.dataActivationClients.batches.get(
|
|
588
|
+
tenantSlug, datalakeSlug, activationClientSlug, batchId,
|
|
589
|
+
)
|
|
590
|
+
```
|
|
591
|
+
|
|
592
|
+
> **The fourth argument is the `batch_id`, not the record's `id`.**
|
|
593
|
+
> A run record carries both, and both are UUIDs. The route looks the
|
|
594
|
+
> run up by its `batch_id` — the identifier the ingest handed you — so
|
|
595
|
+
> passing the record's own `id` is a **404**, not a type error.
|
|
596
|
+
|
|
597
|
+
#### Waiting on a run
|
|
598
|
+
|
|
599
|
+
This is what the run record is for. Poll until `closed_at` is set,
|
|
600
|
+
then branch on `status` — never poll `status` alone, because a run
|
|
601
|
+
in flight reads `processing` forever and tells you nothing:
|
|
602
|
+
|
|
603
|
+
```typescript
|
|
604
|
+
const { data: ingest } = await api.dataActivationClients.ingest(
|
|
605
|
+
tenantSlug, datalakeSlug, activationClientSlug, { data: row },
|
|
606
|
+
)
|
|
607
|
+
|
|
608
|
+
const deadline = Date.now() + 120_000
|
|
609
|
+
let run
|
|
610
|
+
for (;;) {
|
|
611
|
+
const { data } = await api.dataActivationClients.batches.get(
|
|
612
|
+
tenantSlug, datalakeSlug, activationClientSlug, ingest.batch_id,
|
|
613
|
+
)
|
|
614
|
+
if (data.closed_at != null) { run = data; break }
|
|
615
|
+
if (Date.now() > deadline) throw new Error(`run ${ingest.batch_id} never closed`)
|
|
616
|
+
await new Promise((r) => setTimeout(r, 1_000))
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
if (run.status === 'failed') {
|
|
620
|
+
throw new Error(`run ${ingest.batch_id} failed before writing anything`)
|
|
621
|
+
}
|
|
622
|
+
if (run.status === 'skipped') {
|
|
623
|
+
throw new Error(`run ${ingest.batch_id} had nothing to do — check the row filter and the client's contracts`)
|
|
624
|
+
}
|
|
625
|
+
// 'succeeded' or 'partial' — the run is over and rows were written.
|
|
626
|
+
// For a partial, read the log rows (§6.4) for which table refused what.
|
|
627
|
+
```
|
|
628
|
+
|
|
629
|
+
A run that failed or was skipped **ends the wait immediately, carrying
|
|
630
|
+
why**. That is the whole difference from counting rows: a blind poll
|
|
631
|
+
spends its full timeout and then reports nothing about the cause.
|
|
632
|
+
|
|
633
|
+
> **`closed_at` is not the same as "my row is queryable".** It says the
|
|
634
|
+
> run is over. It does not say the archive was merged (that is
|
|
635
|
+
> `output_files` on the log rows, §6.4), and for one specific row it
|
|
636
|
+
> does not replace the landing check in §6.6 — it makes that check a
|
|
637
|
+
> single read after the run is known finished, instead of a guess
|
|
638
|
+
> against a deadline.
|
|
639
|
+
|
|
640
|
+
### 6.6 Verifying ingestion: the two-step dataset search
|
|
538
641
|
|
|
539
642
|
> **Simplest landing check — `executeSql`.** Ingestion is async (the row
|
|
540
643
|
> commits a moment after the `202`). When you just need to know a row
|
|
@@ -701,7 +804,7 @@ screen. Pick the mode by which of those two readers you are serving.
|
|
|
701
804
|
response carries `batch_id` + `jobs_count`, signalling that
|
|
702
805
|
downstream jobs have been enqueued — not that they've
|
|
703
806
|
completed. Poll logs (or use the search verification
|
|
704
|
-
pattern in §6.
|
|
807
|
+
pattern in §6.6) before treating the row as ingested.
|
|
705
808
|
|
|
706
809
|
7. **`dataset_updated > 0` is the load-bearing freshness
|
|
707
810
|
gate.** `rows_ingested` flips as soon as the per-row job
|
package/.agent/datalakes.md
CHANGED
|
@@ -110,7 +110,7 @@ The platform does NOT expose a query API over the archive itself —
|
|
|
110
110
|
cold reads happen externally so the platform's processes aren't
|
|
111
111
|
responsible for analytical workloads. Live data lives in the
|
|
112
112
|
datalake's Postgres halves (queryable via the dataset search
|
|
113
|
-
two-step pattern in `data_activation_clients.md` §6.
|
|
113
|
+
two-step pattern in `data_activation_clients.md` §6.6); historical
|
|
114
114
|
data lives in the cloud-storage buckets configured here.
|
|
115
115
|
|
|
116
116
|
### Canonical create body
|
|
@@ -299,7 +299,7 @@ in the datalake and there is nothing to search afterwards. The
|
|
|
299
299
|
contract's `resource_type` destination) runs only when the DAC
|
|
300
300
|
worker ingests a row — i.e. on `dataActivationClients.ingest`, not
|
|
301
301
|
on `.run`. To prove a row *landed*, ingest and then query by
|
|
302
|
-
`batch_id` (see `data_activation_clients.md` §6.
|
|
302
|
+
`batch_id` (see `data_activation_clients.md` §6.6).
|
|
303
303
|
|
|
304
304
|
```typescript
|
|
305
305
|
const { data: result } = await api.interoperabilityContracts.run(
|
package/.agent/mdm.md
CHANGED
|
@@ -26,10 +26,10 @@ master records.
|
|
|
26
26
|
## 1. Wire shape — `.verify`
|
|
27
27
|
|
|
28
28
|
Verify that a person on the other end of a workflow action or a
|
|
29
|
-
|
|
29
|
+
form behind an authenticated link matches an existing subject in the
|
|
30
30
|
datalake. Used at action time (workflows confirming an SMS
|
|
31
|
-
recipient) and at runtime (
|
|
32
|
-
submitter before posting tracking updates).
|
|
31
|
+
recipient) and at runtime (the host behind `link_prefix` confirming
|
|
32
|
+
the form submitter before posting tracking updates).
|
|
33
33
|
|
|
34
34
|
Every MDM subject is ultimately a **person** or a **company**,
|
|
35
35
|
and the request type is that ontology's identity vocabulary. You
|
|
@@ -339,7 +339,7 @@ SDK surface:
|
|
|
339
339
|
|
|
340
340
|
Today there is no `.list`, `.get`, or `.metadata` on the MDM
|
|
341
341
|
namespace — subjects themselves are queried through the dataset
|
|
342
|
-
search surface (see `data_activation_clients.md` §6.
|
|
342
|
+
search surface (see `data_activation_clients.md` §6.6), not
|
|
343
343
|
through `api.mdm`.
|
|
344
344
|
|
|
345
345
|
## 6. Gotchas
|
package/.agent/messages.md
CHANGED
|
@@ -44,7 +44,12 @@ alias: `m`.
|
|
|
44
44
|
invalidated (idempotency key collision — the fire was
|
|
45
45
|
rejected, nothing went out)
|
|
46
46
|
received (inbound message, direction: 'inbound')
|
|
47
|
-
|
|
47
|
+
|
|
48
|
+
engagement, each with its own time:
|
|
49
|
+
read read_at
|
|
50
|
+
opened opened_at the link host's page opened
|
|
51
|
+
clicked clicked_at a person first tapped a link
|
|
52
|
+
form_submitted form_submitted_at the link host's form was submitted
|
|
48
53
|
```
|
|
49
54
|
|
|
50
55
|
`accepted` (the provider took the send request) and `sending`
|
|
@@ -53,13 +58,14 @@ and `sent`; the order above is the order the contract declares them.
|
|
|
53
58
|
Treat any of them as "in flight, outcome unknown" — see **Status is
|
|
54
59
|
eventually honest** below.
|
|
55
60
|
|
|
56
|
-
|
|
61
|
+
Four different writers touch a message row. Know which is which:
|
|
57
62
|
|
|
58
63
|
| Writer | Writes | When |
|
|
59
64
|
|---|---|---|
|
|
60
65
|
| the action executor | the row itself; `pending`→`sent`, `external_id`, `sent_at`; also `duplicate` | at execution (writes the row at `pending`; advances to `sent` + sets `external_id` only on a live tool call — a dry-run stops at `pending`). Lands `duplicate` when the provider id it got back is already held by another send through the same tool |
|
|
61
66
|
| the action status updater | `delivered` / `failed`, `delivered_at`, `failure_reason` | later, when its poll runs (see `action_status_updaters.md` §7) |
|
|
62
|
-
| `
|
|
67
|
+
| `authenticatedLinks.exchange` | `clicked`, `clicked_at` (the first person's tap only) | when the host behind `link_prefix` forwards a tap; automated taps move nothing |
|
|
68
|
+
| `authenticatedLinks.updateMessageTracking` | `opened` / `form_submitted`, `opened_at`, `form_submitted_at` | when the host reports its page opened or its form submitted; status only moves forward |
|
|
63
69
|
|
|
64
70
|
Two consequences:
|
|
65
71
|
|
package/.agent/mock-services.md
CHANGED
|
@@ -95,21 +95,21 @@ flowchart TD
|
|
|
95
95
|
end
|
|
96
96
|
```
|
|
97
97
|
|
|
98
|
-
**It lives in this repo**, at `cloudflare
|
|
98
|
+
**It lives in this repo**, at `cloudflare/workers/mock/`. Deploy from a normal checkout:
|
|
99
99
|
|
|
100
100
|
```bash
|
|
101
|
-
cd cloudflare
|
|
101
|
+
cd cloudflare/workers/mock && wrangler deploy
|
|
102
102
|
```
|
|
103
103
|
|
|
104
104
|
The single line that makes this work without a vendored copy of the corpus:
|
|
105
105
|
|
|
106
106
|
```jsonc
|
|
107
|
-
"image_build_context": "
|
|
107
|
+
"image_build_context": "../../.."
|
|
108
108
|
```
|
|
109
109
|
|
|
110
110
|
That sets the Docker build context to the repo root, so the Dockerfile copies
|
|
111
111
|
`wm_mappings/` and `wm_response_files/` from where they already live. Without it
|
|
112
|
-
the context is `cloudflare
|
|
112
|
+
the context is `cloudflare/workers/mock/` and the corpus would have to be duplicated there —
|
|
113
113
|
two directories that look identical until they quietly aren't.
|
|
114
114
|
|
|
115
115
|
### There is no authentication
|
|
@@ -190,4 +190,3 @@ flip.
|
|
|
190
190
|
|
|
191
191
|
- [Tools](tools.md) — tool bodies, `endpoint_url`, and REST API auth methods
|
|
192
192
|
- [Datalakes](datalakes.md) — how datalake provisioning uses MiniStack
|
|
193
|
-
- [Connected Apps](connected_apps.md) — the third-party integrations the corpus stands in for
|
package/.agent/mutations.md
CHANGED
|
@@ -12,7 +12,7 @@ everything:
|
|
|
12
12
|
downstream references — never pre-compute a server-derived field yourself.
|
|
13
13
|
|
|
14
14
|
Non-CRUD methods are out of scope for this page — RESTful sub-actions
|
|
15
|
-
(`.
|
|
15
|
+
(`.testInvocation`, `.refresh`), artifact issuers
|
|
16
16
|
(`.createUploadLink`, `.createDownloadLink`), and async triggers (`.run`,
|
|
17
17
|
`.execute`, `.ingest`) each document their own semantics in their resource's MD.
|
|
18
18
|
|
|
@@ -53,9 +53,9 @@ A small set of methods accept a narrow partial body instead of replacing the
|
|
|
53
53
|
whole resource. Each is named here and documents its partial-body contract in
|
|
54
54
|
its TypeScript method's JSDoc and its per-resource MD §5 (Lifecycle):
|
|
55
55
|
|
|
56
|
-
| SDK method
|
|
57
|
-
|
|
58
|
-
| `api.
|
|
56
|
+
| SDK method | Resource MD |
|
|
57
|
+
|--------------------------------------------------|---------------------------|
|
|
58
|
+
| `api.authenticatedLinks.updateMessageTracking` | `authenticated_links.md` |
|
|
59
59
|
|
|
60
60
|
For every other method, the default holds: `update()` replaces the whole
|
|
61
61
|
resource.
|