@alvera-ai/platform-sdk 0.17.4-next.gc508af1 → 0.17.4

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 the polling tool
18
- and the data source must exist before you `create()`.
17
+ parent datalake must be `status: 'ready'` and every
18
+ referenced tool 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
- data_source_id: dataSourceId, // the data source whose messages + action logs this updater updates
36
+ sender_tool_ids: [smsToolId], // tools whose sent messages this updater tracks
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 `data_source_id` serve different roles
357
+ ### `updater_tool_id` and `sender_tool_ids` 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,25 +364,14 @@ 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
- - `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.
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).
386
375
 
387
376
  ## 3. Field ownership
388
377
 
@@ -396,7 +385,7 @@ name required string
396
385
  cron_expression required string — standard cron
397
386
  updater_type required enum — e.g. 'cloud_watch'
398
387
  updater_tool_id required UUID — the polling tool
399
- data_source_id required UUID — the data source whose messages + action logs this updater updates (read back nullable)
388
+ sender_tool_ids required UUID[] — tools whose messages this updater tracks
400
389
  datalake_id required UUID
401
390
  updater_body required embed — { updater_body_type, ... }
402
391
  message_config required embed — { type, body }
@@ -413,7 +402,7 @@ Standard JSON:API envelopes per `errors.md`. Common rejections:
413
402
  |-------------------------------------|----------------------------------------------------|
414
403
  | `/cron_expression` | invalid cron syntax or below minimum interval |
415
404
  | `/updater_tool_id` | tool id doesn't exist or wrong datalake (intent is NOT checked — see §2) |
416
- | `/data_source_id` | missing (`can't be blank`, create AND update) or the data source doesn't exist |
405
+ | `/sender_tool_ids/0` | tool id doesn't exist or wrong datalake |
417
406
  | `/updater_body/updater_body_type` | value not in the polymorphic embed's type enum |
418
407
  | `/message_config/body` | empty when `type: 'custom'` |
419
408
  | `/action_log_config` | missing entirely — `Missing field: action_log_config` (create AND update) |
@@ -637,7 +626,7 @@ is the part that matters:
637
626
  - **No updater configured → no delivery truth.** Rows stay `sent`
638
627
  forever; a delivery-rate number computed from them is fiction.
639
628
  Updaters are created manually (this page §1) — the platform does
640
- not auto-create them per data source.
629
+ not auto-create them per sender tool.
641
630
  - **The join is `{external_id, set_params}`,** exact-match, silent
642
631
  on misses (gotcha 3). "Eventually honest": a just-sent row is
643
632
  neither delivered nor failed yet — show the in-between state.
@@ -22,8 +22,7 @@ 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
- - `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.
25
+ - `sender_tool_ids` scopes it to the messages a given sender tool produced.
27
26
 
28
27
  This is **global** — the same shape reconciles any sender's delivery status. See
29
28
  `action_status_updaters.md` for the wire reference and `tools.md` for the
@@ -105,8 +104,7 @@ ctx.cwToolId = cwTool.id!
105
104
  ## 004 — create the action status updater
106
105
 
107
106
  The ASU ties it together: a `cron_expression` schedule, the `updater_tool_id`
108
- (the poller), the `data_source_id` whose messages it reconciles — the one the
109
- SMS sender tool names — and an `updater_body`
107
+ (the poller), the `sender_tool_ids` it reconciles for, and an `updater_body`
110
108
  naming the log group + the time window. On `start_time`/`end_time`, use
111
109
  `now_msec` — the injected variable holding unix milliseconds — piped through
112
110
  `minutes_ago`; the bare `now` is a DateTime and raises here. The
@@ -124,7 +122,7 @@ const { data: asu } = await api.actionStatusUpdaters.create(tenantSlug, datalake
124
122
  cron_expression: '*/30 * * * *',
125
123
  updater_type: 'cloud_watch',
126
124
  updater_tool_id: ctx.cwToolId,
127
- data_source_id: dataSourceId,
125
+ sender_tool_ids: [ctx.smsToolId],
128
126
  datalake_id: ctx.datalakeId,
129
127
  updater_body: {
130
128
  updater_body_type: 'cloud_watch_request',
@@ -161,7 +159,7 @@ const { data: ck } = await api.actionStatusUpdaters.checksum(tenantSlug, datalak
161
159
  cron_expression: row.cron_expression,
162
160
  updater_type: 'cloud_watch',
163
161
  updater_tool_id: ctx.cwToolId,
164
- data_source_id: dataSourceId,
162
+ sender_tool_ids: [ctx.smsToolId],
165
163
  datalake_id: ctx.datalakeId,
166
164
  updater_body: {
167
165
  updater_body_type: 'cloud_watch_request',
@@ -237,9 +235,8 @@ blind.
237
235
  # Gotchas
238
236
 
239
237
  - **The poller tool's intent is `status_poller`, not the sender's intent.** The
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`.
238
+ reconciler reads logs; it doesn't send. `sender_tool_ids` is the separate list
239
+ of sender tools whose messages it updates.
243
240
  - **`message_config` renders once per event, and emits a FLAT object.** The event
