@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.
@@ -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
- ctx.runLogId = run.workflow_run_log_id!
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-log id. The
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
- if (typeof probeRun.workflow_run_log_id !== 'string' || probeRun.workflow_run_log_id.length === 0) {
948
- throw new Error('dry-run probe returned no workflow_run_log_id')
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
- ctx.runLogId = runResp.data.workflow_run_log_id!
518
- ctx.runBatchId = runResp.data.batch_id!
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-log id. The
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
- if (typeof probeRun.workflow_run_log_id !== 'string' || probeRun.workflow_run_log_id.length === 0) {
670
- throw new Error('dry-run probe returned no workflow_run_log_id')
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
- ctx.runLogId = runResp.data.workflow_run_log_id!
490
- ctx.runBatchId = runResp.data.batch_id!
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-log id. The
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
- if (typeof probeRun.workflow_run_log_id !== 'string' || probeRun.workflow_run_log_id.length === 0) {
577
- throw new Error('dry-run probe returned no workflow_run_log_id')
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
- ctx.runLogId = runResp.data.workflow_run_log_id!
426
- ctx.runBatchId = runResp.data.batch_id!
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-log id.
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
- if (typeof probeRun.workflow_run_log_id !== 'string' || probeRun.workflow_run_log_id.length === 0) {
507
- throw new Error('dry-run probe returned no workflow_run_log_id')
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 scheduling it, so the run reaches a
386
- terminal status on its own — poll `batchLogs.refresh` until it
387
- leaves `:pending`. A `:partial` status is expected and fine here:
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
- ctx.runLogId = runResp.data.workflow_run_log_id!
397
- ctx.runBatchId = runResp.data.batch_id!
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-log id.
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
- if (typeof probeRun.workflow_run_log_id !== 'string' || probeRun.workflow_run_log_id.length === 0) {
547
- throw new Error('dry-run probe returned no workflow_run_log_id')
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. The
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
  ```