@alvera-ai/platform-sdk 0.17.3 → 0.17.4-next.g85c825c

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.
@@ -14,8 +14,8 @@ polls the provider's log group to mark each message as
14
14
  SDK namespace: `api.actionStatusUpdaters`.
15
15
 
16
16
  Action status updaters are **Datalake-DB-resident** — the
17
- parent datalake must be `status: 'ready'` and every
18
- referenced tool must exist before you `create()`.
17
+ parent datalake must be `status: 'ready'`, and the polling tool
18
+ and the data source must exist before you `create()`.
19
19
 
20
20
  ## 1. Wire shape
21
21
 
@@ -33,7 +33,7 @@ const { data: created } = await api.actionStatusUpdaters.create(
33
33
  cron_expression: '*/30 * * * *', // every 30 minutes
34
34
  updater_type: 'cloud_watch', // polling source family
35
35
  updater_tool_id: cloudWatchToolId, // the tool that issues the poll
36
- sender_tool_ids: [smsToolId], // tools whose sent messages this updater tracks
36
+ data_source_id: dataSourceId, // the data source whose messages + action logs this updater updates
37
37
  datalake_id: datalakeId,
38
38
 
39
39
  // Polymorphic — discriminator: updater_body_type
@@ -354,7 +354,7 @@ anyway — a future release may start enforcing it, and a lying
354
354
  `updater_type` is a debugging trap — but don't expect a 422 to catch
355
355
  a mismatch for you today.
356
356
 
357
- ### `updater_tool_id` and `sender_tool_ids` serve different roles
357
+ ### `updater_tool_id` and `data_source_id` serve different roles
358
358
 
359
359
  - `updater_tool_id` (single) — the tool the updater uses to
360
360
  ISSUE the poll. By convention this is a tool whose `intent` is
@@ -364,14 +364,25 @@ a mismatch for you today.
364
364
  wrong-intent tool is accepted and only fails later, when the poll
365
365
  actually runs. Wire a `status_poller` tool anyway; just don't rely
366
366
  on a create-time 422 to enforce it.
367
- - `sender_tool_ids` (array) — the tools whose sent messages
368
- this updater is responsible for tracking. The match logic
369
- joins each polled event back to a message row that was
370
- sent through one of these tools.
371
-
372
- A single status updater can track messages from multiple
373
- sender tools (e.g. an SMS tool plus a voice tool routed
374
- through the same provider).
367
+ - `data_source_id` (single) — the data source whose messages and
368
+ action logs this updater updates. Every tool names a data source
369
+ (`tools.md`), and every message a tool sends carries that data
370
+ source's `uri`. The updater matches each polled event to a row by
371
+ the data source's `uri` plus the vendor's id (`external_id`): the
372
+ unique key on messages and action logs is
373
+ `(data_source_uri, external_id)`, and a vendor id that repeats
374
+ under one data source is kept with status `duplicate`.
375
+
376
+ So the updater is not tied to one sender tool: a message sent
377
+ through any tool on its data source is a candidate for its match.
378
+ One data source may hold SMS, MMS and voice tools, and nothing
379
+ checks what else is on it.
380
+
381
+ `data_source_id` is required on create AND update (PUT replaces the
382
+ whole body). The response reads it back nullable: updaters created
383
+ before it existed carry none, skip every run, and report
384
+ `no data source: …` as their last run error until an update names
385
+ one.
375
386
 
376
387
  ## 3. Field ownership
377
388
 
@@ -385,7 +396,7 @@ name required string
385
396
  cron_expression required string — standard cron
386
397
  updater_type required enum — e.g. 'cloud_watch'
387
398
  updater_tool_id required UUID — the polling tool
388
- sender_tool_ids required UUID[] — tools whose messages this updater tracks
399
+ data_source_id required UUID — the data source whose messages + action logs this updater updates (read back nullable)
389
400
  datalake_id required UUID
390
401
  updater_body required embed — { updater_body_type, ... }
391
402
  message_config required embed — { type, body }
@@ -402,7 +413,7 @@ Standard JSON:API envelopes per `errors.md`. Common rejections:
402
413
  |-------------------------------------|----------------------------------------------------|
403
414
  | `/cron_expression` | invalid cron syntax or below minimum interval |
404
415
  | `/updater_tool_id` | tool id doesn't exist or wrong datalake (intent is NOT checked — see §2) |
405
- | `/sender_tool_ids/0` | tool id doesn't exist or wrong datalake |
416
+ | `/data_source_id` | missing (`can't be blank`, create AND update) or the data source doesn't exist |
406
417
  | `/updater_body/updater_body_type` | value not in the polymorphic embed's type enum |
407
418
  | `/message_config/body` | empty when `type: 'custom'` |
408
419
  | `/action_log_config` | missing entirely — `Missing field: action_log_config` (create AND update) |
@@ -626,7 +637,7 @@ is the part that matters:
626
637
  - **No updater configured → no delivery truth.** Rows stay `sent`
627
638
  forever; a delivery-rate number computed from them is fiction.
628
639
  Updaters are created manually (this page §1) — the platform does
629
- not auto-create them per sender tool.
640
+ not auto-create them per data source.
630
641
  - **The join is `{external_id, set_params}`,** exact-match, silent
631
642
  on misses (gotcha 3). "Eventually honest": a just-sent row is
632
643
  neither delivered nor failed yet — show the in-between state.
@@ -22,7 +22,8 @@ An **action status updater (ASU)** is a scheduled reconciler:
22
22
  - the ASU runs on a `cron_expression`,
23
23
  - its `message_config` Liquid maps each log event back to the message it came
24
24
  from (by `external_id`) and the new `status`,
25
- - `sender_tool_ids` scopes it to the messages a given sender tool produced.
25
+ - `data_source_id` scopes it to one data source: the messages and action logs
26
+ that carry that data source's `uri` — every message a tool on it sends.
26
27
 
27
28
  This is **global** — the same shape reconciles any sender's delivery status. See
28
29
  `action_status_updaters.md` for the wire reference and `tools.md` for the
@@ -104,7 +105,8 @@ ctx.cwToolId = cwTool.id!
104
105
  ## 004 — create the action status updater
105
106
 
106
107
  The ASU ties it together: a `cron_expression` schedule, the `updater_tool_id`
107
- (the poller), the `sender_tool_ids` it reconciles for, and an `updater_body`
108
+ (the poller), the `data_source_id` whose messages it reconciles — the one the
109
+ SMS sender tool names — and an `updater_body`
108
110
  naming the log group + the time window. On `start_time`/`end_time`, use
109
111
  `now_msec` — the injected variable holding unix milliseconds — piped through
110
112
  `minutes_ago`; the bare `now` is a DateTime and raises here. The
@@ -122,7 +124,7 @@ const { data: asu } = await api.actionStatusUpdaters.create(tenantSlug, datalake
122
124
  cron_expression: '*/30 * * * *',
123
125
  updater_type: 'cloud_watch',
124
126
  updater_tool_id: ctx.cwToolId,
125
- sender_tool_ids: [ctx.smsToolId],
127
+ data_source_id: dataSourceId,
126
128
  datalake_id: ctx.datalakeId,
127
129
  updater_body: {
128
130
  updater_body_type: 'cloud_watch_request',
@@ -159,7 +161,7 @@ const { data: ck } = await api.actionStatusUpdaters.checksum(tenantSlug, datalak
159
161
  cron_expression: row.cron_expression,
160
162
  updater_type: 'cloud_watch',
161
163
  updater_tool_id: ctx.cwToolId,
162
- sender_tool_ids: [ctx.smsToolId],
164
+ data_source_id: dataSourceId,
163
165
  datalake_id: ctx.datalakeId,
164
166
  updater_body: {
165
167
  updater_body_type: 'cloud_watch_request',
@@ -235,8 +237,9 @@ blind.
235
237
  # Gotchas
236
238
 
237
239
  - **The poller tool's intent is `status_poller`, not the sender's intent.** The
238
- reconciler reads logs; it doesn't send. `sender_tool_ids` is the separate list
239
- of sender tools whose messages it updates.
240
+ reconciler reads logs; it doesn't send. The messages it updates are named by
241
+ `data_source_id`, not by a list of sender tools: every tool names a data
242
+ source, and the messages it sends carry that data source's `uri`.
240
243
  - **`message_config` renders once per event, and emits a FLAT object.** The event
241
244
  is the assigns — there is no `events` list to loop — and every top-level key
242
245
  that is not `external_id` IS the update (`status`, `delivered_at`, …). There is
@@ -99,12 +99,22 @@ const OPENAI_BASE_BODY =
99
99
  const OPENAI_EXTRACTOR =
100
100
  '{"output_json": "{{ msg.choices[0].message.content | json_escape }}", "explanation": "{{ msg.choices[0].message.reasoning_content | json_escape }}", "input_tokens": {{ msg.usage.prompt_tokens | default: 0 }}, "output_tokens": {{ msg.usage.completion_tokens | default: 0 }}, "total_tokens": {{ msg.usage.total_tokens | default: 0 }}}'
101
101
 
102
+ // Every tool names a data source; this one exists for the LLM tool alone.
103
+ const { data: toolSource } = await api.dataSources.create(tenantSlug, datalakeSlug, {
104
+ name: `Extraction Tool Source ${runSuffix}`,
105
+ uri: 'openai.local',
106
+ description: 'Data source the LLM-enrichment tool names.',
107
+ status: 'active',
108
+ is_default: false,
109
+ })
110
+
102
111
  const { data: tool } = await api.tools.create(tenantSlug, datalakeSlug, {
103
112
  name: `Legal-Entity Extraction Tool ${runSuffix}`,
104
113
  description: 'Multimodal LLM-enrichment adapter for legal_entity extraction.',
105
114
  intent: 'llm_enrichment',
106
115
  status: 'active',
107
116
  datalake_id: ctx.datalakeId,
117
+ data_source_id: toolSource.id!,
108
118
  response_extractor: { type: 'custom', body: OPENAI_EXTRACTOR, output_schema: ENRICHMENT_OUTPUT_SCHEMA },
109
119
  body: {
110
120
  tool_body_type: 'rest_api',
@@ -77,13 +77,27 @@ dev points it at LocalStack on `http://localhost:4566` via
77
77
  `intent: 'sms'` tags this tool for workflow actions that send SMS
78
78
  (versus `data_exchange` for ingestion tools).
79
79
 
80
+ Every tool names a data source, and the messages a sender tool sends carry
81
+ that data source's `uri` — what a status updater matches on — so the step
82
+ creates one for the SMS tool first.
83
+
80
84
  ```typescript
85
+ const toolSourceResp = await api.dataSources.create(tenantSlug, datalakeSlug, {
86
+ name: `Cookbook SMS Source ${runSuffix}`,
87
+ uri: 'sns.local',
88
+ description: 'Origin of the messages the SMS tool sends.',
89
+ status: 'active',
90
+ is_default: false,
91
+ })
92
+ ctx.toolSourceId = toolSourceResp.data.id!
93
+
81
94
  const smsToolResp = await api.tools.create(tenantSlug, datalakeSlug, {
82
95
  name: `Cookbook SMS Tool ${runSuffix}`,
83
96
  description: 'SNS-backed SMS dispatcher for the Review SMS workflow, wired to LocalStack.',
84
97
  intent: 'sms',
85
98
  status: 'active',
86
99
  datalake_id: ctx.datalakeId,
100
+ data_source_id: ctx.toolSourceId,
87
101
  body: {
88
102
  tool_body_type: 'sns',
89
103
  auth_method: 'access_key',
@@ -70,13 +70,27 @@ local dev points it at LocalStack on `http://localhost:4566` via
70
70
  `intent: 'sms'` tags this tool for workflow actions that send SMS
71
71
  (versus `data_exchange` for ingestion tools).
72
72
 
73
+ Every tool names a data source, and the messages a sender tool sends carry
74
+ that data source's `uri` — what a status updater matches on — so the step
75
+ creates one for the SMS tool first.
76
+
73
77
  ```typescript
78
+ const toolSourceResp = await api.dataSources.create(tenantSlug, datalakeSlug, {
79
+ name: `Cookbook SMS Source ${runSuffix}`,
80
+ uri: 'sns.local',
81
+ description: 'Origin of the messages the SMS tool sends.',
82
+ status: 'active',
83
+ is_default: false,
84
+ })
85
+ ctx.toolSourceId = toolSourceResp.data.id!
86
+
74
87
  const smsToolResp = await api.tools.create(tenantSlug, datalakeSlug, {
75
88
  name: `Cookbook SMS Tool ${runSuffix}`,
76
89
  description: 'SNS-backed SMS dispatcher for the Happy Birthday workflow, wired to LocalStack.',
77
90
  intent: 'sms',
78
91
  status: 'active',
79
92
  datalake_id: ctx.datalakeId,
93
+ data_source_id: ctx.toolSourceId,
80
94
  body: {
81
95
  tool_body_type: 'sns',
82
96
  auth_method: 'access_key',
@@ -136,13 +136,27 @@ per-bucket SMS body lives on each workflow action, not on the tool.
136
136
  SNS-backed LocalStack wiring — `intent: 'sms'` tags it for
137
137
  workflow-action use.
138
138
 
139
+ Every tool names a data source, and the messages a sender tool sends carry
140
+ that data source's `uri` — what a status updater matches on — so the step
141
+ creates one for the SMS tool first.
142
+
139
143
  ```typescript
144
+ const toolSourceResp = await api.dataSources.create(tenantSlug, datalakeSlug, {
145
+ name: `Cookbook SMS Source ${runSuffix}`,
146
+ uri: 'sns.local',
147
+ description: 'Origin of the messages the SMS tool sends.',
148
+ status: 'active',
149
+ is_default: false,
150
+ })
151
+ ctx.toolSourceId = toolSourceResp.data.id!
152
+
140
153
  const smsToolResp = await api.tools.create(tenantSlug, datalakeSlug, {
141
154
  name: `Cookbook SMS Tool ${runSuffix}`,
142
155
  description: 'SNS-backed SMS dispatcher for the Contact Us Triage workflow, wired to LocalStack.',
143
156
  intent: 'sms',
144
157
  status: 'active',
145
158
  datalake_id: ctx.datalakeId,
159
+ data_source_id: ctx.toolSourceId,
146
160
  body: {
147
161
  tool_body_type: 'sns',
148
162
  auth_method: 'access_key',
@@ -194,6 +208,7 @@ const llmToolResp = await api.tools.create(tenantSlug, datalakeSlug, {
194
208
  intent: 'llm_enrichment',
195
209
  status: 'active',
196
210
  datalake_id: ctx.datalakeId,
211
+ data_source_id: ctx.toolSourceId,
197
212
  response_extractor: { type: 'custom', body: OLLAMA_EXTRACTOR, output_schema: ENRICHMENT_OUTPUT_SCHEMA },
198
213
  body: {
199
214
  tool_body_type: 'rest_api',
@@ -79,13 +79,27 @@ dev points it at LocalStack on `http://localhost:4566` via
79
79
  `intent: 'sms'` tags this tool for workflow actions that send SMS
80
80
  (versus `data_exchange` for ingestion tools).
81
81
 
82
+ Every tool names a data source, and the messages a sender tool sends carry
83
+ that data source's `uri` — what a status updater matches on — so the step
84
+ creates one for the SMS tool first.
85
+
82
86
  ```typescript
87
+ const toolSourceResp = await api.dataSources.create(tenantSlug, datalakeSlug, {
88
+ name: `Cookbook SMS Source ${runSuffix}`,
89
+ uri: 'sns.local',
90
+ description: 'Origin of the messages the SMS tool sends.',
91
+ status: 'active',
92
+ is_default: false,
93
+ })
94
+ ctx.toolSourceId = toolSourceResp.data.id!
95
+
83
96
  const smsToolResp = await api.tools.create(tenantSlug, datalakeSlug, {
84
97
  name: `Cookbook SMS Tool ${runSuffix}`,
85
98
  description: 'SNS-backed SMS dispatcher for the Dunning SMS workflow, wired to LocalStack.',
86
99
  intent: 'sms',
87
100
  status: 'active',
88
101
  datalake_id: ctx.datalakeId,
102
+ data_source_id: ctx.toolSourceId,
89
103
  body: {
90
104
  tool_body_type: 'sns',
91
105
  auth_method: 'access_key',
@@ -77,13 +77,27 @@ local dev points it at LocalStack on `http://localhost:4566` via
77
77
  `intent: 'sms'` tags this tool for workflow actions that send SMS
78
78
  (versus `data_exchange` for ingestion tools).
79
79
 
80
+ Every tool names a data source, and the messages a sender tool sends carry
81
+ that data source's `uri` — what a status updater matches on — so the step
82
+ creates one for the SMS tool first.
83
+
80
84
  ```typescript
85
+ const toolSourceResp = await api.dataSources.create(tenantSlug, datalakeSlug, {
86
+ name: `Cookbook SMS Source ${runSuffix}`,
87
+ uri: 'sns.local',
88
+ description: 'Origin of the messages the SMS tool sends.',
89
+ status: 'active',
90
+ is_default: false,
91
+ })
92
+ ctx.toolSourceId = toolSourceResp.data.id!
93
+
81
94
  const smsToolResp = await api.tools.create(tenantSlug, datalakeSlug, {
82
95
  name: `Cookbook SMS Tool ${runSuffix}`,
83
96
  description: 'SNS-backed SMS dispatcher for the KYC-notification workflow, wired to LocalStack.',
84
97
  intent: 'sms',
85
98
  status: 'active',
86
99
  datalake_id: ctx.datalakeId,
100
+ data_source_id: ctx.toolSourceId,
87
101
  body: {
88
102
  tool_body_type: 'sns',
89
103
  auth_method: 'access_key',
@@ -507,6 +507,15 @@ manifest therefore runs against any server; you are not asking the
507
507
  deployment to be in "test mode". Swap `mock` for `ses` and the same
508
508
  workflow sends real email.
509
509
 
510
+ The sender lives in the tool's Base Configuration: `base_from_email`
511
+ (required on every email tool) and `base_from_name`, Liquid templates
512
+ rendered when the tool is saved with the tool's own variables only
513
+ (`tool.name`, `tool.provider`, …). An email action's own `from_email` /
514
+ `from_name` / `reply_to` still win, field by field, when they render
515
+ non-blank. Both tools name the campaign's data source, as every tool
516
+ must; the messages they send carry its `uri`, which is what §009's
517
+ status updater matches on.
518
+
510
519
  The connected app is the campaign's booking form. It is registered
511
520
  here, and §010's actions deep-link into it.
512
521
 
@@ -519,6 +528,7 @@ const { data: smsTool } = await api.tools.create(tenantSlug, datalakeSlug, {
519
528
  intent: ToolIntent.SMS,
520
529
  status: 'active',
521
530
  datalake_id: ctx.datalakeId,
531
+ data_source_id: dataSourceId,
522
532
  body: {
523
533
  tool_body_type: 'sns',
524
534
  auth_method: 'access_key',
@@ -537,11 +547,12 @@ const { data: emailTool } = await api.tools.create(tenantSlug, datalakeSlug, {
537
547
  intent: ToolIntent.EMAIL,
538
548
  status: 'active',
539
549
  datalake_id: ctx.datalakeId,
550
+ data_source_id: dataSourceId,
540
551
  body: {
541
552
  tool_body_type: 'email',
542
553
  provider: 'mock',
543
- from_email: 'campaigns@bloom-salon.example.com',
544
- from_name: 'Bloom Salon',
554
+ base_from_email: { type: 'custom', body: 'campaigns@bloom-salon.example.com' },
555
+ base_from_name: { type: 'custom', body: 'Bloom Salon' },
545
556
  },
546
557
  })
547
558
  ctx.emailToolId = emailTool.id!
@@ -652,11 +663,12 @@ const { data: sesTool } = await api.tools.create(tenantSlug, datalakeSlug, {
652
663
  intent: ToolIntent.EMAIL,
653
664
  status: 'active',
654
665
  datalake_id: ctx.datalakeId,
666
+ data_source_id: dataSourceId,
655
667
  body: {
656
668
  tool_body_type: 'email',
657
669
  provider: 'ses',
658
- from_email: sesSender,
659
- from_name: 'Bloom Salon',
670
+ base_from_email: { type: 'custom', body: sesSender },
671
+ base_from_name: { type: 'custom', body: 'Bloom Salon' },
660
672
  region: 'us-east-1',
661
673
  access_key_id: 'localkey',
662
674
  secret_access_key: 'localsecret',
@@ -748,7 +760,7 @@ const { data: sesUpdater } = await api.actionStatusUpdaters.create(tenantSlug, d
748
760
  cron_expression: '*/30 * * * *',
749
761
  updater_type: 'cloud_watch',
750
762
  updater_tool_id: sesReader.id!,
751
- sender_tool_ids: [ctx.sesToolId],
763
+ data_source_id: dataSourceId,
752
764
  datalake_id: ctx.datalakeId,
753
765
  updater_body: {
754
766
  updater_body_type: 'cloud_watch_request',
@@ -57,8 +57,9 @@ dataSourceId = ds.id!
57
57
 
58
58
  ## 002 — create the sender tool whose messages get reconciled
59
59
 
60
- The ASU updates messages a sender produced; `sender_tool_ids` will point here.
61
- An SNS-backed SMS sender (LocalStack locally) plays that role.
60
+ The ASU updates the messages sent through its data source; the sender names
61
+ that data source, so its messages carry the data source's `uri`. An
62
+ SNS-backed SMS sender (LocalStack locally) plays that role.
62
63
 
63
64
  ```typescript
64
65
  const { data: smsTool } = await api.tools.create(tenantSlug, datalakeSlug, {
@@ -126,7 +127,7 @@ try {
126
127
  cron_expression: '*/30 * * * *',
127
128
  updater_type: 'restapi',
128
129
  updater_tool_id: ctx.restToolId,
129
- sender_tool_ids: [ctx.senderToolId],
130
+ data_source_id: dataSourceId,
130
131
  datalake_id: ctx.datalakeId,
131
132
  // FLOOR the platform enforces: an array whose items are objects listing
132
133
  // `external_id` in `required`. A bare `{ type: 'array' }` is a 422 at
@@ -201,7 +202,7 @@ const { data: asu } = await api.actionStatusUpdaters.create(tenantSlug, datalake
201
202
  cron_expression: '*/30 * * * *',
202
203
  updater_type: 'restapi',
203
204
  updater_tool_id: ctx.restToolId,
204
- sender_tool_ids: [ctx.senderToolId],
205
+ data_source_id: dataSourceId,
205
206
  datalake_id: ctx.datalakeId,
206
207
  events_output_schema: {
207
208
  type: 'array',
@@ -82,13 +82,27 @@ local dev points it at LocalStack on `http://localhost:4566` via
82
82
  `intent: 'sms'` tags this tool for workflow actions that send SMS
83
83
  (versus `data_exchange` for ingestion tools).
84
84
 
85
+ Every tool names a data source, and the messages a sender tool sends carry
86
+ that data source's `uri` — what a status updater matches on — so the step
87
+ creates one for the SMS tool first.
88
+
85
89
  ```typescript
90
+ const toolSourceResp = await api.dataSources.create(tenantSlug, datalakeSlug, {
91
+ name: `Cookbook SMS Source ${runSuffix}`,
92
+ uri: 'sns.local',
93
+ description: 'Origin of the messages the SMS tool sends.',
94
+ status: 'active',
95
+ is_default: false,
96
+ })
97
+ ctx.toolSourceId = toolSourceResp.data.id!
98
+
86
99
  const smsToolResp = await api.tools.create(tenantSlug, datalakeSlug, {
87
100
  name: `Cookbook SMS Tool ${runSuffix}`,
88
101
  description: 'SNS-backed SMS dispatcher for the Sanctions Review workflow, wired to LocalStack.',
89
102
  intent: 'sms',
90
103
  status: 'active',
91
104
  datalake_id: ctx.datalakeId,
105
+ data_source_id: ctx.toolSourceId,
92
106
  body: {
93
107
  tool_body_type: 'sns',
94
108
  auth_method: 'access_key',
@@ -141,6 +155,7 @@ const llmToolResp = await api.tools.create(tenantSlug, datalakeSlug, {
141
155
  intent: 'llm_enrichment',
142
156
  status: 'active',
143
157
  datalake_id: ctx.datalakeId,
158
+ data_source_id: ctx.toolSourceId,
144
159
  response_extractor: { type: 'custom', body: OLLAMA_EXTRACTOR, output_schema: ENRICHMENT_OUTPUT_SCHEMA },
145
160
  body: {
146
161
  tool_body_type: 'rest_api',
@@ -146,13 +146,27 @@ per-band SMS body lives on each workflow action, not on the tool. Same
146
146
  SNS-backed LocalStack wiring as the birthday cookbook — `intent: 'sms'`
147
147
  tags it for workflow-action use.
148
148
 
149
+ Every tool names a data source, and the messages a sender tool sends carry
150
+ that data source's `uri` — what a status updater matches on — so the step
151
+ creates one for the SMS tool first.
152
+
149
153
  ```typescript
154
+ const toolSourceResp = await api.dataSources.create(tenantSlug, datalakeSlug, {
155
+ name: `Cookbook SMS Source ${runSuffix}`,
156
+ uri: 'sns.local',
157
+ description: 'Origin of the messages the SMS tool sends.',
158
+ status: 'active',
159
+ is_default: false,
160
+ })
161
+ ctx.toolSourceId = toolSourceResp.data.id!
162
+
150
163
  const smsToolResp = await api.tools.create(tenantSlug, datalakeSlug, {
151
164
  name: `Cookbook SMS Tool ${runSuffix}`,
152
165
  description: 'SNS-backed SMS dispatcher for the Score Lead workflow, wired to LocalStack.',
153
166
  intent: 'sms',
154
167
  status: 'active',
155
168
  datalake_id: ctx.datalakeId,
169
+ data_source_id: ctx.toolSourceId,
156
170
  body: {
157
171
  tool_body_type: 'sns',
158
172
  auth_method: 'access_key',
@@ -204,6 +218,7 @@ const llmToolResp = await api.tools.create(tenantSlug, datalakeSlug, {
204
218
  intent: 'llm_enrichment',
205
219
  status: 'active',
206
220
  datalake_id: ctx.datalakeId,
221
+ data_source_id: ctx.toolSourceId,
207
222
  response_extractor: { type: 'custom', body: OLLAMA_EXTRACTOR, output_schema: ENRICHMENT_OUTPUT_SCHEMA },
208
223
  body: {
209
224
  tool_body_type: 'rest_api',
@@ -65,13 +65,27 @@ A single SMS tool dispatches every band's message; the per-band body lives on
65
65
  each workflow action. SNS-backed, pointed at LocalStack locally; `intent: 'sms'`
66
66
  tags it for workflow actions.
67
67
 
68
+ Every tool names a data source, and the messages a sender tool sends carry
69
+ that data source's `uri` — what a status updater matches on — so the step
70
+ creates one for the SMS tool first.
71
+
68
72
  ```typescript
73
+ const toolSourceResp = await api.dataSources.create(tenantSlug, datalakeSlug, {
74
+ name: `Cookbook SMS Source ${runSuffix}`,
75
+ uri: 'sns.local',
76
+ description: 'Origin of the messages the SMS tool sends.',
77
+ status: 'active',
78
+ is_default: false,
79
+ })
80
+ ctx.toolSourceId = toolSourceResp.data.id!
81
+
69
82
  const smsToolResp = await api.tools.create(tenantSlug, datalakeSlug, {
70
83
  name: `Cookbook SMS Tool ${runSuffix}`,
71
84
  description: 'SNS-backed SMS dispatcher for the priority-triage workflow, wired to LocalStack.',
72
85
  intent: 'sms',
73
86
  status: 'active',
74
87
  datalake_id: ctx.datalakeId,
88
+ data_source_id: ctx.toolSourceId,
75
89
  body: {
76
90
  tool_body_type: 'sns',
77
91
  auth_method: 'access_key',
@@ -119,6 +133,7 @@ const llmToolResp = await api.tools.create(tenantSlug, datalakeSlug, {
119
133
  intent: 'llm_enrichment',
120
134
  status: 'active',
121
135
  datalake_id: ctx.datalakeId,
136
+ data_source_id: ctx.toolSourceId,
122
137
  response_extractor: { type: 'custom', body: OLLAMA_EXTRACTOR, output_schema: ENRICHMENT_OUTPUT_SCHEMA },
123
138
  body: {
124
139
  tool_body_type: 'rest_api',
@@ -76,13 +76,27 @@ dev points it at LocalStack on `http://localhost:4566` via
76
76
  `intent: 'sms'` tags this tool for workflow actions that send SMS
77
77
  (versus `data_exchange` for ingestion tools).
78
78
 
79
+ Every tool names a data source, and the messages a sender tool sends carry
80
+ that data source's `uri` — what a status updater matches on — so the step
81
+ creates one for the SMS tool first.
82
+
79
83
  ```typescript
84
+ const toolSourceResp = await api.dataSources.create(tenantSlug, datalakeSlug, {
85
+ name: `Cookbook SMS Source ${runSuffix}`,
86
+ uri: 'sns.local',
87
+ description: 'Origin of the messages the SMS tool sends.',
88
+ status: 'active',
89
+ is_default: false,
90
+ })
91
+ ctx.toolSourceId = toolSourceResp.data.id!
92
+
80
93
  const smsToolResp = await api.tools.create(tenantSlug, datalakeSlug, {
81
94
  name: `Cookbook SMS Tool ${runSuffix}`,
82
95
  description: 'SNS-backed SMS dispatcher for the Welcome SMS workflow, wired to LocalStack.',
83
96
  intent: 'sms',
84
97
  status: 'active',
85
98
  datalake_id: ctx.datalakeId,
99
+ data_source_id: ctx.toolSourceId,
86
100
  body: {
87
101
  tool_body_type: 'sns',
88
102
  auth_method: 'access_key',
@@ -210,6 +210,9 @@ asymmetric cascade behavior by dependent resource:
210
210
  restricting FK (`on_delete: :restrict`), so the delete is
211
211
  **rejected** while any action still points at the source —
212
212
  detach those actions first.
213
+ - Every tool names its data source (`tools.md` §2), and that FK
214
+ restricts too: a data source that still has tools answers
215
+ `409` (`still has tools`). Delete or move its tools first.
213
216
 
214
217
  The auto-created default Manual Upload source (see §2 "A
215
218
  default Manual Upload data source is auto-created per
@@ -99,8 +99,10 @@ Sends an email message via the configured email provider.
99
99
  - **to** (TemplateConfig) — Recipient email address (RFC 5322), rendered as Liquid template
100
100
  - **subject** (TemplateConfig) — Email subject line, rendered as Liquid template
101
101
  - **body** (TemplateConfig) — Email body, rendered as Liquid template
102
- - **from_name** (TemplateConfig, optional) — Sender display name; overrides the email tool's `from_name`
103
- - **from_email** (TemplateConfig, optional) — Sender address (RFC 5322); overrides the email tool's `from_email`
104
- - **reply_to** (TemplateConfig, optional) — Reply-to address (RFC 5322); overrides the email tool's `reply_to`
102
+ - **from_name** (TemplateConfig, optional) — Sender display name; overrides the email tool's `base_from_name`
103
+ - **from_email** (TemplateConfig, optional) — Sender address (RFC 5322); overrides the email tool's `base_from_email`
104
+ - **reply_to** (TemplateConfig, optional) — Reply-to address (RFC 5322); overrides the email tool's `base_reply_to`
105
105
 
106
- Each optional sender field the action leaves unset, or that renders blank, keeps the email tool's value.
106
+ The email tool's Base Configuration renders first. Each field the action leaves unset, or that renders blank,
107
+ keeps the tool's value — the sender fields, and `to`, `subject` and `body` against `base_to`, `base_subject`
108
+ and `base_body`.