@alvera-ai/platform-sdk 0.14.0 → 0.15.0-next.g2f28e7b
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/action_status_updaters.md +113 -14
- package/.agent/ai_agents.md +1 -0
- package/.agent/cookbook/appointment-review-sms-workflow.md +1 -0
- package/.agent/cookbook/birthday-greeting-sms-trigger.md +2 -1
- package/.agent/cookbook/contact-us-triage-with-llm.md +2 -1
- package/.agent/cookbook/dunning-sms-for-delinquent.md +2 -1
- package/.agent/cookbook/kyc-notification-on-account-activation.md +2 -1
- package/.agent/cookbook/marketing-campaign-send.md +1 -0
- package/.agent/cookbook/paginated-restapi-poller.md +62 -8
- package/.agent/cookbook/sanctions-screening-with-agent-review.md +2 -1
- package/.agent/cookbook/score-leads-with-llm-categorization.md +2 -1
- package/.agent/cookbook/triage-prospects-by-priority.md +2 -1
- package/.agent/cookbook/welcome-sms-for-customers.md +2 -1
- package/.agent/datalakes.md +44 -3
- package/.agent/errors.md +7 -1
- package/.agent/workflows.md +23 -0
- package/dist/index.d.mts +574 -113
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
|
@@ -117,6 +117,10 @@ scope — do not loop.
|
|
|
117
117
|
{"external_id": "{{ notification.messageId }}", "status": "delivered"}
|
|
118
118
|
```
|
|
119
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
|
+
|
|
120
124
|
It must render a **flat** JSON object:
|
|
121
125
|
|
|
122
126
|
```
|
|
@@ -139,15 +143,107 @@ events for messages this updater doesn't own.
|
|
|
139
143
|
Custom Liquid filters (`json_escape`, `e164`, `to_json`, ...) are
|
|
140
144
|
available here, as everywhere.
|
|
141
145
|
|
|
142
|
-
###
|
|
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
|
+
```
|
|
143
228
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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.
|
|
151
247
|
|
|
152
248
|
### Carrier information is available for SMS, not MMS
|
|
153
249
|
|
|
@@ -160,12 +256,15 @@ events — not something the platform chooses to drop:
|
|
|
160
256
|
under `delivery.phoneCarrier` (e.g. `"T-mobile USA Inc."`, with
|
|
161
257
|
numeric `mcc`/`mnc`). The SNS status template maps it to
|
|
162
258
|
`sms_carrier`.
|
|
163
|
-
- **MMS via AWS End User Messaging (`sms-voice`)** — the
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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`.
|
|
169
268
|
|
|
170
269
|
AWS's Nov-2024 EventBridge-integration announcement states MMS
|
|
171
270
|
delivery events carry carrier information in EventBridge, but the
|
package/.agent/ai_agents.md
CHANGED
|
@@ -159,6 +159,7 @@ const workflowResp = await api.workflows.create(tenantSlug, datalakeSlug, {
|
|
|
159
159
|
description: 'Sends a review-request SMS with a connected-app form link after a fulfilled appointment.',
|
|
160
160
|
dataset_type: 'appointment',
|
|
161
161
|
status: 'live',
|
|
162
|
+
tags: ['appointments', 'review'],
|
|
162
163
|
skip_mdm_resolution: false,
|
|
163
164
|
filter_config: {
|
|
164
165
|
type: 'custom',
|
|
@@ -157,6 +157,7 @@ const workflowResp = await api.workflows.create(tenantSlug, datalakeSlug, {
|
|
|
157
157
|
description: "Sends a happy-birthday SMS on each contact's next birthday — pure-Liquid trigger does the year-roll math.",
|
|
158
158
|
dataset_type: 'legal_entity',
|
|
159
159
|
status: 'live',
|
|
160
|
+
tags: ['lifecycle', 'birthday'],
|
|
160
161
|
skip_mdm_resolution: false,
|
|
161
162
|
filter_config: {
|
|
162
163
|
type: 'custom',
|
|
@@ -175,7 +176,7 @@ const workflowResp = await api.workflows.create(tenantSlug, datalakeSlug, {
|
|
|
175
176
|
position: 0,
|
|
176
177
|
trigger_template: TRIGGER_TEMPLATE,
|
|
177
178
|
idempotency_template:
|
|
178
|
-
'{{ subject_id }}-{{ workflow_id }}-{{ action_id }}
|
|
179
|
+
'{{ subject_id }}-{{ workflow_id }}-{{ action_id }}',
|
|
179
180
|
connected_app_id: connectedAppId,
|
|
180
181
|
connected_app_route: '/forms/birthday-greeting',
|
|
181
182
|
connected_app_metadata_template:
|
|
@@ -310,6 +310,7 @@ const workflowResp = await api.workflows.create(tenantSlug, datalakeSlug, {
|
|
|
310
310
|
generic_table_id: genericTableId,
|
|
311
311
|
skip_mdm_resolution: true,
|
|
312
312
|
status: 'live',
|
|
313
|
+
tags: ['support', 'triage'],
|
|
313
314
|
filter_config: {
|
|
314
315
|
type: 'custom',
|
|
315
316
|
body: 'true',
|
|
@@ -326,7 +327,7 @@ const workflowResp = await api.workflows.create(tenantSlug, datalakeSlug, {
|
|
|
326
327
|
tool_id: toolId,
|
|
327
328
|
position: 0,
|
|
328
329
|
trigger_template: 'now',
|
|
329
|
-
idempotency_template: `{{ subject_id }}-{{ action_id }}-${bucket}
|
|
330
|
+
idempotency_template: `{{ subject_id }}-{{ action_id }}-${bucket}`,
|
|
330
331
|
tool_call: {
|
|
331
332
|
tool_call_type: 'sms_request',
|
|
332
333
|
to: { type: 'custom', body: '+15551234567' },
|
|
@@ -162,6 +162,7 @@ const workflowResp = await api.workflows.create(tenantSlug, datalakeSlug, {
|
|
|
162
162
|
description: 'Sends a payment-reminder SMS to delinquent contracted customers with a self-serve pay link.',
|
|
163
163
|
dataset_type: 'customer',
|
|
164
164
|
status: 'live',
|
|
165
|
+
tags: ['billing', 'dunning'],
|
|
165
166
|
filter_config: {
|
|
166
167
|
type: 'custom',
|
|
167
168
|
body: FILTER_BODY,
|
|
@@ -189,7 +190,7 @@ const workflowResp = await api.workflows.create(tenantSlug, datalakeSlug, {
|
|
|
189
190
|
decision_key: DECISION_KEY,
|
|
190
191
|
position: 0,
|
|
191
192
|
trigger_template: 'now',
|
|
192
|
-
idempotency_template: '{{ customer_id }}-{{ decision_key }}
|
|
193
|
+
idempotency_template: '{{ customer_id }}-{{ decision_key }}',
|
|
193
194
|
connected_app_id: connectedAppId,
|
|
194
195
|
connected_app_route: '/portal/pay',
|
|
195
196
|
connected_app_metadata_template:
|
|
@@ -160,6 +160,7 @@ const workflowResp = await api.workflows.create(tenantSlug, datalakeSlug, {
|
|
|
160
160
|
description: 'Sends a KYC-notification SMS for newly activated payment accounts with a self-serve KYC portal link.',
|
|
161
161
|
dataset_type: 'payment_account',
|
|
162
162
|
status: 'live',
|
|
163
|
+
tags: ['compliance', 'kyc'],
|
|
163
164
|
filter_config: {
|
|
164
165
|
type: 'custom',
|
|
165
166
|
body: FILTER_BODY,
|
|
@@ -188,7 +189,7 @@ const workflowResp = await api.workflows.create(tenantSlug, datalakeSlug, {
|
|
|
188
189
|
decision_key: DECISION_KEY,
|
|
189
190
|
position: 0,
|
|
190
191
|
trigger_template: 'now',
|
|
191
|
-
idempotency_template: '{{ payment_account.id }}-{{ decision_key }}
|
|
192
|
+
idempotency_template: '{{ payment_account.id }}-{{ decision_key }}',
|
|
192
193
|
connected_app_id: connectedAppId,
|
|
193
194
|
connected_app_route: '/portal/kyc',
|
|
194
195
|
connected_app_metadata_template: '{"payment_account_id":"{{ payment_account.id }}"}',
|
|
@@ -609,6 +609,7 @@ const { data: workflow } = await api.workflows.create(tenantSlug, datalakeSlug,
|
|
|
609
609
|
dataset_type: 'generic_table',
|
|
610
610
|
generic_table_id: ctx.audienceTableId,
|
|
611
611
|
status: 'live',
|
|
612
|
+
tags: ['marketing', 'loyalty'],
|
|
612
613
|
skip_mdm_resolution: false,
|
|
613
614
|
filter_config: { type: 'custom', body: CAMPAIGN_FILTER, output_schema: { type: 'boolean' } },
|
|
614
615
|
decision_config: {
|
|
@@ -128,7 +128,17 @@ try {
|
|
|
128
128
|
updater_tool_id: ctx.restToolId,
|
|
129
129
|
sender_tool_ids: [ctx.senderToolId],
|
|
130
130
|
datalake_id: ctx.datalakeId,
|
|
131
|
-
|
|
131
|
+
// FLOOR the platform enforces: an array whose items are objects listing
|
|
132
|
+
// `external_id` in `required`. A bare `{ type: 'array' }` is a 422 at
|
|
133
|
+
// create — every event has to name the message it reconciles.
|
|
134
|
+
events_output_schema: {
|
|
135
|
+
type: 'array',
|
|
136
|
+
items: {
|
|
137
|
+
type: 'object',
|
|
138
|
+
required: ['external_id'],
|
|
139
|
+
properties: { external_id: { type: 'string' } },
|
|
140
|
+
},
|
|
141
|
+
},
|
|
132
142
|
pagination_context_output_schema: {
|
|
133
143
|
type: 'object',
|
|
134
144
|
required: ['has_next'],
|
|
@@ -140,7 +150,18 @@ try {
|
|
|
140
150
|
// Static on both — the cursor is captured below and never read back.
|
|
141
151
|
path: { type: 'custom', body: '/wiremock.domain/events' },
|
|
142
152
|
params: { type: 'custom', body: '{"event": "delivered"}' },
|
|
143
|
-
|
|
153
|
+
// Providers name their own id — `message-id` here, `messageId` / `sid`
|
|
154
|
+
// elsewhere. The events_template is where that becomes `external_id`:
|
|
155
|
+
// the apply path POPS that key off every rendered event to find the row
|
|
156
|
+
// it updates, so the render PROJECTS each event rather than passing the
|
|
157
|
+
// provider body through untouched.
|
|
158
|
+
events_template: {
|
|
159
|
+
type: 'custom',
|
|
160
|
+
body:
|
|
161
|
+
'[{% for item in response.items %}' +
|
|
162
|
+
'{"external_id": "{{ item.message.headers[\'message-id\'] }}", "event": "{{ item.event }}"}' +
|
|
163
|
+
'{% unless forloop.last %},{% endunless %}{% endfor %}]',
|
|
164
|
+
},
|
|
144
165
|
pagination_context_template: {
|
|
145
166
|
type: 'custom',
|
|
146
167
|
body:
|
|
@@ -150,11 +171,11 @@ try {
|
|
|
150
171
|
},
|
|
151
172
|
message_config: {
|
|
152
173
|
type: 'custom',
|
|
153
|
-
body: '{"external_id": "{{
|
|
174
|
+
body: '{"external_id": "{{ external_id }}", "status": "delivered"}',
|
|
154
175
|
},
|
|
155
176
|
action_log_config: {
|
|
156
177
|
type: 'custom',
|
|
157
|
-
body: '{"external_id": "{{
|
|
178
|
+
body: '{"external_id": "{{ external_id }}", "status": "delivered"}',
|
|
158
179
|
},
|
|
159
180
|
})
|
|
160
181
|
} catch (err) {
|
|
@@ -182,7 +203,14 @@ const { data: asu } = await api.actionStatusUpdaters.create(tenantSlug, datalake
|
|
|
182
203
|
updater_tool_id: ctx.restToolId,
|
|
183
204
|
sender_tool_ids: [ctx.senderToolId],
|
|
184
205
|
datalake_id: ctx.datalakeId,
|
|
185
|
-
events_output_schema: {
|
|
206
|
+
events_output_schema: {
|
|
207
|
+
type: 'array',
|
|
208
|
+
items: {
|
|
209
|
+
type: 'object',
|
|
210
|
+
required: ['external_id'],
|
|
211
|
+
properties: { external_id: { type: 'string' } },
|
|
212
|
+
},
|
|
213
|
+
},
|
|
186
214
|
pagination_context_output_schema: {
|
|
187
215
|
type: 'object',
|
|
188
216
|
required: ['has_next'],
|
|
@@ -201,7 +229,16 @@ const { data: asu } = await api.actionStatusUpdaters.create(tenantSlug, datalake
|
|
|
201
229
|
type: 'custom',
|
|
202
230
|
body: '{% unless msg.pagination_context %}{"event": "delivered"}{% endunless %}',
|
|
203
231
|
},
|
|
204
|
-
|
|
232
|
+
// The provider's own id (`message-id`) becomes `external_id` HERE — the
|
|
233
|
+
// apply path pops that key to find the row it reconciles, so project each
|
|
234
|
+
// event instead of passing the provider body through untouched.
|
|
235
|
+
events_template: {
|
|
236
|
+
type: 'custom',
|
|
237
|
+
body:
|
|
238
|
+
'[{% for item in response.items %}' +
|
|
239
|
+
'{"external_id": "{{ item.message.headers[\'message-id\'] }}", "event": "{{ item.event }}"}' +
|
|
240
|
+
'{% unless forloop.last %},{% endunless %}{% endfor %}]',
|
|
241
|
+
},
|
|
205
242
|
pagination_context_template: {
|
|
206
243
|
type: 'custom',
|
|
207
244
|
body:
|
|
@@ -211,11 +248,11 @@ const { data: asu } = await api.actionStatusUpdaters.create(tenantSlug, datalake
|
|
|
211
248
|
},
|
|
212
249
|
message_config: {
|
|
213
250
|
type: 'custom',
|
|
214
|
-
body: '{"external_id": "{{
|
|
251
|
+
body: '{"external_id": "{{ external_id }}", "status": "delivered"}',
|
|
215
252
|
},
|
|
216
253
|
action_log_config: {
|
|
217
254
|
type: 'custom',
|
|
218
|
-
body: '{"external_id": "{{
|
|
255
|
+
body: '{"external_id": "{{ external_id }}", "status": "delivered"}',
|
|
219
256
|
},
|
|
220
257
|
})
|
|
221
258
|
actionStatusUpdaterId = asu.id!
|
|
@@ -298,6 +335,23 @@ config and wait for another tick.
|
|
|
298
335
|
`events_output_schema` on every cycle; the pagination render is validated
|
|
299
336
|
against `pagination_context_output_schema` (which must require `has_next`).
|
|
300
337
|
Both schemas are REQUIRED for `restapi` updaters — blank is a 422.
|
|
338
|
+
- **Each schema has a FLOOR the platform enforces, above which the contract is
|
|
339
|
+
yours.** `events_output_schema` must describe an **array whose `items` are
|
|
340
|
+
objects listing `external_id` in `required`**; a bare `{ type: 'array' }` is a
|
|
341
|
+
422 at create. `pagination_context_output_schema` must be an **object listing
|
|
342
|
+
`has_next` in `required`** — that key is what ends the page loop. Demand more
|
|
343
|
+
of your provider on top if you like; only the floor is checked.
|
|
344
|
+
- **The floor exists because `external_id` is how reconciliation finds the row.**
|
|
345
|
+
`apply_status_update` pops that key off every rendered event, so mapping the
|
|
346
|
+
provider's own id (`message-id` / `messageId` / `sid`) into `external_id` is
|
|
347
|
+
the **`events_template`'s job** — project each event, never pass the provider
|
|
348
|
+
body through untouched. A schema that satisfies the floor while the template
|
|
349
|
+
emits raw provider rows creates cleanly and then reconciles nothing on every
|
|
350
|
+
cycle. `message_config` / `action_log_config` then read the **mapped**
|
|
351
|
+
`{{ external_id }}`, not the provider's original key.
|
|
352
|
+
- **`action_log_config` is required alongside `message_config`** for every
|
|
353
|
+
updater type, and both are cast against the *same* pinned reconciliation
|
|
354
|
+
schema — so one template body satisfies both.
|
|
301
355
|
- **A newly created poller is `status: 'active'`.** `cycle_detected` is only
|
|
302
356
|
ever set at runtime by the poll driver — never by a caller; you cannot
|
|
303
357
|
create your way into it.
|
|
@@ -270,6 +270,7 @@ const workflowResp = await api.workflows.create(tenantSlug, datalakeSlug, {
|
|
|
270
270
|
description: 'Runs an LLM disambiguation pass on gray-zone-scored sanctions matches and fires a verdict-keyed SMS.',
|
|
271
271
|
dataset_type: 'compliance_screening',
|
|
272
272
|
status: 'live',
|
|
273
|
+
tags: ['compliance', 'sanctions'],
|
|
273
274
|
filter_config: {
|
|
274
275
|
type: 'custom',
|
|
275
276
|
body: GRAY_ZONE_FILTER,
|
|
@@ -286,7 +287,7 @@ const workflowResp = await api.workflows.create(tenantSlug, datalakeSlug, {
|
|
|
286
287
|
tool_id: toolId,
|
|
287
288
|
position: 0,
|
|
288
289
|
trigger_template: 'now',
|
|
289
|
-
idempotency_template: `{{ compliance_screening.id }}-${verdict}
|
|
290
|
+
idempotency_template: `{{ compliance_screening.id }}-${verdict}`,
|
|
290
291
|
tool_call: {
|
|
291
292
|
tool_call_type: 'sms_request',
|
|
292
293
|
to: { type: 'custom', body: '+15550000000' },
|
|
@@ -321,6 +321,7 @@ const workflowResp = await api.workflows.create(tenantSlug, datalakeSlug, {
|
|
|
321
321
|
generic_table_id: genericTableId,
|
|
322
322
|
skip_mdm_resolution: true,
|
|
323
323
|
status: 'live',
|
|
324
|
+
tags: ['leads', 'llm'],
|
|
324
325
|
filter_config: {
|
|
325
326
|
type: 'custom',
|
|
326
327
|
body: 'true',
|
|
@@ -337,7 +338,7 @@ const workflowResp = await api.workflows.create(tenantSlug, datalakeSlug, {
|
|
|
337
338
|
tool_id: toolId,
|
|
338
339
|
position: 0,
|
|
339
340
|
trigger_template: 'now',
|
|
340
|
-
idempotency_template: `{{ subject_id }}-{{ action_id }}-${band}
|
|
341
|
+
idempotency_template: `{{ subject_id }}-{{ action_id }}-${band}`,
|
|
341
342
|
tool_call: {
|
|
342
343
|
tool_call_type: 'sms_request',
|
|
343
344
|
to: { type: 'custom', body: '+15551234567' },
|
|
@@ -293,6 +293,7 @@ const workflowResp = await api.workflows.create(tenantSlug, datalakeSlug, {
|
|
|
293
293
|
description: 'Bands AR customers into priority_high/medium/low via an LLM agent; one SMS action per band.',
|
|
294
294
|
dataset_type: 'customer',
|
|
295
295
|
status: 'live',
|
|
296
|
+
tags: ['ar', 'triage'],
|
|
296
297
|
filter_config: {
|
|
297
298
|
type: 'custom',
|
|
298
299
|
body: '{% if customer.phone and customer.tax_id %}true{% endif %}',
|
|
@@ -309,7 +310,7 @@ const workflowResp = await api.workflows.create(tenantSlug, datalakeSlug, {
|
|
|
309
310
|
tool_id: toolId,
|
|
310
311
|
position: 0,
|
|
311
312
|
trigger_template: 'now',
|
|
312
|
-
idempotency_template: `{{ customer_id }}-${band}
|
|
313
|
+
idempotency_template: `{{ customer_id }}-${band}`,
|
|
313
314
|
tool_call: {
|
|
314
315
|
tool_call_type: 'sms_request',
|
|
315
316
|
to: { type: 'custom', body: '{{ mdm_output.regulated_customer.phone }}' },
|
|
@@ -155,6 +155,7 @@ const workflowResp = await api.workflows.create(tenantSlug, datalakeSlug, {
|
|
|
155
155
|
description: 'Sends a welcome SMS to newly contracted customers with a self-serve billing link.',
|
|
156
156
|
dataset_type: 'customer',
|
|
157
157
|
status: 'live',
|
|
158
|
+
tags: ['lifecycle', 'welcome'],
|
|
158
159
|
filter_config: {
|
|
159
160
|
type: 'custom',
|
|
160
161
|
body: FILTER_BODY,
|
|
@@ -182,7 +183,7 @@ const workflowResp = await api.workflows.create(tenantSlug, datalakeSlug, {
|
|
|
182
183
|
decision_key: DECISION_KEY,
|
|
183
184
|
position: 0,
|
|
184
185
|
trigger_template: 'now',
|
|
185
|
-
idempotency_template: '{{ customer_id }}-{{ decision_key }}
|
|
186
|
+
idempotency_template: '{{ customer_id }}-{{ decision_key }}',
|
|
186
187
|
connected_app_id: connectedAppId,
|
|
187
188
|
connected_app_route: '/portal/welcome',
|
|
188
189
|
connected_app_metadata_template: '{"customer_id":"{{ mdm_output.customer.id }}"}',
|
package/.agent/datalakes.md
CHANGED
|
@@ -240,7 +240,7 @@ a hostname pattern; values like `"invalid_host!"` (with
|
|
|
240
240
|
non-hostname punctuation) are rejected.
|
|
241
241
|
|
|
242
242
|
This is the only field-format constraint surfaced before the
|
|
243
|
-
synchronous reachability probe runs (see §
|
|
243
|
+
synchronous reachability probe runs (see §8 Gotcha 9) — a
|
|
244
244
|
malformed identifier never gets as far as a connection attempt.
|
|
245
245
|
|
|
246
246
|
### Schema names must be unique within a database
|
|
@@ -576,7 +576,7 @@ agent-facing markdown, or the whole-domain catalog can be fetched
|
|
|
576
576
|
in one shot via `api.datasets.metadata(tenantSlug, datalakeSlug)`. Pair `.systemDatasets()` with a
|
|
577
577
|
`page_size: 1` probe per name as a post-migration smoke (proves
|
|
578
578
|
each industry-built table is queryable; the post-ready probe
|
|
579
|
-
code is in §
|
|
579
|
+
code is in §8 Gotcha 3).
|
|
580
580
|
|
|
581
581
|
```typescript
|
|
582
582
|
const { data: catalog } = await api.datalakes.systemDatasets(
|
|
@@ -641,7 +641,48 @@ const { data: csv } = await api.datalakes.executeSql(
|
|
|
641
641
|
) // csv is a string, not the JSON envelope
|
|
642
642
|
```
|
|
643
643
|
|
|
644
|
-
## 6.
|
|
644
|
+
## 6. Logical name → physical table
|
|
645
|
+
|
|
646
|
+
The name you author is not the name you query. Datasets are addressed
|
|
647
|
+
**logically** everywhere in a manifest or SDK call, and **physically** in SQL:
|
|
648
|
+
|
|
649
|
+
| You author | You query in SQL | Schema |
|
|
650
|
+
|---|---|---|
|
|
651
|
+
| `message` | `regulated_messages` | `app_regulated` |
|
|
652
|
+
| `action_log` | `action_logs` | `app_unregulated` |
|
|
653
|
+
| a generic table `clients` | `regulated_alvera_custom_clients` | `app_regulated` |
|
|
654
|
+
|
|
655
|
+
Custom (generic) tables carry an `alvera_custom_` prefix, and the regulated side
|
|
656
|
+
adds a further `regulated_` prefix on top. The schema follows `execute-sql`'s
|
|
657
|
+
`--mode` (`regulated` → `app_regulated`, `unregulated` → `app_unregulated`).
|
|
658
|
+
|
|
659
|
+
**Don't derive these by hand — read them.** Guessing column and table names is
|
|
660
|
+
how six turns get spent on `column "error_message" does not exist` (the real
|
|
661
|
+
column is `failure_reason`):
|
|
662
|
+
|
|
663
|
+
- `alvera get-metadata dataset --only <type>` prints the **physical dataset
|
|
664
|
+
name** plus every column, from the Ecto struct itself.
|
|
665
|
+
- `alvera get-metadata generic-table --only <slug>` prints the exact **primary
|
|
666
|
+
table alias** to use in a `WHERE` clause, with a worked `SELECT`.
|
|
667
|
+
|
|
668
|
+
An `execute-sql` failure on an undefined column, relation, table, or schema now
|
|
669
|
+
names these verbs in its error, so the correction path is one command away.
|
|
670
|
+
|
|
671
|
+
## 7. The roster owns the dedupe uri — don't inject it per source
|
|
672
|
+
|
|
673
|
+
MDM matches on the full identification triple, so a `source_uri` injected
|
|
674
|
+
per data source participates in matching. Two sources describing the same
|
|
675
|
+
subject then produce two different triples, and the same subject is written
|
|
676
|
+
**twice** instead of being reconciled onto one entity.
|
|
677
|
+
|
|
678
|
+
Derive the dedupe uri **once, at the roster**, and let every data source inherit
|
|
679
|
+
it. Never compute a per-source `source_uri` and expect MDM to see through it.
|
|
680
|
+
|
|
681
|
+
This divergence is **not visible through `get-metadata`**: the roster value and
|
|
682
|
+
the per-source values each look well-formed in isolation, and only the duplicate
|
|
683
|
+
rows downstream reveal the mismatch. Check the derivation, not the metadata.
|
|
684
|
+
|
|
685
|
+
## 8. Gotchas
|
|
645
686
|
|
|
646
687
|
1. **`create()` does NOT auto-enqueue migration.** The create response
|
|
647
688
|
returns immediately with `status: 'new'`; Datalake-DB-resident
|
package/.agent/errors.md
CHANGED
|
@@ -125,11 +125,17 @@ body):
|
|
|
125
125
|
{ "source": { "pointer": "/name" },
|
|
126
126
|
"detail": "Missing field: name" },
|
|
127
127
|
{ "source": { "pointer": "/dataset_type" },
|
|
128
|
-
"detail": "Missing field: dataset_type" }
|
|
128
|
+
"detail": "Missing field: dataset_type" },
|
|
129
|
+
{ "source": { "pointer": "/tags" },
|
|
130
|
+
"detail": "Missing field: tags" }
|
|
129
131
|
]
|
|
130
132
|
}
|
|
131
133
|
```
|
|
132
134
|
|
|
135
|
+
`tags` is the easy one to miss: it is required with no default, so
|
|
136
|
+
omitting it 422s even though an empty list is a perfectly ordinary
|
|
137
|
+
workflow. Send `tags: []` (see `workflows.md` §2).
|
|
138
|
+
|
|
133
139
|
**Layer 1 — invalid enum value** (`dataset_type: "nonexistent_type"`):
|
|
134
140
|
|
|
135
141
|
```json
|
package/.agent/workflows.md
CHANGED
|
@@ -49,6 +49,7 @@ const { data: created } = await api.workflows.create(
|
|
|
49
49
|
description: 'Send a review-request SMS after a fulfilled appointment',
|
|
50
50
|
dataset_type: 'appointment', // the dataset this workflow listens on
|
|
51
51
|
status: 'live', // 'live' | 'draft' | 'manual' (see §5)
|
|
52
|
+
tags: ['appointments', 'sms'], // REQUIRED on every write — [] if untagged (see §2)
|
|
52
53
|
filter_config: {
|
|
53
54
|
type: 'custom',
|
|
54
55
|
body: `{% if appointment.source_uri == "12345.example.com" %}true{% endif %}`,
|
|
@@ -138,6 +139,25 @@ authors are Complex) — don't assume every `{ type, body }`-shaped config
|
|
|
138
139
|
also carries a request-writable `output_schema`; check whether the field
|
|
139
140
|
is pinned or caller-authored for that specific config before authoring one.
|
|
140
141
|
|
|
142
|
+
### `tags` is required on every write, and has no default
|
|
143
|
+
|
|
144
|
+
`tags` is a `string[]` on both the request and the response, and it is
|
|
145
|
+
in `required:`. **Every `create()` / `update()` without it is a 422.**
|
|
146
|
+
Send `tags: []` for an untagged workflow — the empty array is how you
|
|
147
|
+
say "no tags", not something you can omit.
|
|
148
|
+
|
|
149
|
+
The missing default is deliberate. `update()` is full-replacement (there
|
|
150
|
+
is no PATCH, and no add-tag / remove-tag endpoint), so an omitted key
|
|
151
|
+
cannot mean "leave tags alone". With a default, omitting it would have
|
|
152
|
+
silently emptied a workflow's tags and returned 200. A 422 beats losing
|
|
153
|
+
state the caller never mentioned.
|
|
154
|
+
|
|
155
|
+
The labels are free text — no taxonomy, no shared vocabulary, and the
|
|
156
|
+
execution pipeline does not read them. But they **do** participate in the
|
|
157
|
+
workflow checksum, so retagging shifts the drift fingerprint. Re-tag and
|
|
158
|
+
`checksum()` returns a different value even though nothing executable
|
|
159
|
+
changed.
|
|
160
|
+
|
|
141
161
|
### `update()` replaces inline arrays whole
|
|
142
162
|
|
|
143
163
|
`actions` and `context_datasets` are replaced whole by `update()`.
|
|
@@ -322,6 +342,7 @@ are removed, and re-supplied ones are repositioned.
|
|
|
322
342
|
await api.workflows.create(tenantSlug, datalakeSlug, {
|
|
323
343
|
name: 'Contact-Us Triage',
|
|
324
344
|
dataset_type: 'generic_table',
|
|
345
|
+
tags: [],
|
|
325
346
|
// …other workflow fields…
|
|
326
347
|
workflow_ai_agents: [
|
|
327
348
|
{
|
|
@@ -412,6 +433,8 @@ name required string
|
|
|
412
433
|
description optional string
|
|
413
434
|
dataset_type required string — the dataset this listens on
|
|
414
435
|
status required enum — 'live' | 'draft' | 'manual' (§5 Create)
|
|
436
|
+
tags required array — string[]; NO default, send [] if
|
|
437
|
+
untagged; feeds the checksum (§2)
|
|
415
438
|
generic_table_id optional UUID — required iff dataset_type='generic_table'
|
|
416
439
|
skip_mdm_resolution optional bool — default false; a deliberate
|
|
417
440
|
choice, NOT a generic-table default (§3)
|