@alvera-ai/platform-sdk 0.13.0 → 0.15.0
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/account_management.md +31 -0
- package/.agent/action_status_updaters.md +273 -25
- package/.agent/ai_sandbox.md +4 -2
- package/.agent/connected_apps.md +7 -2
- package/.agent/cookbook/action-status-updaters.md +73 -14
- package/.agent/cookbook/ai-agent-invoke.md +36 -0
- package/.agent/cookbook/appointment-review-sms-workflow.md +32 -0
- package/.agent/cookbook/birthday-greeting-sms-trigger.md +35 -1
- package/.agent/cookbook/bulk-ingest.md +48 -0
- package/.agent/cookbook/contact-us-triage-with-llm.md +36 -1
- package/.agent/cookbook/dunning-sms-for-delinquent.md +33 -1
- package/.agent/cookbook/generic-tables.md +40 -0
- package/.agent/cookbook/kyc-notification-on-account-activation.md +35 -1
- package/.agent/cookbook/marketing-campaign-send.md +35 -0
- package/.agent/cookbook/paginated-restapi-poller.md +383 -0
- package/.agent/cookbook/rest-fetch.md +27 -0
- package/.agent/cookbook/sanctions-screening-with-agent-review.md +35 -1
- package/.agent/cookbook/score-leads-with-llm-categorization.md +36 -1
- package/.agent/cookbook/system-templates.md +36 -0
- package/.agent/cookbook/talk-to-data.md +39 -0
- package/.agent/cookbook/triage-prospects-by-priority.md +33 -1
- package/.agent/cookbook/welcome-sms-for-customers.md +33 -1
- package/.agent/datalakes.md +68 -3
- package/.agent/interoperability_contracts.md +29 -0
- package/.agent/tool-call-configs.md +11 -0
- package/.agent/tools.md +103 -36
- package/.agent/type_naming.md +4 -0
- package/dist/index.d.mts +189 -19
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +3 -1
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
|
@@ -102,6 +102,37 @@ app authenticates **tenant-scoped against exactly one tenant**,
|
|
|
102
102
|
with that tenant's publishable key; connected apps in particular
|
|
103
103
|
are single-tenant by construction (`connected_apps.md`).
|
|
104
104
|
|
|
105
|
+
### Key-only clients — machine-to-machine on the publishable key
|
|
106
|
+
|
|
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:
|
|
111
|
+
|
|
112
|
+
```typescript
|
|
113
|
+
const api = createIsolatedPlatformApi({ baseUrl, apiKey })
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
Such a client sends `X-API-Key` and **no `Authorization` header**,
|
|
117
|
+
and reaches exactly the `public_api` (publishable-key) allowlist —
|
|
118
|
+
five calls:
|
|
119
|
+
|
|
120
|
+
```
|
|
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
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
(The same allowlist `connected_apps.md` documents — that guide owns
|
|
129
|
+
the runtime-call details.) Every call outside these five returns
|
|
130
|
+
401; that ceiling is the point, not an error to work around.
|
|
131
|
+
Minting a session and passing `sessionToken` unlocks the full
|
|
132
|
+
tenant-scoped API exactly as before — the two credentials are
|
|
133
|
+
complementary (the key identifies the client; the Bearer
|
|
134
|
+
authorizes), never substitutes.
|
|
135
|
+
|
|
105
136
|
### Explicit teardown — `revokeSession`
|
|
106
137
|
|
|
107
138
|
The inverse of `createSession`. Revokes the current Bearer on
|
|
@@ -40,8 +40,8 @@ const { data: created } = await api.actionStatusUpdaters.create(
|
|
|
40
40
|
updater_body: {
|
|
41
41
|
updater_body_type: 'cloud_watch_request',
|
|
42
42
|
log_group_name: 'sns/us-east-1/...',
|
|
43
|
-
start_time: '{{
|
|
44
|
-
end_time: '{{
|
|
43
|
+
start_time: '{{ now_msec | minutes_ago: 45 }}', // Liquid window
|
|
44
|
+
end_time: '{{ now_msec }}',
|
|
45
45
|
},
|
|
46
46
|
|
|
47
47
|
// Liquid, rendered ONCE PER EVENT — the event IS the assigns, so there is no
|
|
@@ -51,6 +51,14 @@ const { data: created } = await api.actionStatusUpdaters.create(
|
|
|
51
51
|
type: 'custom',
|
|
52
52
|
body: '{"external_id": "{{ notification.messageId }}", "status": "delivered"}',
|
|
53
53
|
},
|
|
54
|
+
|
|
55
|
+
// REQUIRED on every updater, both types, on create AND update. Same
|
|
56
|
+
// per-event rendering contract as `message_config`, but targets the
|
|
57
|
+
// action_log row instead of the message row.
|
|
58
|
+
action_log_config: {
|
|
59
|
+
type: 'custom',
|
|
60
|
+
body: '{"external_id": "{{ notification.messageId }}", "status": "delivered"}',
|
|
61
|
+
},
|
|
54
62
|
},
|
|
55
63
|
)
|
|
56
64
|
```
|
|
@@ -74,22 +82,30 @@ updater_body_type: 'cloud_watch_request'
|
|
|
74
82
|
follow the same `<verb>_request` naming and carry the fields
|
|
75
83
|
their target API requires.)
|
|
76
84
|
|
|
85
|
+
`updater_body_type: 'restapi_request'` additionally **requires an
|
|
86
|
+
`events_template` field**. Without it the REST poll fails with
|
|
87
|
+
`missing_events_template`. `events_template` is specific to the REST
|
|
88
|
+
poll's event-list extraction — it is not read by data-activation
|
|
89
|
+
fetches.
|
|
90
|
+
|
|
77
91
|
`start_time` / `end_time` are Liquid, and they **must render to
|
|
78
|
-
unix milliseconds** — that is what the poll request takes.
|
|
79
|
-
|
|
80
|
-
|
|
92
|
+
unix milliseconds** — that is what the poll request takes. Use the
|
|
93
|
+
**`now_msec`** variable (unix ms) and pipe it through the
|
|
94
|
+
relative-time filters. **Not the bare `now`** — that is a DateTime,
|
|
95
|
+
and feeding it a millisecond filter now raises at render:
|
|
81
96
|
|
|
82
97
|
```
|
|
83
|
-
start_time: '{{
|
|
84
|
-
end_time: '{{
|
|
98
|
+
start_time: '{{ now_msec | minutes_ago: 45 }}' // 45 minutes back
|
|
99
|
+
end_time: '{{ now_msec }}' // right now
|
|
85
100
|
```
|
|
86
101
|
|
|
87
102
|
Also available: `hours_ago: N`.
|
|
88
103
|
|
|
89
|
-
**Do not use the
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
literal is not valid on
|
|
104
|
+
**Do not use the bare `now` variable or the ISO-8601 `now` filter
|
|
105
|
+
here.** The `now` variable is a DateTime and `{{ "" | now }}`
|
|
106
|
+
renders a datetime *string* — neither is a valid unix-ms window,
|
|
107
|
+
and the platform rejects both. An ISO 8601 literal is not valid on
|
|
108
|
+
these fields either.
|
|
93
109
|
|
|
94
110
|
### `message_config.body` renders ONCE PER EVENT
|
|
95
111
|
|
|
@@ -101,6 +117,10 @@ scope — do not loop.
|
|
|
101
117
|
{"external_id": "{{ notification.messageId }}", "status": "delivered"}
|
|
102
118
|
```
|
|
103
119
|
|
|
120
|
+
> **Shape only — do not copy this mapping.** A hardcoded `"delivered"`
|
|
121
|
+
> records every event as delivered, failures included. Real bodies
|
|
122
|
+
> branch on the provider's status; see the SNS and MMS sections below.
|
|
123
|
+
|
|
104
124
|
It must render a **flat** JSON object:
|
|
105
125
|
|
|
106
126
|
```
|
|
@@ -123,6 +143,174 @@ events for messages this updater doesn't own.
|
|
|
123
143
|
Custom Liquid filters (`json_escape`, `e164`, `to_json`, ...) are
|
|
124
144
|
available here, as everywhere.
|
|
125
145
|
|
|
146
|
+
### SMS-over-SNS delivery tracking — two `custom` bodies
|
|
147
|
+
|
|
148
|
+
The SNS SMS delivery receipt is **binary and single-shot**: one terminal
|
|
149
|
+
outcome, `status` of `SUCCESS` or `FAILURE`. Its event shape is
|
|
150
|
+
**disjoint from EUM's** — `status`, `notification.messageId`,
|
|
151
|
+
`notification.timestamp`, `delivery.phoneCarrier`,
|
|
152
|
+
`delivery.providerResponse` all sit at the root. Do not carry an EUM
|
|
153
|
+
template over to SNS or vice versa.
|
|
154
|
+
|
|
155
|
+
Two convenient consequences, both unlike MMS:
|
|
156
|
+
|
|
157
|
+
- `delivered_at` is `notification.timestamp` — a UTC
|
|
158
|
+
`"YYYY-MM-DD HH:MM:SS.sss"` string Ecto casts directly, so **no date
|
|
159
|
+
filter is needed** (EUM needs `from_unix_ms` on its Unix-ms field).
|
|
160
|
+
- the DLR **does** surface the US carrier as `delivery.phoneCarrier`.
|
|
161
|
+
|
|
162
|
+
**`message_config`:**
|
|
163
|
+
|
|
164
|
+
```liquid
|
|
165
|
+
{"external_id": "{{ notification.messageId }}", "status": "{% if status == 'SUCCESS' %}delivered{% else %}failed{% endif %}", "status_description": "{{ delivery.providerResponse | json_escape }}"{% if status == 'SUCCESS' and notification.timestamp %}, "delivered_at": "{{ notification.timestamp }}"{% endif %}{% unless status == 'SUCCESS' %}, "failure_reason": "{{ status | json_escape }}"{% endunless %}{% if delivery.phoneCarrier and delivery.phoneCarrier != '' %}, "sms_carrier": "{{ delivery.phoneCarrier | json_escape }}"{% endif %}}
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
**`action_log_config`:**
|
|
169
|
+
|
|
170
|
+
```liquid
|
|
171
|
+
{"external_id": "{{ notification.messageId }}", "status": "{% if status == 'SUCCESS' %}delivered{% else %}failed{% endif %}", "status_description": "{{ delivery.providerResponse | json_escape }}"}
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
`status_description` is `delivery.providerResponse` verbatim on both
|
|
175
|
+
sides — the carrier's own text, on success and failure alike. Because
|
|
176
|
+
SNS's status is binary, `failure_reason` can only ever be `"FAILURE"`;
|
|
177
|
+
the granular carrier text stays in `status_description`.
|
|
178
|
+
|
|
179
|
+
**Note how the two SNS bodies differ from each other**: same status
|
|
180
|
+
mapping, fewer emitted fields on the action log (no `delivered_at`,
|
|
181
|
+
`sms_carrier`, or `failure_reason` — those are message concerns). That
|
|
182
|
+
is a *different* reason from MMS's, where the two also disagree on the
|
|
183
|
+
mapping itself. Both channels need two bodies; they need them for
|
|
184
|
+
different reasons.
|
|
185
|
+
|
|
186
|
+
### MMS delivery tracking needs TWO different templates — author both as `custom`
|
|
187
|
+
|
|
188
|
+
MMS delivery tracking is a `cloud_watch` updater. Author **both**
|
|
189
|
+
`message_config` and `action_log_config` as `custom` bodies — the two
|
|
190
|
+
render *different* JSON from the same event, so one body cannot serve
|
|
191
|
+
both. This is the single most-missed thing about updaters.
|
|
192
|
+
|
|
193
|
+
**Why they differ.** A message's status vocabulary includes
|
|
194
|
+
`customer_rejected`; an action log's does not. A recipient or carrier
|
|
195
|
+
rejection (`SPAM` / `BLOCKED` / `CARRIER_BLOCKED`) is a message-level
|
|
196
|
+
outcome — the action *did* deliver — so the action-log render must
|
|
197
|
+
**NO-OP** on those events while the message render records
|
|
198
|
+
`customer_rejected`. The message side also carries `delivered_at`,
|
|
199
|
+
`failure_reason`, and `sms_carrier`; only `status` +
|
|
200
|
+
`status_description` are ever applied to an action log.
|
|
201
|
+
|
|
202
|
+
AWS End User Messaging (`sms-voice`) event fields: top-level
|
|
203
|
+
`messageId`, `eventType` (`MEDIA_*`), `messageStatus`,
|
|
204
|
+
`messageStatusDescription`, `eventTimestamp` (Unix ms), `isFinal`.
|
|
205
|
+
|
|
206
|
+
**`message_config`** — map every status, emit the extra fields:
|
|
207
|
+
|
|
208
|
+
```liquid
|
|
209
|
+
{% case messageStatus %}
|
|
210
|
+
{% when 'DELIVERED' %}{% assign mapped_status = 'delivered' %}
|
|
211
|
+
{% when 'SUCCESSFUL' %}{% assign mapped_status = 'sent' %}
|
|
212
|
+
{% when 'PENDING' %}{% assign mapped_status = 'sent' %}
|
|
213
|
+
{% when 'QUEUED' %}{% assign mapped_status = 'queued' %}
|
|
214
|
+
{% when 'SPAM' %}{% assign mapped_status = 'customer_rejected' %}
|
|
215
|
+
{% when 'BLOCKED' %}{% assign mapped_status = 'customer_rejected' %}
|
|
216
|
+
{% when 'CARRIER_BLOCKED' %}{% assign mapped_status = 'customer_rejected' %}
|
|
217
|
+
{% else %}{% assign mapped_status = 'failed' %}
|
|
218
|
+
{% endcase %}
|
|
219
|
+
{"external_id": "{{ messageId }}", "status": "{{ mapped_status }}", "status_description": "{{ messageStatusDescription | json_escape }}"{% if messageStatus == 'DELIVERED' and eventTimestamp %}, "delivered_at": "{{ eventTimestamp | from_unix_ms }}"{% endif %}{% if mapped_status == 'failed' or mapped_status == 'customer_rejected' %}, "failure_reason": "{{ messageStatus | json_escape }}"{% endif %}{% if carrierName and carrierName != '' %}, "sms_carrier": "{{ carrierName | json_escape }}"{% endif %}}
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
**`action_log_config`** — the rejection statuses fall through to a
|
|
223
|
+
bare `external_id`, which applies nothing:
|
|
224
|
+
|
|
225
|
+
```liquid
|
|
226
|
+
{% assign al_status = '' %}{% case messageStatus %}{% when 'DELIVERED' %}{% assign al_status = 'delivered' %}{% when 'SUCCESSFUL' %}{% assign al_status = 'sent' %}{% when 'PENDING' %}{% assign al_status = 'sent' %}{% when 'QUEUED' %}{% assign al_status = 'queued' %}{% when 'SPAM', 'BLOCKED', 'CARRIER_BLOCKED' %}{% else %}{% assign al_status = 'failed' %}{% endcase %}{"external_id": "{{ messageId }}"{% if al_status != '' %}, "status": "{{ al_status }}", "status_description": "{{ messageStatusDescription | json_escape }}"{% endif %}}
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
Note the `{% when 'SPAM', 'BLOCKED', 'CARRIER_BLOCKED' %}` arm is
|
|
230
|
+
deliberately **empty** — it leaves `al_status` blank, so the render
|
|
231
|
+
emits `external_id` alone and the action log is untouched.
|
|
232
|
+
|
|
233
|
+
**Capture every event, not just the terminal one.** 2–3 events arrive
|
|
234
|
+
per message — interim `MEDIA_QUEUED` / `MEDIA_PENDING` /
|
|
235
|
+
`MEDIA_SUCCESSFUL` (`isFinal: false`), then one terminal event
|
|
236
|
+
(`isFinal: true`), up to 72h late and out of order. Apply is
|
|
237
|
+
idempotent and a monotonic guard only ever advances `status`, so a
|
|
238
|
+
late or interim lower-rank event can never demote a message that
|
|
239
|
+
already reached a higher state. Poll a window that overlaps
|
|
240
|
+
deliberately; nothing is dropped.
|
|
241
|
+
|
|
242
|
+
`queued_at` and `sent_at` are **your outbound pipeline's** times, set
|
|
243
|
+
when the platform enqueues and dispatches — a delivery poll never
|
|
244
|
+
overwrites them. Only `delivered_at` comes from the carrier event
|
|
245
|
+
(`DELIVERED`'s own `eventTimestamp` via `from_unix_ms`), so
|
|
246
|
+
re-polling a wide window does not drift it.
|
|
247
|
+
|
|
248
|
+
### Carrier information is available for SMS, not MMS
|
|
249
|
+
|
|
250
|
+
The recipient's mobile carrier is present on SMS delivery events but
|
|
251
|
+
not MMS. This is AWS behavior — verified against real delivery
|
|
252
|
+
events — not something the platform chooses to drop:
|
|
253
|
+
|
|
254
|
+
- **SMS via Amazon SNS** — the delivery-status log
|
|
255
|
+
(`sns/<region>/<account>/DirectPublishToPhoneNumber`) nests carrier
|
|
256
|
+
under `delivery.phoneCarrier` (e.g. `"T-mobile USA Inc."`, with
|
|
257
|
+
numeric `mcc`/`mnc`). The SNS status template maps it to
|
|
258
|
+
`sms_carrier`.
|
|
259
|
+
- **MMS via AWS End User Messaging (`sms-voice`)** — the event *can*
|
|
260
|
+
carry `carrierName`, but it is DLR-populated and comes back
|
|
261
|
+
**absent for US destinations** (the US DLR does not surface it),
|
|
262
|
+
unlike the SNS path's `phoneCarrier`, whose US DLR does. So map it
|
|
263
|
+
when present — `{% if carrierName and carrierName != '' %}` — and
|
|
264
|
+
expect `sms_carrier` to stay null for US long-code traffic.
|
|
265
|
+
Verified against real AWS delivery events: the terminal
|
|
266
|
+
`MEDIA_DELIVERED` event for a US destination exposes
|
|
267
|
+
`totalCarrierFee` (a cost) and no `carrierName`.
|
|
268
|
+
|
|
269
|
+
AWS's Nov-2024 EventBridge-integration announcement states MMS
|
|
270
|
+
delivery events carry carrier information in EventBridge, but the
|
|
271
|
+
real events do not — a documented doc-vs-behavior discrepancy. For
|
|
272
|
+
per-number US carrier, use `PhoneNumberValidate` (returns `Carrier` +
|
|
273
|
+
`PhoneType`) at enrollment and join on destination/`messageId`; it is
|
|
274
|
+
not in the delivery stream.
|
|
275
|
+
|
|
276
|
+
### `action_log_config` is required — same rendering contract as `message_config`
|
|
277
|
+
|
|
278
|
+
`action_log_config` is a **required** field on every action status
|
|
279
|
+
updater — `cloud_watch` and `restapi` alike — on **both `create()` and
|
|
280
|
+
`update()`**. There is no updater for which it is optional and no way
|
|
281
|
+
to omit it on a PUT.
|
|
282
|
+
|
|
283
|
+
It follows the same per-event rendering contract as `message_config`
|
|
284
|
+
(§2 above), but targets the **action_log** row instead of the message
|
|
285
|
+
row: it must render a JSON object containing `external_id` plus at
|
|
286
|
+
least one updatable action-log field — one of `status`, `sent_at`, or
|
|
287
|
+
`metadata`. A minimal valid value:
|
|
288
|
+
|
|
289
|
+
```liquid
|
|
290
|
+
{"external_id": "{{ notification.messageId }}", "status": "delivered"}
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
Two distinct rejections come off this field, and they read differently:
|
|
294
|
+
|
|
295
|
+
- **Missing entirely** — omitting `action_log_config` on create or
|
|
296
|
+
update is a 422 with `Missing field: action_log_config` at
|
|
297
|
+
`source.pointer: /action_log_config`.
|
|
298
|
+
- **Present but renders nothing** — a value that passes shallow
|
|
299
|
+
validation but renders `null` or an empty result at run time (e.g.
|
|
300
|
+
a `{"type": "null"}` config) is refused with a 422 whose detail
|
|
301
|
+
begins `must render a JSON object containing external_id`.
|
|
302
|
+
|
|
303
|
+
**Response nullability is lazy, not retroactive.** Updaters created
|
|
304
|
+
before this became required still read back `action_log_config: null`
|
|
305
|
+
— the platform does not backfill a template onto old rows. That
|
|
306
|
+
`null` persists until the row's **next edit** forces a real template
|
|
307
|
+
through, and "next edit" is broader than a plain field change: it
|
|
308
|
+
also includes resuming a halted updater by setting `status` back to
|
|
309
|
+
`'active'` (§7) and issuing a `refresh()` call with a one-shot
|
|
310
|
+
`updater_body` override (§7) — both now require `action_log_config`
|
|
311
|
+
to be present too, which is what completes the migration for an old
|
|
312
|
+
row.
|
|
313
|
+
|
|
126
314
|
### `cron_expression` is a standard 5-field cron string
|
|
127
315
|
|
|
128
316
|
```
|
|
@@ -201,6 +389,7 @@ sender_tool_ids required UUID[] — tools whose messages this updater trac
|
|
|
201
389
|
datalake_id required UUID
|
|
202
390
|
updater_body required embed — { updater_body_type, ... }
|
|
203
391
|
message_config required embed — { type, body }
|
|
392
|
+
action_log_config required embed — { type, body } (required on create AND update)
|
|
204
393
|
```
|
|
205
394
|
|
|
206
395
|
**Write-only (Request-only).** None.
|
|
@@ -216,6 +405,8 @@ Standard JSON:API envelopes per `errors.md`. Common rejections:
|
|
|
216
405
|
| `/sender_tool_ids/0` | tool id doesn't exist or wrong datalake |
|
|
217
406
|
| `/updater_body/updater_body_type` | value not in the polymorphic embed's type enum |
|
|
218
407
|
| `/message_config/body` | empty when `type: 'custom'` |
|
|
408
|
+
| `/action_log_config` | missing entirely — `Missing field: action_log_config` (create AND update) |
|
|
409
|
+
| (detail, not pointer) | `action_log_config` present but renders `null`/empty — detail begins `must render a JSON object containing external_id` |
|
|
219
410
|
| `/base` | `cron_management_failed` — body validated and saved but scheduler registration failed; the row is rolled back |
|
|
220
411
|
|
|
221
412
|
## 5. Lifecycle
|
|
@@ -318,7 +509,7 @@ check or retry for partial-delete states.
|
|
|
318
509
|
The poll follows pages while `has_next` is true and stops on
|
|
319
510
|
the first empty page.
|
|
320
511
|
|
|
321
|
-
## 7. Manual trigger via `refresh` —
|
|
512
|
+
## 7. Manual trigger via `refresh` — 202, and what `last_run_*` means
|
|
322
513
|
|
|
323
514
|
An action status updater CAN be fired on demand:
|
|
324
515
|
|
|
@@ -328,28 +519,85 @@ const { data } = await api.actionStatusUpdaters.refresh(
|
|
|
328
519
|
);
|
|
329
520
|
```
|
|
330
521
|
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
522
|
+
**`refresh` answers `202` with the updater row AS-IS — the poll is
|
|
523
|
+
ENQUEUED, not run.** The `last_run_*` fields on that response are the
|
|
524
|
+
PREVIOUS run's stamps; reading them as the outcome of the refresh you
|
|
525
|
+
just issued is the single most common mistake here. An optional body
|
|
526
|
+
carries a one-shot `updater_body` override (e.g. a widened
|
|
527
|
+
`start_time`/`end_time` window for a backfill) that does not mutate
|
|
528
|
+
the persisted updater. Apply jobs are asynchronous too — observe
|
|
529
|
+
delivery status on the message rows, not in this response.
|
|
530
|
+
(CLI: `alvera refresh-action-status-updater --id <uuid>`, alias
|
|
531
|
+
`refresh-updater`.)
|
|
532
|
+
|
|
533
|
+
The correct read is baseline-then-advance:
|
|
534
|
+
|
|
535
|
+
```ts
|
|
536
|
+
const before = await api.actionStatusUpdaters.get(tenantSlug, datalakeSlug, updaterId)
|
|
537
|
+
const baseline = before.data.last_run_at ?? null // BEFORE refreshing
|
|
538
|
+
|
|
539
|
+
await api.actionStatusUpdaters.refresh(tenantSlug, datalakeSlug, updaterId)
|
|
540
|
+
|
|
541
|
+
let row = before.data
|
|
542
|
+
const deadline = Date.now() + 60_000
|
|
543
|
+
while (Date.now() < deadline) {
|
|
544
|
+
await new Promise((r) => setTimeout(r, 2_000))
|
|
545
|
+
const { data } = await api.actionStatusUpdaters.get(tenantSlug, datalakeSlug, updaterId)
|
|
546
|
+
if (data.last_run_at && data.last_run_at !== baseline) { row = data; break }
|
|
547
|
+
}
|
|
548
|
+
// row.last_run_status / last_run_error now describe THIS run
|
|
549
|
+
```
|
|
335
550
|
|
|
336
551
|
What the ASU still does NOT have is a **per-run log** (nothing like
|
|
337
552
|
a data activation client's `logs` or a workflow's `batchLogs`). The
|
|
338
|
-
|
|
339
|
-
|
|
553
|
+
run-outcome surface is four fields on the row itself, plus the halt
|
|
554
|
+
flag:
|
|
340
555
|
|
|
341
556
|
```
|
|
342
|
-
last_run_at when the poll worker last
|
|
343
|
-
last_run_status 'ok' | 'error'
|
|
344
|
-
last_run_error why the last run failed
|
|
345
|
-
last_run_events_found
|
|
557
|
+
last_run_at when the poll worker last made progress
|
|
558
|
+
last_run_status 'ok' | 'partial' | 'error'
|
|
559
|
+
last_run_error why the last run failed, or why a partial run was truncated
|
|
560
|
+
last_run_events_found events fetched so far in the current run
|
|
561
|
+
status 'active' | 'cycle_detected'
|
|
346
562
|
```
|
|
347
563
|
|
|
564
|
+
**A poll run has three outcomes, and only `'ok'` means it reconciled its
|
|
565
|
+
whole time window:**
|
|
566
|
+
|
|
567
|
+
- `'ok'` — the run completed and read its entire window. This is the only
|
|
568
|
+
value that proves the reconciliation is complete.
|
|
569
|
+
- `'partial'` — the run completed but its provider fetch was **truncated**:
|
|
570
|
+
the window was not fully read, and the events it missed are the **newest**
|
|
571
|
+
ones. `last_run_error` carries the detail (it is **not** null on a
|
|
572
|
+
`partial` run). A client that treats `last_run_status !== 'error'` as
|
|
573
|
+
healthy silently accepts this incomplete reconciliation — check
|
|
574
|
+
`last_run_status === 'ok'` instead.
|
|
575
|
+
- `'error'` — the run failed; `last_run_error` says why.
|
|
576
|
+
|
|
577
|
+
`last_run_events_found` is the event count for the current run; because
|
|
578
|
+
`refresh` is asynchronous (below), a read taken before the run finishes can
|
|
579
|
+
legitimately show a smaller, in-progress figure than the final count.
|
|
580
|
+
|
|
581
|
+
**`status` is the halt flag.** The server sets `'cycle_detected'` when
|
|
582
|
+
a run re-reads events it has already handled — the runtime backstop for
|
|
583
|
+
a poll whose pagination does not advance (a provider cursor that never
|
|
584
|
+
drains, or a one-shot `updater_body` override). Every later job then
|
|
585
|
+
fails without calling the provider, and `last_run_error` carries the
|
|
586
|
+
diagnostic. **Nothing clears it but setting `status` back to
|
|
587
|
+
`'active'`.** It is also the cheapest deterministic failure to assert
|
|
588
|
+
on: a non-advancing config trips it within seconds, no provider
|
|
589
|
+
credential rejection required. Related: a `restapi` updater that
|
|
590
|
+
declares pagination but whose `path`/`params`/`body` never read
|
|
591
|
+
`msg.pagination_context` or `msg.page` is now refused at create/update
|
|
592
|
+
— see §2's template contract.
|
|
593
|
+
|
|
348
594
|
**If a message's status is not reconciling, do this — in order:**
|
|
349
595
|
|
|
350
|
-
1. Call `refresh()
|
|
351
|
-
|
|
352
|
-
|
|
596
|
+
1. Call `refresh()`, wait for `last_run_at` to advance past the
|
|
597
|
+
baseline you captured, then read `last_run_status` /
|
|
598
|
+
`last_run_error` / `status`. They name the failing stage: window
|
|
599
|
+
render, source fetch, template render, JSON parse — or a halted
|
|
600
|
+
poll cycle.
|
|
353
601
|
2. Check the config against §2 above. A config that violates the
|
|
354
602
|
template contract is rejected at create, so a *deployed* updater's
|
|
355
603
|
templates are structurally sound — the fault is more likely the
|
package/.agent/ai_sandbox.md
CHANGED
|
@@ -103,10 +103,12 @@ consumers cannot extend the set:
|
|
|
103
103
|
|
|
104
104
|
minutes_ago Unix ms N minutes before a unix-ms input. Bounds
|
|
105
105
|
the start of an ActionStatusUpdater poll window.
|
|
106
|
-
|
|
106
|
+
`now_msec` is the injected unix-ms variable (the
|
|
107
|
+
bare `now` is a DateTime — feeding it here raises).
|
|
108
|
+
Example: {{ now_msec | minutes_ago: 45 }} → 1773585000000
|
|
107
109
|
|
|
108
110
|
hours_ago Unix ms N hours before a unix-ms input.
|
|
109
|
-
Example: {{
|
|
111
|
+
Example: {{ now_msec | hours_ago: 2 }} → 1773578400000
|
|
110
112
|
|
|
111
113
|
uuid Generate a new v4 UUID.
|
|
112
114
|
Example: {{ '' | uuid }} → a1b2c3d4-…
|
package/.agent/connected_apps.md
CHANGED
|
@@ -64,7 +64,8 @@ const { data: created } = await api.connectedApps.create(
|
|
|
64
64
|
)
|
|
65
65
|
// created.id, created.slug — server-derived
|
|
66
66
|
// created.status === 'pending' (transient) → 'synced' after route fetch
|
|
67
|
-
//
|
|
67
|
+
// (an M2M key IS auto-provisioned at create (see §2), but its id is NOT
|
|
68
|
+
// echoed on the resource — no response carries an api_key_id field)
|
|
68
69
|
// created.routes — array populated from .well-known/routes.json
|
|
69
70
|
// (empty until first sync)
|
|
70
71
|
// created.last_synced_at — set when routes successfully validated
|
|
@@ -191,9 +192,13 @@ last_synced_at string — ISO 8601; set at successful route sync
|
|
|
191
192
|
error string — populated when status === 'error'
|
|
192
193
|
routes array — discovered from .well-known/routes.json
|
|
193
194
|
(each: { name, path, description? })
|
|
194
|
-
api_key_id UUID — id of the auto-provisioned M2M key
|
|
195
195
|
```
|
|
196
196
|
|
|
197
|
+
(The auto-provisioned M2M key's id is **not** among these — neither
|
|
198
|
+
`.get` nor `get-metadata` returns an `api_key_id`. Don't assert on it
|
|
199
|
+
in a contract hook; the key itself reaches the deployment as
|
|
200
|
+
`ALVERA_API_KEY`, §2.)
|
|
201
|
+
|
|
197
202
|
**Caller-supplied (round-trip).**
|
|
198
203
|
|
|
199
204
|
```
|
|
@@ -105,11 +105,16 @@ ctx.cwToolId = cwTool.id!
|
|
|
105
105
|
|
|
106
106
|
The ASU ties it together: a `cron_expression` schedule, the `updater_tool_id`
|
|
107
107
|
(the poller), the `sender_tool_ids` it reconciles for, and an `updater_body`
|
|
108
|
-
naming the log group + the time window. On `start_time`/`end_time`,
|
|
109
|
-
variable holding unix milliseconds —
|
|
108
|
+
naming the log group + the time window. On `start_time`/`end_time`, use
|
|
109
|
+
`now_msec` — the injected variable holding unix milliseconds — piped through
|
|
110
|
+
`minutes_ago`; the bare `now` is a DateTime and raises here. The
|
|
110
111
|
`message_config` Liquid is rendered ONCE PER EVENT, with the event itself as the
|
|
111
112
|
assigns (there is no `events` list to loop), and emits a FLAT object: the
|
|
112
113
|
`external_id` of the message to update plus the fields to set at the top level.
|
|
114
|
+
`action_log_config` is REQUIRED alongside it — same per-event assigns, rendered
|
|
115
|
+
into the action-log write shape: a JSON object with `external_id` plus at least
|
|
116
|
+
one of `status` / `sent_at` / `metadata`. Omitting it is a 422 (`Missing field:
|
|
117
|
+
action_log_config`).
|
|
113
118
|
|
|
114
119
|
```typescript
|
|
115
120
|
const { data: asu } = await api.actionStatusUpdaters.create(tenantSlug, datalakeSlug, {
|
|
@@ -122,13 +127,17 @@ const { data: asu } = await api.actionStatusUpdaters.create(tenantSlug, datalake
|
|
|
122
127
|
updater_body: {
|
|
123
128
|
updater_body_type: 'cloud_watch_request',
|
|
124
129
|
log_group_name: 'sns/us-east-1/000000000000/DirectPublishToPhoneNumber',
|
|
125
|
-
start_time: '{{
|
|
126
|
-
end_time: '{{
|
|
130
|
+
start_time: '{{ now_msec | minutes_ago: 45 }}',
|
|
131
|
+
end_time: '{{ now_msec }}',
|
|
127
132
|
},
|
|
128
133
|
message_config: {
|
|
129
134
|
type: 'custom',
|
|
130
135
|
body: '{"external_id": "{{ notification.messageId }}", "status": "delivered"}',
|
|
131
136
|
},
|
|
137
|
+
action_log_config: {
|
|
138
|
+
type: 'custom',
|
|
139
|
+
body: '{"external_id": "{{ notification.messageId }}", "status": "delivered"}',
|
|
140
|
+
},
|
|
132
141
|
})
|
|
133
142
|
actionStatusUpdaterId = asu.id!
|
|
134
143
|
```
|
|
@@ -155,13 +164,17 @@ const { data: ck } = await api.actionStatusUpdaters.checksum(tenantSlug, datalak
|
|
|
155
164
|
updater_body: {
|
|
156
165
|
updater_body_type: 'cloud_watch_request',
|
|
157
166
|
log_group_name: 'sns/us-east-1/000000000000/DirectPublishToPhoneNumber',
|
|
158
|
-
start_time: '{{
|
|
159
|
-
end_time: '{{
|
|
167
|
+
start_time: '{{ now_msec | minutes_ago: 45 }}',
|
|
168
|
+
end_time: '{{ now_msec }}',
|
|
160
169
|
},
|
|
161
170
|
message_config: {
|
|
162
171
|
type: 'custom',
|
|
163
172
|
body: '{"external_id": "{{ notification.messageId }}", "status": "delivered"}',
|
|
164
173
|
},
|
|
174
|
+
action_log_config: {
|
|
175
|
+
type: 'custom',
|
|
176
|
+
body: '{"external_id": "{{ notification.messageId }}", "status": "delivered"}',
|
|
177
|
+
},
|
|
165
178
|
})
|
|
166
179
|
if (typeof ck.checksum !== 'string' || ck.checksum.length === 0) {
|
|
167
180
|
throw new Error('expected a checksum for the ASU body')
|
|
@@ -190,6 +203,35 @@ if (typeof detail !== 'string' || detail.length === 0) {
|
|
|
190
203
|
}
|
|
191
204
|
```
|
|
192
205
|
|
|
206
|
+
## 007 — write the integration test
|
|
207
|
+
|
|
208
|
+
End the build with a test you keep: one block that re-reads the reconciler
|
|
209
|
+
and asserts the facts the scenario depends on. This block runs live under
|
|
210
|
+
`make validate-cookbook`.
|
|
211
|
+
|
|
212
|
+
```typescript
|
|
213
|
+
// Re-GET — the stored row echoes what was authored.
|
|
214
|
+
const { data: asuRow } = await api.actionStatusUpdaters.get(tenantSlug, datalakeSlug, actionStatusUpdaterId)
|
|
215
|
+
if (asuRow.cron_expression !== '*/30 * * * *') {
|
|
216
|
+
throw new Error(`cron mismatch on read-back: ${asuRow.cron_expression}`)
|
|
217
|
+
}
|
|
218
|
+
// last_run_* is the ONLY run surface (there is no per-run log). On a
|
|
219
|
+
// fresh create it is legitimately null — assert the field EXISTS on the
|
|
220
|
+
// wire, never a value.
|
|
221
|
+
if (!('last_run_status' in asuRow)) {
|
|
222
|
+
throw new Error('ASU response carries no last_run_status field')
|
|
223
|
+
}
|
|
224
|
+
// Behavioural probe — the metadata surface an agent reads must render.
|
|
225
|
+
const { data: asuDetail } = await api.actionStatusUpdaters.metadataDetails(tenantSlug, datalakeSlug, actionStatusUpdaterId)
|
|
226
|
+
if (typeof asuDetail !== 'string' || asuDetail.length === 0) {
|
|
227
|
+
throw new Error('ASU metadataDetails came back empty')
|
|
228
|
+
}
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
If the test fails in production, escalate with the failing read's
|
|
232
|
+
evidence (the response body, `last_run_error`) — don't rewrite the config
|
|
233
|
+
blind.
|
|
234
|
+
|
|
193
235
|
# Gotchas
|
|
194
236
|
|
|
195
237
|
- **The poller tool's intent is `status_poller`, not the sender's intent.** The
|
|
@@ -199,14 +241,31 @@ if (typeof detail !== 'string' || detail.length === 0) {
|
|
|
199
241
|
is the assigns — there is no `events` list to loop — and every top-level key
|
|
200
242
|
that is not `external_id` IS the update (`status`, `delivered_at`, …). There is
|
|
201
243
|
no `set_params` wrapper. Both mistakes are rejected at create.
|
|
202
|
-
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
244
|
+
- **`action_log_config` is REQUIRED on both create and update (PUT), same as
|
|
245
|
+
`message_config`.** Omitting it is a 422 (`Missing field: action_log_config`).
|
|
246
|
+
It renders the same per-event assigns into the action-log write shape:
|
|
247
|
+
`external_id` plus at least one of `status` / `sent_at` / `metadata`.
|
|
248
|
+
- **The schedule is a cron, evaluated platform-side — and one poll cycle can
|
|
249
|
+
be fired on demand.** Creating the ASU registers the schedule. The
|
|
250
|
+
`updater_body` window (`{{ now_msec | minutes_ago: 45 }}` … `{{ now_msec }}`)
|
|
251
|
+
bounds each run's log query, and must render to unix milliseconds — `now_msec`
|
|
252
|
+
is the unix-ms variable, not the bare `now` (a DateTime) or the ISO-8601 `now`
|
|
253
|
+
filter. To trigger a cycle without
|
|
254
|
+
waiting for the cron tick, `api.actionStatusUpdaters.refresh(tenantSlug,
|
|
255
|
+
datalakeSlug, id)` answers `202` with the updater row AS-IS (the poll runs
|
|
256
|
+
async — the row still shows the PREVIOUS run's stamps; re-read later for
|
|
257
|
+
the outcome). See `action_status_updaters.md` §7.
|
|
258
|
+
- **If it doesn't reconcile, read `last_run_error` — refresh, don't guess.**
|
|
259
|
+
There is no per-run log (known limitation): the run surface is the
|
|
260
|
+
read-only `last_run_*` fields. Fire a `refresh`, re-read `last_run_status`
|
|
261
|
+
/ `last_run_events_found` / `last_run_error`, and escalate with that
|
|
262
|
+
evidence — don't rewrite the config blind between cron ticks.
|
|
263
|
+
- **`last_run_status` has three values — only `'ok'` means done.** `'ok'`
|
|
264
|
+
is the run that read its whole time window; `'partial'` completed but its
|
|
265
|
+
provider fetch was truncated, so the newest events may be missing and
|
|
266
|
+
`last_run_error` says so (it is not null on a `partial`); `'error'` failed.
|
|
267
|
+
Check `last_run_status === 'ok'` — a `!== 'error'` check silently accepts
|
|
268
|
+
a truncated `partial` as if it had reconciled.
|
|
210
269
|
- **`checksum` parity is your drift signal.** The fingerprint of the desired body
|
|
211
270
|
must match the stored row's; a mismatch means the resource drifted.
|
|
212
271
|
|
|
@@ -214,6 +214,42 @@ if (!rejected) {
|
|
|
214
214
|
}
|
|
215
215
|
```
|
|
216
216
|
|
|
217
|
+
## 006 — write the integration test
|
|
218
|
+
|
|
219
|
+
End the build with a test you keep: re-read the agent and probe the one
|
|
220
|
+
behaviour that is provable deterministically — the input gate. A real
|
|
221
|
+
extraction (§004) needs a live provider behind the tool; the schema gate
|
|
222
|
+
does not, which is exactly what makes it the durable test. This block
|
|
223
|
+
runs live under `make validate-cookbook`.
|
|
224
|
+
|
|
225
|
+
```typescript
|
|
226
|
+
// Re-GET — the agent must be enabled, or every invoke will refuse.
|
|
227
|
+
const { data: agentRow } = await api.aiAgents.get(tenantSlug, datalakeSlug, aiAgentId)
|
|
228
|
+
if (agentRow.enabled !== true) {
|
|
229
|
+
throw new Error('expected enabled=true on agent read-back')
|
|
230
|
+
}
|
|
231
|
+
// Behavioural probe — the input_schema gate fires BEFORE the model:
|
|
232
|
+
// a malformed input must 422 even with no working provider.
|
|
233
|
+
let gateFired = false
|
|
234
|
+
try {
|
|
235
|
+
await api.aiAgents.invoke(tenantSlug, datalakeSlug, aiAgentId, {
|
|
236
|
+
input: { wrong_field: 'test — no instructions here' },
|
|
237
|
+
files: [],
|
|
238
|
+
})
|
|
239
|
+
} catch (err) {
|
|
240
|
+
const status = (err as { _httpStatus?: number })._httpStatus
|
|
241
|
+
if (status !== 422) throw err
|
|
242
|
+
gateFired = true
|
|
243
|
+
}
|
|
244
|
+
if (!gateFired) {
|
|
245
|
+
throw new Error('agent accepted input that violates its input_schema (expected 422)')
|
|
246
|
+
}
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
If the gate stops firing in production, escalate with the
|
|
250
|
+
accepted-payload evidence — don't loosen the `input_schema` to make the
|
|
251
|
+
error go away.
|
|
252
|
+
|
|
217
253
|
# Gotchas
|
|
218
254
|
|
|
219
255
|
- **Uploading a file is not ingesting it.** The upload + `invoke` here makes the
|