@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 CHANGED
@@ -93,7 +93,7 @@ corpus page documents the surface you already know:
93
93
  | Sampling | Dataset event triggers | `data_activation_clients.md` §6.4 logs |
94
94
  | Tools | Tool Calls (polymorphic body)| `tools.md` |
95
95
  | Roots | Datalake boundaries | `datalakes.md` |
96
- | Human-in-the-loop | Connected Apps + page tokens | `connected_apps.md` |
96
+ | Human-in-the-loop | Authenticated links | `authenticated_links.md` |
97
97
  | Sandboxed execution | Liquid + SQL fragment lanes | `ai_sandbox.md` |
98
98
 
99
99
  A few caveats on the mapping:
@@ -204,9 +204,9 @@ admin/bootstrap flows (signup, confirmation, tenant creation,
204
204
  test-suite setup) — they are NOT app-building surfaces; an app that
205
205
  holds a root or tenantless Bearer is misdesigned. An app
206
206
  authenticates against exactly one tenant with that tenant's
207
- publishable key. Likewise, connected apps are **single-tenant by
208
- construction** — one connected app per tenant, never one deployment
209
- serving many tenants (see `connected_apps.md`).
207
+ publishable key. Likewise, the host behind an action's
208
+ `link_prefix` resolves its links against one tenant, on that tenant's
209
+ publishable key (see `authenticated_links.md`).
210
210
 
211
211
  Consumers holding multiple concurrent clients in the same file