244
241
  is the assigns — there is no `events` list to loop — and every top-level key
245
242
  that is not `external_id` IS the update (`status`, `delivered_at`, …). There is
@@ -99,22 +99,12 @@ 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
-
111
102
  const { data: tool } = await api.tools.create(tenantSlug, datalakeSlug, {
112
103
  name: `Legal-Entity Extraction Tool ${runSuffix}`,
113
104
  description: 'Multimodal LLM-enrichment adapter for legal_entity extraction.',
114
105
  intent: 'llm_enrichment',
115
106
  status: 'active',
116
107
  datalake_id: ctx.datalakeId,
117
- data_source_id: toolSource.id!,
118
108
  response_extractor: { type: 'custom', body: OPENAI_EXTRACTOR, output_schema: ENRICHMENT_OUTPUT_SCHEMA },
119
109
  body: {
120
110
  tool_body_type: 'rest_api',
@@ -77,27 +77,13 @@ 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
-
84
80
  ```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
-
94
81
  const smsToolResp = await api.tools.create(tenantSlug, datalakeSlug, {
95
82
  name: `Cookbook SMS Tool ${runSuffix}`,
96
83
  description: 'SNS-backed SMS dispatcher for the Review SMS workflow, wired to LocalStack.',
97
84
  intent: 'sms',
98
85
  status: 'active',
99
86
  datalake_id: ctx.datalakeId,
100
- data_source_id: ctx.toolSourceId,
101
87
  body: {
102
88
  tool_body_type: 'sns',
103
89
  auth_method: 'access_key',
@@ -70,27 +70,13 @@ 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
-
77
73
  ```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
-
87
74
  const smsToolResp = await api.tools.create(tenantSlug, datalakeSlug, {
88
75
  name: `Cookbook SMS Tool ${runSuffix}`,
89
76
  description: 'SNS-backed SMS dispatcher for the Happy Birthday workflow, wired to LocalStack.',
90
77
  intent: 'sms',
91
78
  status: 'active',
92
79
  datalake_id: ctx.datalakeId,
93
- data_source_id: ctx.toolSourceId,
94
80
  body: {
95
81
  tool_body_type: 'sns',
96
82
  auth_method: 'access_key',
@@ -136,27 +136,13 @@ 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
-
143
139
  ```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
-
153
140
  const smsToolResp = await api.tools.create(tenantSlug, datalakeSlug, {
154
141
  name: `Cookbook SMS Tool ${runSuffix}`,
155
142
  description: 'SNS-backed SMS dispatcher for the Contact Us Triage workflow, wired to LocalStack.',
156
143
  intent: 'sms',
157
144
  status: 'active',
158
145
  datalake_id: ctx.datalakeId,
159
- data_source_id: ctx.toolSourceId,
160
146
  body: {
161
147
  tool_body_type: 'sns',
162
148
  auth_method: 'access_key',
@@ -208,7 +194,6 @@ const llmToolResp = await api.tools.create(tenantSlug, datalakeSlug, {
208
194
  intent: 'llm_enrichment',
209
195
  status: 'active',
210
196
  datalake_id: ctx.datalakeId,
211
- data_source_id: ctx.toolSourceId,
212
197
  response_extractor: { type: 'custom', body: OLLAMA_EXTRACTOR, output_schema: ENRICHMENT_OUTPUT_SCHEMA },
213
198
  body: {
214
199
  tool_body_type: 'rest_api',
@@ -79,27 +79,13 @@ 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
-
86
82
  ```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
-
96
83
  const smsToolResp = await api.tools.create(tenantSlug, datalakeSlug, {
97
84
  name: `Cookbook SMS Tool ${runSuffix}`,
98
85
  description: 'SNS-backed SMS dispatcher for the Dunning SMS workflow, wired to LocalStack.',
99
86
  intent: 'sms',
100
87
  status: 'active',
101
88
  datalake_id: ctx.datalakeId,
102
- data_source_id: ctx.toolSourceId,
103
89
  body: {
104
90
  tool_body_type: 'sns',
105
91
  auth_method: 'access_key',
@@ -77,27 +77,13 @@ 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
-
84
80
  ```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
-
94
81
  const smsToolResp = await api.tools.create(tenantSlug, datalakeSlug, {
95
82
  name: `Cookbook SMS Tool ${runSuffix}`,
96
83
  description: 'SNS-backed SMS dispatcher for the KYC-notification workflow, wired to LocalStack.',
97
84
  intent: 'sms',
98
85
  status: 'active',
99
86
  datalake_id: ctx.datalakeId,
100
- data_source_id: ctx.toolSourceId,
101
87
  body: {
102
88
  tool_body_type: 'sns',
103
89
  auth_method: 'access_key',
@@ -512,9 +512,7 @@ The sender lives in the tool's Base Configuration: `base_from_email`
512
512
  rendered when the tool is saved with the tool's own variables only
513
513
  (`tool.name`, `tool.provider`, …). An email action's own `from_email` /
514
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.
515
+ non-blank.
518
516
 
519
517
  The connected app is the campaign's booking form. It is registered
520
518
  here, and §010's actions deep-link into it.
@@ -528,7 +526,6 @@ const { data: smsTool } = await api.tools.create(tenantSlug, datalakeSlug, {
528
526
  intent: ToolIntent.SMS,
529
527
  status: 'active',
530
528
  datalake_id: ctx.datalakeId,
531
- data_source_id: dataSourceId,
532
529
  body: {
533
530
  tool_body_type: 'sns',
534
531
  auth_method: 'access_key',
@@ -547,7 +544,6 @@ const { data: emailTool } = await api.tools.create(tenantSlug, datalakeSlug, {
547
544
  intent: ToolIntent.EMAIL,
548
545
  status: 'active',
549
546
  datalake_id: ctx.datalakeId,
550
- data_source_id: dataSourceId,
551
547
  body: {
552
548
  tool_body_type: 'email',
553
549
  provider: 'mock',
@@ -663,7 +659,6 @@ const { data: sesTool } = await api.tools.create(tenantSlug, datalakeSlug, {
663
659
  intent: ToolIntent.EMAIL,
664
660
  status: 'active',
665
661
  datalake_id: ctx.datalakeId,
666
- data_source_id: dataSourceId,
667
662
  body: {
668
663
  tool_body_type: 'email',
669
664
  provider: 'ses',
@@ -760,7 +755,7 @@ const { data: sesUpdater } = await api.actionStatusUpdaters.create(tenantSlug, d
760
755
  cron_expression: '*/30 * * * *',
761
756
  updater_type: 'cloud_watch',
762
757
  updater_tool_id: sesReader.id!,
763
- data_source_id: dataSourceId,
758
+ sender_tool_ids: [ctx.sesToolId],
764
759
  datalake_id: ctx.datalakeId,
765
760
  updater_body: {
766
761
  updater_body_type: 'cloud_watch_request',
@@ -57,9 +57,8 @@ dataSourceId = ds.id!
57
57
 
58
58
  ## 002 — create the sender tool whose messages get reconciled
59
59
 
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.
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.
63
62
 
64
63
  ```typescript
65
64
  const { data: smsTool } = await api.tools.create(tenantSlug, datalakeSlug, {
@@ -127,7 +126,7 @@ try {
127
126
  cron_expression: '*/30 * * * *',
128
127
  updater_type: 'restapi',
129
128
  updater_tool_id: ctx.restToolId,
130
- data_source_id: dataSourceId,
129
+ sender_tool_ids: [ctx.senderToolId],
131
130
  datalake_id: ctx.datalakeId,
132
131
  // FLOOR the platform enforces: an array whose items are objects listing
133
132
  // `external_id` in `required`. A bare `{ type: 'array' }` is a 422 at
@@ -202,7 +201,7 @@ const { data: asu } = await api.actionStatusUpdaters.create(tenantSlug, datalake
202
201
  cron_expression: '*/30 * * * *',
203
202
  updater_type: 'restapi',
204
203
  updater_tool_id: ctx.restToolId,
205
- data_source_id: dataSourceId,
204
+ sender_tool_ids: [ctx.senderToolId],
206
205
  datalake_id: ctx.datalakeId,
207
206
  events_output_schema: {
208
207
  type: 'array',
@@ -82,27 +82,13 @@ 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
-
89
85
  ```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
-
99
86
  const smsToolResp = await api.tools.create(tenantSlug, datalakeSlug, {
100
87
  name: `Cookbook SMS Tool ${runSuffix}`,
101
88
  description: 'SNS-backed SMS dispatcher for the Sanctions Review workflow, wired to LocalStack.',
102
89
  intent: 'sms',
103
90
  status: 'active',
104
91
  datalake_id: ctx.datalakeId,
105
- data_source_id: ctx.toolSourceId,
106
92
  body: {
107
93
  tool_body_type: 'sns',
108
94
  auth_method: 'access_key',
@@ -155,7 +141,6 @@ const llmToolResp = await api.tools.create(tenantSlug, datalakeSlug, {
155
141
  intent: 'llm_enrichment',
156
142
  status: 'active',
157
143
  datalake_id: ctx.datalakeId,
158
- data_source_id: ctx.toolSourceId,
159
144
  response_extractor: { type: 'custom', body: OLLAMA_EXTRACTOR, output_schema: ENRICHMENT_OUTPUT_SCHEMA },
160
145
  body: {
161
146
  tool_body_type: 'rest_api',
@@ -146,27 +146,13 @@ 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
-
153
149
  ```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
-
163
150
  const smsToolResp = await api.tools.create(tenantSlug, datalakeSlug, {
164
151
  name: `Cookbook SMS Tool ${runSuffix}`,
165
152
  description: 'SNS-backed SMS dispatcher for the Score Lead workflow, wired to LocalStack.',
166
153
  intent: 'sms',
167
154
  status: 'active',
168
155
  datalake_id: ctx.datalakeId,
169
- data_source_id: ctx.toolSourceId,
170
156
  body: {
171
157
  tool_body_type: 'sns',
172
158
  auth_method: 'access_key',
@@ -218,7 +204,6 @@ const llmToolResp = await api.tools.create(tenantSlug, datalakeSlug, {
218
204
  intent: 'llm_enrichment',
219
205
  status: 'active',
220
206
  datalake_id: ctx.datalakeId,
221
- data_source_id: ctx.toolSourceId,
222
207
  response_extractor: { type: 'custom', body: OLLAMA_EXTRACTOR, output_schema: ENRICHMENT_OUTPUT_SCHEMA },
223
208
  body: {
224
209
  tool_body_type: 'rest_api',
@@ -65,27 +65,13 @@ 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
-
72
68
  ```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
-
82
69
  const smsToolResp = await api.tools.create(tenantSlug, datalakeSlug, {
83
70
  name: `Cookbook SMS Tool ${runSuffix}`,
84
71
  description: 'SNS-backed SMS dispatcher for the priority-triage workflow, wired to LocalStack.',
85
72
  intent: 'sms',
86
73
  status: 'active',
87
74
  datalake_id: ctx.datalakeId,
88
- data_source_id: ctx.toolSourceId,
89
75
  body: {
90
76
  tool_body_type: 'sns',
91
77
  auth_method: 'access_key',
@@ -133,7 +119,6 @@ const llmToolResp = await api.tools.create(tenantSlug, datalakeSlug, {
133
119
  intent: 'llm_enrichment',
134
120
  status: 'active',
135
121
  datalake_id: ctx.datalakeId,
136
- data_source_id: ctx.toolSourceId,
137
122
  response_extractor: { type: 'custom', body: OLLAMA_EXTRACTOR, output_schema: ENRICHMENT_OUTPUT_SCHEMA },
138
123
  body: {
139
124
  tool_body_type: 'rest_api',
@@ -76,27 +76,13 @@ 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
-
83
79
  ```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
-
93
80
  const smsToolResp = await api.tools.create(tenantSlug, datalakeSlug, {
94
81
  name: `Cookbook SMS Tool ${runSuffix}`,
95
82
  description: 'SNS-backed SMS dispatcher for the Welcome SMS workflow, wired to LocalStack.',
96
83
  intent: 'sms',
97
84
  status: 'active',
98
85
  datalake_id: ctx.datalakeId,
99
- data_source_id: ctx.toolSourceId,
100
86
  body: {
101
87
  tool_body_type: 'sns',
102
88
  auth_method: 'access_key',
@@ -210,9 +210,6 @@ 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.
216
213
 
217
214
  The auto-created default Manual Upload source (see §2 "A
218
215
  default Manual Upload data source is auto-created per
package/.agent/tools.md CHANGED
@@ -12,11 +12,10 @@ dimensions:
12
12
  - **body type** declares HOW the tool connects (S3, SFTP, SQL,
13
13
  REST, AWS Lambda, …) via a polymorphic discriminator
14
14
 
15
- A datalake can own many tools, and every tool names the data source
16
- it belongs to (`data_source_id`, required on every intent — see §2).
17
- A tool stays reachable to any workflow on the datalake; the data
18
- source decides which `uri` the messages it sends carry, which is what
19
- action status updaters match on.
15
+ A datalake can own many tools. Some tools nest under a data source
16
+ (`data_source_id` populated — typical for ingestion-receiving
17
+ tools like `manual_upload`); others are standalone and reachable
18
+ to any workflow on the datalake.
20
19
 
21
20
  ## 1. Wire shape
22
21
 
@@ -120,7 +119,6 @@ happened to it** — no delivery status, no reconciliation.
120
119
  await api.tools.create(tenantSlug, datalakeSlug, {
121
120
  name: 'Campaign Sender — Main Line',
122
121
  intent: 'sms',
123
- data_source_id: dataSourceId, // every tool names one; its messages carry this source's uri
124
122
  tool_body_type: 'twilio',
125
123
  variant_type: 'primary',
126
124
  account_sid: 'AC…', // "AC" + 32 hex
@@ -231,29 +229,16 @@ create time:
231
229
  A misaligned pair (e.g. `intent: sms` + `tool_body_type: sftp`)
232
230
  returns a `422` with `body.tool_body_type` in `errors`.
233
231
 
234
- ### `data_source_id` is required on every tool
232
+ ### `data_source_id` is optional but discriminator-aware
235
233
 
236
- Every tool names the data source it belongs to, whatever its intent
237
- or body type. A create without `data_source_id` is a `422` at
238
- `/data_source_id` (`can't be blank`); `update()` is a full
239
- replacement, so send it on every PUT too. The generated type still
240
- marks the field optional and nullable — the server's rule is the one
241
- that holds.
234
+ When set, the tool is **embedded under a data source** — its
235
+ lifecycle is tied to the parent data source, and ingestion paths
236
+ expect it at that scope. When omitted, the tool is **standalone**
237
+ on the datalake — available to any workflow that names it.
242
238
 
243
- What it drives:
244
-
245
- - **The messages a tool sends carry its data source's `uri`.** An
246
- action status updater names one data source and matches polled
247
- events to messages and action logs by that `uri` plus the vendor's
248
- id (`action_status_updaters.md` §2). One data source may hold SMS,
249
- MMS and voice tools; nothing checks what else is on it.
250
- - **A data source cannot be deleted while it still has tools.** The
251
- delete answers `409` (`still has tools`); delete or move its tools
252
- first. The foreign key restricts, so nothing cascades.
253
-
254
- The body type still signals the typical pattern: a `manual_upload`
255
- tool's data source IS the upload sink; a sender's data source is the
256
- one its messages, and its status updater, belong to.
239
+ The body type itself signals the typical pattern: `manual_upload`
240
+ is almost always embedded (the data source IS the upload sink);
241
+ `sns`, `rest_api`, `sql_database` are typically standalone.
257
242
 
258
243
  ### Body-type sub-fields are validated against the matching discriminator branch (server-side)
259
244
 
@@ -377,7 +362,6 @@ const { data: sesTool } = await api.tools.create(tenantSlug, datalakeSlug, {
377
362
  intent: ToolIntent.EMAIL,
378
363
  status: 'active',
379
364
  datalake_id: datalakeId,
380
- data_source_id: dataSourceId,
381
365
  body: {
382
366
  tool_body_type: 'email',
383
367
  provider: 'ses',
@@ -562,8 +546,8 @@ silent OIDC-provider outage can fail an otherwise-cosmetic
562
546
 
563
547
  scoping
564
548
  datalake_id parent datalake (UUID)
565
- data_source_id required — the data source the tool belongs
566
- to (every intent; send it on every PUT)
549
+ data_source_id (optional) parent data source for embedded
550
+ tools; omit for standalone
567
551
 
568
552
  body (polymorphic — required keys depend on tool_body_type)
569
553
  tool_body_type discriminator — names which branch shape
@@ -597,8 +581,8 @@ Capture both `id` and `slug` from the create response; never
597
581
  pre-compute either client-side. The **UUID `id`** is the CRUD path
598
582
  key (`.get` / `.update` / `.delete`) AND the cross-resource
599
583
  reference — workflows and data-activation clients bind a tool by
600
- `id` (`tool_id`), and action status updaters by `updater_tool_id`
601
- (see §7; an updater names a data source, not its sender tools). The `slug` is the human handle; it does
584
+ `id` (`tool_id`), and action status updaters by `updater_tool_id` /
585
+ `sender_tool_ids` (see §7). The `slug` is the human handle; it does
602
586
  NOT key a CRUD `.get` (passing it 422s `Expected :uuid`). See
603
587
  `type_naming.md` for the id/slug/name convention.
604
588
 
@@ -860,9 +844,9 @@ downstream resource that names a tool must apply AFTER it.
860
844
  `status: "error"` + non-empty `error_message`. If you see a
861
845
  5xx, file a platform bug, do not retry.
862
846
 
863
- 5. **Tools list per datalake, not per data source**
847
+ 5. **Standalone vs embedded tool routing differs at list time**
864
848
  — `tools.list(tenant, datalake, query?)` returns ALL tools on
865
- the datalake, across every data source. The Flop query
849
+ the datalake (standalone + embedded). The Flop query
866
850
  (type_naming.md) filters on `intent`, `status`, `name`, etc. —
867
851
  but `data_source_id` is NOT in the filterable set, so tools
868
852
  owned by a specific data source must still be filtered