@alvera-ai/platform-sdk 0.15.0 → 0.16.1
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 +10 -0
- package/.agent/ai_agents.md +1 -0
- package/.agent/cookbook/_setup/foundation.md +62 -0
- package/.agent/cookbook/_setup/healthcare.md +62 -0
- package/.agent/cookbook/_setup/payments.md +62 -0
- package/.agent/cookbook/_setup/subscription.md +62 -0
- package/.agent/cookbook/appointment-review-sms-workflow.md +16 -8
- package/.agent/cookbook/birthday-greeting-sms-trigger.md +11 -5
- package/.agent/cookbook/contact-us-triage-with-llm.md +11 -5
- package/.agent/cookbook/dunning-sms-for-delinquent.md +16 -8
- package/.agent/cookbook/kyc-notification-on-account-activation.md +16 -8
- package/.agent/cookbook/marketing-campaign-send.md +13 -7
- package/.agent/cookbook/sanctions-screening-with-agent-review.md +11 -5
- package/.agent/cookbook/score-leads-with-llm-categorization.md +11 -5
- package/.agent/cookbook/triage-prospects-by-priority.md +11 -5
- package/.agent/cookbook/welcome-sms-for-customers.md +16 -8
- package/.agent/errors.md +7 -1
- package/.agent/interoperability_contracts.md +58 -0
- package/.agent/tools.md +140 -4
- package/.agent/workflows.md +137 -38
- package/dist/index.d.mts +974 -151
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +138 -5
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
|
@@ -273,7 +273,7 @@ const ROSTER_MDM = `{% assign p = msg %}
|
|
|
273
273
|
"legal_entity_type": "business",
|
|
274
274
|
"business_name": "{{ p.business_name | json_escape }}",
|
|
275
275
|
"identifiers": [
|
|
276
|
-
{"system": "{{ p.source_uri | json_escape }}", "value": "{{ p.direct_customer_id | json_escape }}"}
|
|
276
|
+
{"system": "{{ p.source_uri | json_escape }}", "value": "{{ p.direct_customer_id | json_escape }}", "type": "digital_identifier"}
|
|
277
277
|
]
|
|
278
278
|
}`
|
|
279
279
|
|
|
@@ -376,7 +376,7 @@ const AUDIENCE_MDM = `{% assign p = msg %}
|
|
|
376
376
|
{% if first_name != "" %}"first_name": "{{ first_name | json_escape }}",{% endif %}
|
|
377
377
|
{% if last_name != "" %}"last_name": "{{ last_name | json_escape }}",{% endif %}
|
|
378
378
|
"identifiers": [
|
|
379
|
-
{"system": "{{ p.source_uri | json_escape }}", "value": "{{ handle | json_escape }}"}
|
|
379
|
+
{"system": "{{ p.source_uri | json_escape }}", "value": "{{ handle | json_escape }}", "type": "digital_identifier"}
|
|
380
380
|
]
|
|
381
381
|
}`
|
|
382
382
|
|
|
@@ -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: {
|
|
@@ -690,7 +691,10 @@ const { data: run } = await api.workflows.run(tenantSlug, datalakeSlug, ctx.work
|
|
|
690
691
|
mode: 'live',
|
|
691
692
|
manual_override: false,
|
|
692
693
|
})
|
|
693
|
-
|
|
694
|
+
// run-workflow only SCHEDULES the run. The log id is written when it
|
|
695
|
+
// fires, so read it back via workflowRuns.get.
|
|
696
|
+
const fired = await ctx.waitForFiredRun(datalakeSlug, run.workflow_run_id)
|
|
697
|
+
ctx.runLogId = fired.workflowRunLogId
|
|
694
698
|
|
|
695
699
|
const deadline = Date.now() + 180_000
|
|
696
700
|
let byStatus: Record<string, number> = {}
|
|
@@ -844,7 +848,7 @@ const REPLY_MDM = `{% assign p = msg %}
|
|
|
844
848
|
{
|
|
845
849
|
"legal_entity_type": "individual",
|
|
846
850
|
"identifiers": [
|
|
847
|
-
{"system": "{{ p.source_uri | json_escape }}", "value": "{{ p.handle | json_escape }}"}
|
|
851
|
+
{"system": "{{ p.source_uri | json_escape }}", "value": "{{ p.handle | json_escape }}", "type": "digital_identifier"}
|
|
848
852
|
]
|
|
849
853
|
}`
|
|
850
854
|
|
|
@@ -935,7 +939,7 @@ if (wfRow.status !== 'live') {
|
|
|
935
939
|
}
|
|
936
940
|
// Behavioural probe — a dry run against a selection no row can match:
|
|
937
941
|
// the pipeline executes end-to-end, the final action call is
|
|
938
|
-
// intercepted, and the acknowledgement carries the run
|
|
942
|
+
// intercepted, and the acknowledgement carries the scheduled run id. The
|
|
939
943
|
// clause speaks this workflow's selection dialect: a GENERIC-TABLE
|
|
940
944
|
// audience is addressed by its own columns (no `ra.` dataset alias —
|
|
941
945
|
// that alias exists only for system-dataset selections).
|
|
@@ -944,8 +948,10 @@ const { data: probeRun } = await api.workflows.run(tenantSlug, datalakeSlug, ctx
|
|
|
944
948
|
mode: 'dry_run',
|
|
945
949
|
manual_override: false,
|
|
946
950
|
})
|
|
947
|
-
|
|
948
|
-
|
|
951
|
+
// The run-log id does not exist until the run fires — wait, do not read a null.
|
|
952
|
+
const probeFired = await ctx.waitForFiredRun(datalakeSlug, probeRun.workflow_run_id)
|
|
953
|
+
if (probeFired.workflowRunLogId.length === 0) {
|
|
954
|
+
throw new Error('dry-run probe never produced a workflow_run_log_id')
|
|
949
955
|
}
|
|
950
956
|
```
|
|
951
957
|
|
|
@@ -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,
|
|
@@ -514,8 +515,11 @@ const runResp = await api.workflows.run(tenantSlug, datalakeSlug, ctx.workflowSl
|
|
|
514
515
|
mode: 'live',
|
|
515
516
|
manual_override: false,
|
|
516
517
|
})
|
|
517
|
-
|
|
518
|
-
|
|
518
|
+
// run-workflow only SCHEDULES the run. The log id and batch id are
|
|
519
|
+
// written when it fires, so read them back via workflowRuns.get.
|
|
520
|
+
const fired = await ctx.waitForFiredRun(datalakeSlug, runResp.data.workflow_run_id)
|
|
521
|
+
ctx.runLogId = fired.workflowRunLogId
|
|
522
|
+
ctx.runBatchId = fired.batchId!
|
|
519
523
|
|
|
520
524
|
const deadline = Date.now() + 240_000
|
|
521
525
|
let status: string | null = null
|
|
@@ -658,7 +662,7 @@ if (wfRow.status !== 'live') {
|
|
|
658
662
|
}
|
|
659
663
|
// Behavioural probe — a dry run against a selection no row can match:
|
|
660
664
|
// the pipeline executes end-to-end, the final action call is
|
|
661
|
-
// intercepted, and the acknowledgement carries the run
|
|
665
|
+
// intercepted, and the acknowledgement carries the scheduled run id. The
|
|
662
666
|
// clause must speak this workflow's selection dialect — the dataset
|
|
663
667
|
// alias is `rcs` here, the same alias the live run above uses.
|
|
664
668
|
const { data: probeRun } = await api.workflows.run(tenantSlug, datalakeSlug, ctx.workflowSlug, {
|
|
@@ -666,8 +670,10 @@ const { data: probeRun } = await api.workflows.run(tenantSlug, datalakeSlug, ctx
|
|
|
666
670
|
mode: 'dry_run',
|
|
667
671
|
manual_override: false,
|
|
668
672
|
})
|
|
669
|
-
|
|
670
|
-
|
|
673
|
+
// The run-log id does not exist until the run fires — wait, do not read a null.
|
|
674
|
+
const probeFired = await ctx.waitForFiredRun(datalakeSlug, probeRun.workflow_run_id)
|
|
675
|
+
if (probeFired.workflowRunLogId.length === 0) {
|
|
676
|
+
throw new Error('dry-run probe never produced a workflow_run_log_id')
|
|
671
677
|
}
|
|
672
678
|
```
|
|
673
679
|
|
|
@@ -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',
|
|
@@ -486,8 +487,11 @@ const runResp = await api.workflows.run(tenantSlug, datalakeSlug, ctx.workflowSl
|
|
|
486
487
|
mode: 'live',
|
|
487
488
|
manual_override: true,
|
|
488
489
|
})
|
|
489
|
-
|
|
490
|
-
|
|
490
|
+
// run-workflow only SCHEDULES the run. The log id and batch id are
|
|
491
|
+
// written when it fires, so read them back via workflowRuns.get.
|
|
492
|
+
const fired = await ctx.waitForFiredRun(datalakeSlug, runResp.data.workflow_run_id)
|
|
493
|
+
ctx.runLogId = fired.workflowRunLogId
|
|
494
|
+
ctx.runBatchId = fired.batchId!
|
|
491
495
|
|
|
492
496
|
const deadline = Date.now() + 240_000
|
|
493
497
|
let status: string | null = null
|
|
@@ -564,7 +568,7 @@ if (wfRow.status !== 'live') {
|
|
|
564
568
|
}
|
|
565
569
|
// Behavioural probe — a dry run against a selection no row can match:
|
|
566
570
|
// the pipeline executes end-to-end, the final action call is
|
|
567
|
-
// intercepted, and the acknowledgement carries the run
|
|
571
|
+
// intercepted, and the acknowledgement carries the scheduled run id. The
|
|
568
572
|
// clause speaks this workflow's selection dialect: a GENERIC-TABLE
|
|
569
573
|
// dataset is addressed by its own columns (no `ra.` dataset alias —
|
|
570
574
|
// that alias exists only for system-dataset selections).
|
|
@@ -573,8 +577,10 @@ const { data: probeRun } = await api.workflows.run(tenantSlug, datalakeSlug, ctx
|
|
|
573
577
|
mode: 'dry_run',
|
|
574
578
|
manual_override: false,
|
|
575
579
|
})
|
|
576
|
-
|
|
577
|
-
|
|
580
|
+
// The run-log id does not exist until the run fires — wait, do not read a null.
|
|
581
|
+
const probeFired = await ctx.waitForFiredRun(datalakeSlug, probeRun.workflow_run_id)
|
|
582
|
+
if (probeFired.workflowRunLogId.length === 0) {
|
|
583
|
+
throw new Error('dry-run probe never produced a workflow_run_log_id')
|
|
578
584
|
}
|
|
579
585
|
```
|
|
580
586
|
|
|
@@ -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 %}',
|
|
@@ -422,8 +423,11 @@ const runResp = await api.workflows.run(tenantSlug, datalakeSlug, ctx.workflowSl
|
|
|
422
423
|
mode: 'live',
|
|
423
424
|
manual_override: true,
|
|
424
425
|
})
|
|
425
|
-
|
|
426
|
-
|
|
426
|
+
// run-workflow only SCHEDULES the run. The log id and batch id are
|
|
427
|
+
// written when it fires, so read them back via workflowRuns.get.
|
|
428
|
+
const fired = await ctx.waitForFiredRun(datalakeSlug, runResp.data.workflow_run_id)
|
|
429
|
+
ctx.runLogId = fired.workflowRunLogId
|
|
430
|
+
ctx.runBatchId = fired.batchId!
|
|
427
431
|
|
|
428
432
|
const deadline = Date.now() + 240_000
|
|
429
433
|
let status: string | null = null
|
|
@@ -497,14 +501,16 @@ if (wfRow.status !== 'live') {
|
|
|
497
501
|
}
|
|
498
502
|
// Behavioural probe — a dry run against a selection no row can match:
|
|
499
503
|
// the pipeline executes end-to-end, the final action call is
|
|
500
|
-
// intercepted, and the acknowledgement carries the run
|
|
504
|
+
// intercepted, and the acknowledgement carries the scheduled run id.
|
|
501
505
|
const { data: probeRun } = await api.workflows.run(tenantSlug, datalakeSlug, ctx.workflowSlug, {
|
|
502
506
|
sql_where_clause: "ra.batch_id = 'test-never-matching-batch'",
|
|
503
507
|
mode: 'dry_run',
|
|
504
508
|
manual_override: false,
|
|
505
509
|
})
|
|
506
|
-
|
|
507
|
-
|
|
510
|
+
// The run-log id does not exist until the run fires — wait, do not read a null.
|
|
511
|
+
const probeFired = await ctx.waitForFiredRun(datalakeSlug, probeRun.workflow_run_id)
|
|
512
|
+
if (probeFired.workflowRunLogId.length === 0) {
|
|
513
|
+
throw new Error('dry-run probe never produced a workflow_run_log_id')
|
|
508
514
|
}
|
|
509
515
|
```
|
|
510
516
|
|
|
@@ -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,
|
|
@@ -382,9 +383,11 @@ The SQL where-clause scopes the run to exactly the two batches
|
|
|
382
383
|
exposes).
|
|
383
384
|
|
|
384
385
|
The SMS action's `trigger_template: 'now'` dispatches the action
|
|
385
|
-
immediately rather than
|
|
386
|
-
terminal status on its own — poll `batchLogs.refresh`
|
|
387
|
-
leaves `:pending`.
|
|
386
|
+
immediately rather than deferring it, so once the run fires it
|
|
387
|
+
reaches a terminal status on its own — poll `batchLogs.refresh`
|
|
388
|
+
until it leaves `:pending`. The run itself is still scheduled:
|
|
389
|
+
`workflows.run` records it and returns, which is why the setup
|
|
390
|
+
file's `ctx.waitForFiredRun` sits between the call and the log id. A `:partial` status is expected and fine here:
|
|
388
391
|
one row passed and one was filtered.
|
|
389
392
|
|
|
390
393
|
```typescript
|
|
@@ -393,8 +396,11 @@ const runResp = await api.workflows.run(tenantSlug, datalakeSlug, ctx.workflowSl
|
|
|
393
396
|
mode: 'live',
|
|
394
397
|
manual_override: false,
|
|
395
398
|
})
|
|
396
|
-
|
|
397
|
-
|
|
399
|
+
// run-workflow only SCHEDULES the run. The log id and batch id are
|
|
400
|
+
// written when it fires, so read them back via workflowRuns.get.
|
|
401
|
+
const fired = await ctx.waitForFiredRun(datalakeSlug, runResp.data.workflow_run_id)
|
|
402
|
+
ctx.runLogId = fired.workflowRunLogId
|
|
403
|
+
ctx.runBatchId = fired.batchId!
|
|
398
404
|
|
|
399
405
|
const deadline = Date.now() + 120_000
|
|
400
406
|
let status: string | null = null
|
|
@@ -537,14 +543,16 @@ if (wfRow.status !== 'live') {
|
|
|
537
543
|
}
|
|
538
544
|
// Behavioural probe — a dry run against a selection no row can match:
|
|
539
545
|
// the pipeline executes end-to-end, the final action call is
|
|
540
|
-
// intercepted, and the acknowledgement carries the run
|
|
546
|
+
// intercepted, and the acknowledgement carries the scheduled run id.
|
|
541
547
|
const { data: probeRun } = await api.workflows.run(tenantSlug, datalakeSlug, ctx.workflowSlug, {
|
|
542
548
|
sql_where_clause: "ra.batch_id = 'test-never-matching-batch'",
|
|
543
549
|
mode: 'dry_run',
|
|
544
550
|
manual_override: false,
|
|
545
551
|
})
|
|
546
|
-
|
|
547
|
-
|
|
552
|
+
// The run-log id does not exist until the run fires — wait, do not read a null.
|
|
553
|
+
const probeFired = await ctx.waitForFiredRun(datalakeSlug, probeRun.workflow_run_id)
|
|
554
|
+
if (probeFired.workflowRunLogId.length === 0) {
|
|
555
|
+
throw new Error('dry-run probe never produced a workflow_run_log_id')
|
|
548
556
|
}
|
|
549
557
|
```
|
|
550
558
|
|
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
|
|
@@ -168,6 +168,63 @@ generic_table_id required UUID|null — required iff resource_type='generic_tab
|
|
|
168
168
|
**Write-only (Request-only).** None — every caller-supplied field
|
|
169
169
|
round-trips on the response.
|
|
170
170
|
|
|
171
|
+
### What `mdm_input_config` must render — and the field that is easy to miss
|
|
172
|
+
|
|
173
|
+
The body renders a JSON object describing the **subject** the row belongs
|
|
174
|
+
to, so MDM can resolve it. The one part with a hard requirement that is not
|
|
175
|
+
obvious from the shape is `identifiers`:
|
|
176
|
+
|
|
177
|
+
```
|
|
178
|
+
identifiers[] uri REQUIRED — the identifier's namespace
|
|
179
|
+
type REQUIRED — what kind of identifier it is
|
|
180
|
+
value REQUIRED — the identifier itself
|
|
181
|
+
system accepted, and NORMALISES ONTO `uri`
|
|
182
|
+
country optional — issuing country
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
**`type` is required and has no default.** An identifier with no type
|
|
186
|
+
cannot be matched against anything, so MDM rejects the whole row:
|
|
187
|
+
|
|
188
|
+
```
|
|
189
|
+
MDM input validation failed
|
|
190
|
+
reject_reason: %{identifiers: [%{type: ["can't be blank"]}]}
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
That rejection fails the contract at the `:mdm` stage, which means **the
|
|
194
|
+
row never persists and the ingest looks silently empty** — you poll the
|
|
195
|
+
dataset table and see zero rows, with nothing in the response to say why.
|
|
196
|
+
Read the contract-stage failure to see the reason.
|
|
197
|
+
|
|
198
|
+
`system` is accepted as an alias and normalises onto `uri`, so
|
|
199
|
+
`{system, type, value}` and `{uri, type, value}` are both fine. What is
|
|
200
|
+
never fine is omitting `type`.
|
|
201
|
+
|
|
202
|
+
A worked example, from a green cookbook:
|
|
203
|
+
|
|
204
|
+
```liquid
|
|
205
|
+
{
|
|
206
|
+
"legal_entity_type": "individual",
|
|
207
|
+
"identifiers": [
|
|
208
|
+
{"system": "{{ p.source_uri }}", "value": "{{ handle }}", "type": "digital_identifier"}
|
|
209
|
+
]
|
|
210
|
+
}
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
**Do not confuse this with `identifications`,** which some `legal_entity`
|
|
214
|
+
templates render into the record itself. That is a different embed with
|
|
215
|
+
different keys (`id_type`, `uri`, `id_number`) — the two look alike and are
|
|
216
|
+
not interchangeable.
|
|
217
|
+
|
|
218
|
+
Two related shapes worth keeping straight:
|
|
219
|
+
|
|
220
|
+
- **`mdm.verify`** (see `mdm.md`) takes looser input — it asks "is this
|
|
221
|
+
really you?" about an already-known subject, so it needs `value` plus a
|
|
222
|
+
`uri` **or** a `type`, not both. Its example is not a template for this
|
|
223
|
+
one.
|
|
224
|
+
- **payments** additionally requires `id_type` on each entry of its own
|
|
225
|
+
verify payload. Per-domain differences are real; check the domain you are
|
|
226
|
+
writing for.
|
|
227
|
+
|
|
171
228
|
## 4. Error envelopes
|
|
172
229
|
|
|
173
230
|
Standard JSON:API envelopes per `errors.md`. Common rejections:
|
|
@@ -178,6 +235,7 @@ Standard JSON:API envelopes per `errors.md`. Common rejections:
|
|
|
178
235
|
| `/template_config/type` | enum mismatch |
|
|
179
236
|
| `/template_config/body` | empty when `type === 'custom'` |
|
|
180
237
|
| `/mdm_input_config/type` | enum mismatch |
|
|
238
|
+
| `identifiers[].type` | blank — see §3; fails at the `:mdm` stage at INGEST time, not at create, so the contract saves fine and rows silently never land |
|
|
181
239
|
| `/generic_table_id` | missing when resource_type='generic_table', |
|
|
182
240
|
| | or non-null when not |
|
|
183
241
|
| `/name` | uniqueness within datalake |
|
package/.agent/tools.md
CHANGED
|
@@ -74,6 +74,10 @@ the discriminator. Each branch is its own TypeScript type:
|
|
|
74
74
|
tool_body_type = 'rest_api' → generic REST endpoint
|
|
75
75
|
tool_body_type = 'sns' → AWS SNS topic (used for
|
|
76
76
|
SMS / push notifications)
|
|
77
|
+
tool_body_type = 'twilio' → Twilio Programmable Messaging
|
|
78
|
+
(SMS). A first-class sender —
|
|
79
|
+
do NOT hand-author a rest_api
|
|
80
|
+
body for Twilio; see §2 below
|
|
77
81
|
tool_body_type = 'end_user_messaging'
|
|
78
82
|
→ AWS End User Messaging (used
|
|
79
83
|
for MMS / media messages via
|
|
@@ -99,13 +103,103 @@ discriminator is a string-literal on each branch of the generated
|
|
|
99
103
|
`ToolBodyWritable` union (`tool_body_type: 's3'`, `'rest_api'`, …).
|
|
100
104
|
Consult the branch literals in
|
|
101
105
|
`packages/sdk/src/generated/types.gen.ts` for the complete set,
|
|
102
|
-
since new body types land independently of doc revisions.
|
|
103
|
-
platform declares the same twelve variants in
|
|
104
|
-
`platform/lib/platform/tools/tool.ex` (`polymorphic_embeds_one(:body,
|
|
105
|
-
…)`, lines 255–269).
|
|
106
|
+
since new body types land independently of doc revisions.
|
|
106
107
|
|
|
107
108
|
## 2. Rules the type cannot encode
|
|
108
109
|
|
|
110
|
+
### Twilio is a first-class sender — never hand-author `rest_api` for it
|
|
111
|
+
|
|
112
|
+
Use `tool_body_type: 'twilio'`. Reaching for `intent: 'sms'` + a
|
|
113
|
+
hand-authored `rest_api` body was the old workaround and it is now a
|
|
114
|
+
mistake with a consequence: a `rest_api` Twilio sender has no
|
|
115
|
+
`status_poller/twilio/…` path, so **the send can never learn what
|
|
116
|
+
happened to it** — no delivery status, no reconciliation.
|
|
117
|
+
|
|
118
|
+
```typescript
|
|
119
|
+
await api.tools.create(tenantSlug, datalakeSlug, {
|
|
120
|
+
name: 'Campaign Sender — Main Line',
|
|
121
|
+
intent: 'sms',
|
|
122
|
+
tool_body_type: 'twilio',
|
|
123
|
+
variant_type: 'primary',
|
|
124
|
+
account_sid: 'AC…', // "AC" + 32 hex
|
|
125
|
+
auth_token: '…', // writeOnly — never comes back on a read
|
|
126
|
+
from_number: '+15550000001', // XOR with messaging_service_sid
|
|
127
|
+
// base_url — nullable; blank means https://api.twilio.com
|
|
128
|
+
// timeout_ms — 1..300000, nullable
|
|
129
|
+
// base_message — ComplexTemplateConfig
|
|
130
|
+
})
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
**`from_number` and `messaging_service_sid` are mutually exclusive.**
|
|
134
|
+
Twilio resolves the messaging service and ignores `From` when both are
|
|
135
|
+
sent, so a tool carrying both could no longer say which number a message
|
|
136
|
+
went out on — and that join is the entire reason one tool exists per
|
|
137
|
+
sender identity. The type permits both because the spec cannot express
|
|
138
|
+
XOR; the server rejects it.
|
|
139
|
+
|
|
140
|
+
There is deliberately **no `status_callback_url`**. The platform polls
|
|
141
|
+
Twilio's Messages endpoint; a callback field would advertise a path that
|
|
142
|
+
does not exist.
|
|
143
|
+
|
|
144
|
+
### One credential, many senders: `variant_type` and `primary_tool_id`
|
|
145
|
+
|
|
146
|
+
Every sender body — `twilio`, `sns`, `end_user_messaging`, `email` —
|
|
147
|
+
declares which of two things it is:
|
|
148
|
+
|
|
149
|
+
```
|
|
150
|
+
variant_type = 'primary' holds its own credential, borrows from nothing
|
|
151
|
+
variant_type = 'variant' borrows; primary_tool_id is REQUIRED
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
The problem this solves: one Twilio account behind three numbers used to
|
|
155
|
+
mean three tools each holding the same `account_sid` and `auth_token`.
|
|
156
|
+
Rotation was N edits, and a missed one is a **silent partial outage** —
|
|
157
|
+
the other senders keep working, so nothing looks broken.
|
|
158
|
+
|
|
159
|
+
**A variant stores no credential at all.** Reading one back, its
|
|
160
|
+
`account_sid` / `api_key` / `access_key_id` / `smtp_*` come back `null`.
|
|
161
|
+
**That is correct and complete, not missing data.** Do not "repair" it by
|
|
162
|
+
copying the primary's credential in — that recreates the exact bug this
|
|
163
|
+
removes. The platform resolves the primary at dispatch.
|
|
164
|
+
|
|
165
|
+
**Rotation is one write, to the primary.** Any helper that "updates
|
|
166
|
+
credentials across senders" is now wrong by construction: a partial
|
|
167
|
+
rotation has no representation.
|
|
168
|
+
|
|
169
|
+
**Depth is exactly one**, enforced by a database trigger rather than a
|
|
170
|
+
changeset, because the rule reads another row. A violation is a **422
|
|
171
|
+
with a field on it** — surface the message, do not retry:
|
|
172
|
+
|
|
173
|
+
```
|
|
174
|
+
names a tool that is itself a variant — a variant must name a primary
|
|
175
|
+
names a tool of another type — a variant borrows from a primary of its own type
|
|
176
|
+
names a primary on another provider — a variant borrows one account's credentials
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
What stays with the variant is the sender identity and its event routing:
|
|
180
|
+
|
|
181
|
+
```
|
|
182
|
+
twilio from_number XOR messaging_service_sid
|
|
183
|
+
sns phone_number
|
|
184
|
+
end_user_messaging phone_number · configuration_set_name
|
|
185
|
+
email :mailgun from_email · from_name · reply_to · domain
|
|
186
|
+
email :ses from_email · from_name · reply_to
|
|
187
|
+
email :sendgrid from_email · from_name · reply_to
|
|
188
|
+
email :smtp from_email · from_name · reply_to
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
Everything else — auth, region, endpoints, the MMS staging bucket — comes
|
|
192
|
+
from the primary. Two entries look arbitrary and are not: Mailgun's
|
|
193
|
+
`domain` is also a path segment in the request URL
|
|
194
|
+
(`/v3/{domain}/messages`), and End User Messaging's
|
|
195
|
+
`configuration_set_name` is the *event-routing* address, of which AWS
|
|
196
|
+
permits one per originating number — a number that cannot name its own
|
|
197
|
+
routing cannot be reconciled apart from its siblings.
|
|
198
|
+
|
|
199
|
+
The split is per body type, and for Email per **provider**. That is the
|
|
200
|
+
concrete reason there is no global credential registry: no single list
|
|
201
|
+
could express it.
|
|
202
|
+
|
|
109
203
|
### `intent` and `tool_body_type` are loosely coupled
|
|
110
204
|
|
|
111
205
|
The TypeScript type lets you combine any intent with any body
|
|
@@ -189,6 +283,48 @@ The full per-branch shape lives in
|
|
|
189
283
|
`packages/sdk/src/generated/types.gen.ts` under the matching
|
|
190
284
|
`Tool<Type>BodyWritable` types.
|
|
191
285
|
|
|
286
|
+
### `assume_role` needs the platform's ARN — read it from `GET /api/ping`
|
|
287
|
+
|
|
288
|
+
The `assume_role` auth method (on `sns` and `end_user_messaging`
|
|
289
|
+
bodies) is configured from two values that come from opposite
|
|
290
|
+
sides of the trust relationship:
|
|
291
|
+
|
|
292
|
+
```
|
|
293
|
+
assume_role_arn the role YOU create in YOUR AWS
|
|
294
|
+
account, for the platform to assume
|
|
295
|
+
assume_role_external_id the platform-generated external ID
|
|
296
|
+
that role must require
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
Creating that role means naming a principal in its trust policy,
|
|
300
|
+
and that principal is the platform's own IAM task role. It is
|
|
301
|
+
readable from the unauthenticated health endpoint:
|
|
302
|
+
|
|
303
|
+
```typescript
|
|
304
|
+
const { data } = await api.ping()
|
|
305
|
+
// data.iam_role_arn — e.g.
|
|
306
|
+
// 'arn:aws:iam::123456789012:role/alvera-platform-task'
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
Three things to hold onto:
|
|
310
|
+
|
|
311
|
+
- **`api.ping` takes no auth**, so the ARN is readable before a
|
|
312
|
+
session exists — onboarding flows and setup scripts can fetch it
|
|
313
|
+
on first contact with the platform.
|
|
314
|
+
- **It differs per environment.** Read it from the environment you
|
|
315
|
+
are configuring against; never hardcode one and carry it across.
|
|
316
|
+
- **It can be `null`.** Where the platform runs without AWS — the
|
|
317
|
+
normal case in local development — there is no task role to
|
|
318
|
+
trust. The generated `PingResponse` types `iam_role_arn` as
|
|
319
|
+
optional and `string | null` for exactly this reason, so handle
|
|
320
|
+
the null at every read rather than assuming a string.
|
|
321
|
+
|
|
322
|
+
The ARN being public is deliberate: it has to reach every customer
|
|
323
|
+
for onboarding to be possible. What stops a third party from
|
|
324
|
+
pointing their own tool at your role is the
|
|
325
|
+
`assume_role_external_id` — your trust policy must require that
|
|
326
|
+
external ID, so possession of the ARN alone grants nothing.
|
|
327
|
+
|
|
192
328
|
### `status` field gates whether the tool can be invoked
|
|
193
329
|
|
|
194
330
|
```
|