212
212
  (e.g. integration tests that need root + tenantless + tenant-
@@ -248,7 +248,6 @@ numbered steps inside the cookbook whose use case motivates it.
248
248
  consumed by other resources.
249
249
  ai_agents.md LLM-backed workers.
250
250
  action_status_updaters.md Polling reconcilers.
251
- connected_apps.md External app bridges.
252
251
  mdm.md Entity resolution + identity
253
252
  verification across domains.
254
253
  ai_sandbox.md Three-layer safety contract
@@ -279,13 +278,16 @@ numbered steps inside the cookbook whose use case motivates it.
279
278
  action_logs.md The action_logs DATASET — per-
280
279
  action outcomes, batch_id joins,
281
280
  audience export.
281
+ authenticated_links.md Every URL a message carries, as
282
+ <link_prefix>/<token>; the host
283
+ behind the prefix resolves taps
284
+ and reports the page.
282
285
  ```
283
286
 
284
287
  Axes are mostly disjoint but a few resources cross. AI Agents
285
288
  anchor INFRASTRUCTURE and appear in DATA ACTIVATION (they
286
289
  tokenize during ingestion) and WORKFLOWS (workflows invoke
287
- them). Connected Apps anchor INFRASTRUCTURE and tag into
288
- WORKFLOWS.
290
+ them).
289
291
 
290
292
  Each resource is exposed via a TypeScript namespace on the
291
293
  typed client. The SDK namespace name follows TS conventions
@@ -299,13 +301,13 @@ wire name (snake_case):
299
301
  api.tools tools.md
300
302
  api.aiAgents ai_agents.md
301
303
  api.actionStatusUpdaters action_status_updaters.md
302
- api.connectedApps connected_apps.md
303
304
  api.mdm mdm.md
304
305
  api.genericTables generic_tables.md
305
306
  api.advancedMigrations advanced_migrations.md
306
307
  api.interoperabilityContracts interoperability_contracts.md
307
308
  api.dataActivationClients data_activation_clients.md
308
309
  api.workflows workflows.md
310
+ api.authenticatedLinks authenticated_links.md
309
311
 
310
312
  Two corpus pages document DATASETS, not namespaces — read
311
313
  through `api.datasets` with SINGULAR dataset types (the plural
@@ -99,15 +99,16 @@ a tenant (creator path) or accept an invitation (invitee path).
99
99
  are platform-UI / admin-bootstrap surfaces (signup, confirmation,
100
100
  tenant creation, test setup) — never ship them inside an app. An
101
101
  app authenticates **tenant-scoped against exactly one tenant**,
102
- with that tenant's publishable key; connected apps in particular
103
- are single-tenant by construction (`connected_apps.md`).
102
+ with that tenant's publishable key; the host behind an action's
103
+ `link_prefix` in particular resolves links for one tenant
104
+ (`authenticated_links.md`).
104
105
 
105
106
  ### Key-only clients — machine-to-machine on the publishable key
106
107
 
107
- `sessionToken` is optional on the client config. A deployed
108
- connected app that authenticates purely on its tenant's publishable
109
- key constructs with just the key — no session mint, no fabricated
110
- empty token:
108
+ `sessionToken` is optional on the client config. The host behind an
109
+ action's `link_prefix`, authenticating purely on its tenant's
110
+ publishable key, constructs with just the key — no session mint, no
111
+ fabricated empty token:
111
112
 
112
113
  ```typescript
113
114
  const api = createIsolatedPlatformApi({ baseUrl, apiKey })
@@ -118,15 +119,15 @@ and reaches exactly the `public_api` (publishable-key) allowlist —
118
119
  five calls:
119
120
 
120
121
  ```
121
- api.connectedApps.resolvePage mint/read a delivered page
122
- api.connectedApps.updateMessageTracking opened_at / form_submitted_at
123
- api.dataActivationClients.ingest submit rows
124
- api.dataActivationClients.ingestFile submit a file
125
- api.mdm.verify resolve an identity
122
+ api.authenticatedLinks.exchange a tap on a link
123
+ api.authenticatedLinks.updateMessageTracking opened / form_submitted
124
+ api.dataActivationClients.ingest submit rows
125
+ api.dataActivationClients.ingestFile submit a file
126
+ api.mdm.verify resolve an identity
126
127
  ```
127
128
 
128
- (The same allowlist `connected_apps.md` documents — that guide owns
129
- the runtime-call details.) Every call outside these five returns
129
+ (`authenticated_links.md` owns the details of the two link calls.)
130
+ Every call outside these five returns
130
131
  401; that ceiling is the point, not an error to work around.
131
132
  Minting a session and passing `sessionToken` unlocks the full
132
133
  tenant-scoped API exactly as before — the two credentials are
@@ -635,9 +635,10 @@ is the part that matters:
635
635
  decision (`mode: 'live'`, `manual_override: true`) — the same
636
636
  call as any manual dispatch; there is no retry endpoint. See
637
637
  `workflows.md` §8.
638
- - **Engagement is not here either.** `opened_at` /
639
- `form_submitted_at` arrive via
640
- `connectedApps.updateMessageTracking` (magic-link seam,
641
- `connected_apps.md` §6) and never touch `status`.
638
+ - **Engagement is not here either.** `clicked` arrives via
639
+ `authenticatedLinks.exchange`, and `opened` / `form_submitted`
640
+ with their times via `authenticatedLinks.updateMessageTracking`
641
+ — both called by the host behind the action's `link_prefix`
642
+ (`authenticated_links.md`).
642
643
  - Field-by-field semantics of the rows being updated:
643
644
  `messages.md`, `action_logs.md`.
@@ -157,7 +157,7 @@ trail this trap leaves.
157
157
 
158
158
  SQL is the second consumer-author surface: WHERE clauses on
159
159
  dataset searches (`api.datasets.createUserSearch.search_query`,
160
- see `data_activation_clients.md` §6.5). Liquid renders some of
160
+ see `data_activation_clients.md` §6.6). Liquid renders some of
161
161
  these from templates; others arrive directly as raw SQL
162
162
  fragments. Either path lands at the same boundary.
163
163
 
@@ -165,7 +165,7 @@ fragments. Either path lands at the same boundary.
165
165
 
166
166
  - SELECT against the tables exposed by the dataset search aliases
167
167
  (`le` for legal entities, `m` for messages, `al` for action logs
168
- — see `data_activation_clients.md` §6.5 SQL aliases).
168
+ — see `data_activation_clients.md` §6.6 SQL aliases).
169
169
  - Filter results into the platform's `search_results`
170
170
  accumulator scoped to the materialised search id. This is the
171
171
  one write the query path performs, and the platform builds it
@@ -305,5 +305,5 @@ guarantees it captures back-stop Layer 1.
305
305
  - `account_management.md` §3 — session role vocabularies (Layer 1)
306
306
  - `interoperability_contracts.md` §2 — template_config body shape (Layer 2)
307
307
  - `workflows.md` gotcha 9 — the `| to_json` vs `| json` trap (Layer 2)
308
- - `data_activation_clients.md` §6.5 — dataset search SQL aliases (Layer 3)
308
+ - `data_activation_clients.md` §6.6 — dataset search SQL aliases (Layer 3)
309
309
  - `mdm.md` — verification surface that composes with Layer 1
@@ -0,0 +1,247 @@
1
+ # Authenticated links
2
+
3
+ Every URL in an SMS or MMS body, and every `href` in an email body, goes out
4
+ as an **authenticated link**:
5
+
6
+ ```
7
+ <link_prefix>/<token>
8
+ ```
9
+
10
+ `link_prefix` is set on the action's tool call. It is the address of a host
11
+ **you** run. The platform appends only the token; any path belongs to the
12
+ prefix. When a person taps the link, the request reaches your host, and your
13
+ host asks the platform what the link is with one call and reports what the
14
+ person did on the page with a second.
15
+
16
+ SDK namespace: `api.authenticatedLinks` — `exchange` and
17
+ `updateMessageTracking`.
18
+
19
+ ```
20
+ workflow action (sms_request · mms_request · email_request)
21
+ link_prefix = "https://go.example.com/l"
22
+ body = "Book a slot: https://booking.example.com/slots"
23
+ │ send
24
+ ▼
25
+ delivered "Book a slot: https://go.example.com/l/q3v9RkZ0aB7xYtL"
26
+ │ a person taps
27
+ ▼
28
+ your host at go.example.com/l
29
+ exchange({ token, method, user_agent, ip, country, headers })
30
+ → destination_url "https://booking.example.com/slots",
31
+ legal_entity_id, workflow, run, action, expires_at, message
32
+ send the person on to destination_url
33
+ │ the page opens · the form is submitted
34
+ ▼
35
+ updateMessageTracking({ token, status: 'opened', opened_at })
36
+ updateMessageTracking({ token, status: 'form_submitted', form_submitted_at })
37
+ ```
38
+
39
+ **Both calls need only the tenant's publishable key.** The host is a key-only
40
+ client: no session, no user.
41
+
42
+ ```typescript
43
+ import { createIsolatedPlatformApi } from '@alvera-ai/platform-sdk'
44
+
45
+ const links = createIsolatedPlatformApi({
46
+ baseUrl: 'https://api.alvera.ai',
47
+ apiKey: 'pk_live_tenant_publishable_key',
48
+ })
49
+ ```
50
+
51
+ This replaces Connected Apps and page tokens, which the platform retired:
52
+ there is no app to register, no route discovery, no `resolvePage`, and no
53
+ `{{ connected_app_form_url }}`. Write the real destination URL into the
54
+ message body; the platform turns it into an authenticated link at send.
55
+
56
+ ## 1. Wire shape
57
+
58
+ ### On the action — set when you build the workflow
59
+
60
+ | Field | Where | Shape |
61
+ |---|---|---|
62
+ | `link_prefix` | `SmsCallRequest`, `MmsCallRequest`, `EmailCallRequest` | **required** string — the address of your host |
63
+ | `link_validity_days` | the same three | optional whole number, at least 1, default **365** — how many days each link resolves after the send |
64
+ | `metadata_template` | every action type | optional Liquid template rendered to JSON once per send |
65
+
66
+ ```typescript
67
+ import type { AgenticWorkflowRequestWritable } from '@alvera-ai/platform-sdk'
68
+
69
+ type Action = NonNullable<AgenticWorkflowRequestWritable['actions']>[number]
70
+
71
+ const reviewRequest: Action['tool_call'] = {
72
+ tool_call_type: 'sms_request',
73
+ sms_type: 'transactional',
74
+ to: { type: 'custom', body: '{{ event_dataset.phone }}' },
75
+ body: {
76
+ type: 'custom',
77
+ body: 'Thanks for visiting. Tell us how it went: https://reviews.example.com/visit',
78
+ },
79
+ link_prefix: 'https://go.example.com/l',
80
+ link_validity_days: 30,
81
+ }
82
+ ```
83
+
84
+ ### `exchange` — a tap on the link
85
+
86
+ ```typescript
87
+ import type { AuthenticatedLinkExchangeRequest } from '@alvera-ai/platform-sdk'
88
+
89
+ const tap: AuthenticatedLinkExchangeRequest = {
90
+ token: 'q3v9RkZ0aB7xYtL', // required: from <link_prefix>/<token>
91
+ method: 'GET', // the tap's HTTP method
92
+ user_agent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)',
93
+ ip: '203.0.113.7',
94
+ country: 'US',
95
+ headers: { 'accept-language': 'en-US' },
96
+ }
97
+ ```
98
+
99
+ ### `updateMessageTracking` — what happened on the page
100
+
101
+ ```typescript
102
+ import type { AuthenticatedLinkUpdateMessageTrackingRequest } from '@alvera-ai/platform-sdk'
103
+
104
+ const submitted: AuthenticatedLinkUpdateMessageTrackingRequest = {
105
+ token: 'q3v9RkZ0aB7xYtL', // required: the same token
106
+ status: 'form_submitted', // typically 'opened' or 'form_submitted'
107
+ form_submitted_at: '2026-10-04T10:00:00Z',
108
+ }
109
+ ```
110
+
111
+ ### The answer — `AuthenticatedLinkResponse`, from both calls
112
+
113
+ | Field | Meaning |
114
+ |---|---|
115
+ | `destination_url` | the URL as written in the message |
116
+ | `legal_entity_id` | the recipient's legal entity, when MDM resolved one — else `null` |
117
+ | `message_id` | the message the link went out in |
118
+ | `workflow_id` | the workflow that sent it |
119
+ | `workflow_execution_log_id` | the workflow run that sent it |
120
+ | `action_id` | the action that sent it |
121
+ | `expires_at` | when the link stops resolving |
122
+ | `link_clicked_at` | when a person first clicked this link — `null` until then |
123
+ | `message` | the message, a `MessageResponse` |
124
+
125
+ `message.metadata` holds the recipient's `to` merged with what the action's
126
+ `metadata_template` rendered, as stored at send. The message carries each
127
+ engagement status with its own time: `read` (`read_at`), `opened`
128
+ (`opened_at`), `clicked` (`clicked_at`) and `form_submitted`
129
+ (`form_submitted_at`).
130
+
131
+ ## 2. Rules the type cannot encode
132
+
133
+ ### `link_prefix` is an absolute URL with nothing after the path
134
+
135
+ An absolute `http` or `https` URL. A path is allowed —
136
+ `https://go.example.com/l` — but no query, no fragment and **no trailing
137
+ slash**: the platform appends `/<token>` itself.
138
+
139
+ ### A person's tap moves the message to `clicked`; a machine's does not
140
+
141
+ `exchange` answers every tap, but only a person's moves anything. The first
142
+ click stamps the link's `link_clicked_at` and the message's `clicked_at`;
143
+ later taps keep both. The platform judges a tap from what your host forwards,
144
+ and these look automated and move nothing:
145
+
146
+ - a `HEAD` request;
147
+ - a prefetch header — `sec-purpose`, `purpose`, `x-purpose`, `x-moz`;
148
+ - an empty user agent, or one naming a bot, a link preview or a mail scanner.
149
+
150
+ Every tap is recorded with the details you forwarded and the signal that
151
+ flagged it, if one did. Headers are recorded as client headers — never cookies
152
+ or credentials.
153
+
154
+ ### The message status only moves forward
155
+
156
+ `updateMessageTracking` applies its `status` through the message's order
157
+ guard: reporting `opened` after `form_submitted` leaves the message at
158
+ `form_submitted`. A time you leave empty keeps the message's value. The
159
+ `status` field is a plain string on the type; one a message cannot have is
160
+ refused with `422`.
161
+
162
+ ### A link answers until it expires
163
+
164
+ Each link resolves for `link_validity_days` after the send, then answers
165
+ `410`. The response's `expires_at` says when.
166
+
167
+ ### `metadata_template` renders once, at send
168
+
169
+ It renders to JSON once per send, on every action type, and is stored on the
170
+ message. A template that does not render **fails the send**. You read the
171
+ result back from `message.metadata` — not from a field on the link.
172
+
173
+ ## 3. Field ownership
174
+
175
+ | Field | Written by |
176
+ |---|---|
177
+ | `link_prefix`, `link_validity_days`, `metadata_template` | you, on the action |
178
+ | `destination_url` | you, as the URL written in the body |
179
+ | the token, `expires_at`, `message_id`, `workflow_id`, `workflow_execution_log_id`, `action_id` | the platform, at send |
180
+ | `legal_entity_id` | the platform, when MDM resolves the recipient |
181
+ | `link_clicked_at`, `message.clicked_at` | the platform, on a person's first tap |
182
+ | `message.opened_at`, `message.form_submitted_at` | your host, through `updateMessageTracking` |
183
+
184
+ ## 4. Error envelopes
185
+
186
+ Both calls throw on a refusal, and the thrown value carries the HTTP status
187
+ as `_httpStatus` (see `errors.md`). They refuse in ways a host must tell
188
+ apart:
189
+
190
+ | Status | `exchange` | `updateMessageTracking` | Meaning |
191
+ |---|---|---|---|
192
+ | `404` | yes | yes | a token the platform never minted |
193
+ | `410` | yes | yes | the link is past `expires_at` |
194
+ | `503` | yes | yes | the datalake cannot be reached right now |
195
+ | `422` | — | yes | a status the message cannot have |
196
+ | `401` | yes | yes | no publishable key, or one that does not resolve |
197
+
198
+ ```typescript
199
+ async function resolveTap(token: string, userAgent: string): Promise<string> {
200
+ try {
201
+ const { data } = await api.authenticatedLinks.exchange(tenantSlug, datalakeSlug, {
202
+ token,
203
+ method: 'GET',
204
+ user_agent: userAgent,
205
+ })
206
+ return data.destination_url
207
+ } catch (err) {
208
+ const status = (err as { _httpStatus?: number })._httpStatus
209
+ if (status === 410) return 'https://go.example.com/expired'
210
+ if (status === 404) return 'https://go.example.com/not-found'
211
+ if (status === 503) return 'https://go.example.com/try-again'
212
+ throw err
213
+ }
214
+ }
215
+ ```
216
+
217
+ ## 5. Lifecycle
218
+
219
+ 1. **Build.** Give every SMS, MMS and email action a `link_prefix`, and a
220
+ `metadata_template` when your page needs more than the recipient.
221
+ 2. **Send.** Each URL in the body becomes `<link_prefix>/<token>`, valid for
222
+ `link_validity_days`.
223
+ 3. **Tap.** Your host calls `exchange` with the token and the tap's request
224
+ details, and sends the person on to `destination_url`. A person's tap
225
+ moves the message to `clicked`.
226
+ 4. **Page.** Your host reports `opened` when its page opens and
227
+ `form_submitted` when the form is done, each with its time.
228
+ 5. **Expiry.** After `expires_at` both calls answer `410`.
229
+
230
+ ## 6. Gotchas
231
+
232
+ - **Forward what the tap said about itself.** Only the token is required, but
233
+ a host that forwards nothing else gives the platform nothing to judge a
234
+ person by. Pass the method, user agent and headers of the request that
235
+ reached you, not those of your own server-side call.
236
+ - **Link previews are taps too.** Messaging apps fetch a link to draw its
237
+ preview; those are recorded and do not count as clicks.
238
+ - **Put the path in the prefix.** The platform appends only the token, so
239
+ `https://go.example.com/l` gives `https://go.example.com/l/<token>`. A
240
+ trailing slash is refused.
241
+ - **`metadata` lives on the message.** Read `data.message.metadata`; the link
242
+ itself has no metadata field.
243
+ - **Coming from Connected Apps.** `connected_app_id`, `connected_app_route`
244
+ and `connected_app_metadata_template` are gone from actions;
245
+ `link_metadata_template` is now `metadata_template`; the body carries the
246
+ destination URL itself instead of `{{ connected_app_form_url }}`; and
247
+ `updateMessageTracking` is keyed by the link's token, not a page token.