@alvera-ai/platform-sdk 0.15.0 → 0.16.0

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.
@@ -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
@@ -49,6 +49,7 @@ const { data: created } = await api.workflows.create(
49
49
  description: 'Send a review-request SMS after a fulfilled appointment',
50
50
  dataset_type: 'appointment', // the dataset this workflow listens on
51
51
  status: 'live', // 'live' | 'draft' | 'manual' (see §5)
52
+ tags: ['appointments', 'sms'], // REQUIRED on every write — [] if untagged (see §2)
52
53
  filter_config: {
53
54
  type: 'custom',
54
55
  body: `{% if appointment.source_uri == "12345.example.com" %}true{% endif %}`,
@@ -138,6 +139,25 @@ authors are Complex) — don't assume every `{ type, body }`-shaped config
138
139
  also carries a request-writable `output_schema`; check whether the field
139
140
  is pinned or caller-authored for that specific config before authoring one.
140
141
 
142
+ ### `tags` is required on every write, and has no default
143
+
144
+ `tags` is a `string[]` on both the request and the response, and it is
145
+ in `required:`. **Every `create()` / `update()` without it is a 422.**
146
+ Send `tags: []` for an untagged workflow — the empty array is how you
147
+ say "no tags", not something you can omit.
148
+
149
+ The missing default is deliberate. `update()` is full-replacement (there
150
+ is no PATCH, and no add-tag / remove-tag endpoint), so an omitted key
151
+ cannot mean "leave tags alone". With a default, omitting it would have
152
+ silently emptied a workflow's tags and returned 200. A 422 beats losing
153
+ state the caller never mentioned.
154
+
155
+ The labels are free text — no taxonomy, no shared vocabulary, and the
156
+ execution pipeline does not read them. But they **do** participate in the
157
+ workflow checksum, so retagging shifts the drift fingerprint. Re-tag and
158
+ `checksum()` returns a different value even though nothing executable
159
+ changed.
160
+
141
161
  ### `update()` replaces inline arrays whole
142
162
 
143
163
  `actions` and `context_datasets` are replaced whole by `update()`.
@@ -164,17 +184,34 @@ The placeholder names depend on the workflow's `dataset_type`
164
184
  appointment-driven workflow, customer_id + invoice.id for an
165
185
  invoice-driven workflow, etc.).
166
186
 
167
- ### Scheduling lives on the ACTION, not on the run
187
+ ### Two scheduling levers, and they answer different questions
188
+
189
+ There are **two**, and picking the wrong one is the most common mistake
190
+ here:
191
+
192
+ | lever | lives on | answers |
193
+ |---|---|---|
194
+ | `scheduled_at` | the **run** (`.run` body, §5) | *when is this send going out?* |
195
+ | `trigger_template` | each **action** (below) | *when does this action fire, relative to the run?* |
196
+
197
+ **`scheduled_at` is the one you want for a campaign.** A send time is
198
+ per-send, not per-workflow: "this campaign goes Tuesday 9am" is a
199
+ property of *this* send, and the next one goes at a different time. Pass
200
+ `scheduled_at` on the run and leave the workflow definition alone.
168
201
 
169
- **The scheduling knob is `trigger_template` on each `action` — the
170
- `.run` / `.execute` calls carry no schedule field.** `.run` takes only
171
- `{ sql_where_clause, manual_override, mode }` (see §5); there is no
172
- `run_at`, no `schedule_for`. This is by design: you invoke the run
173
- *now*, every matched row is evaluated *now*, and each fired action then
174
- waits for its own `trigger_template` instant. "The platform can't
175
- schedule a campaign for Tuesday 9am" is the wrong conclusion — you
176
- schedule the *action* for Tuesday 9am and call `.run` whenever you
177
- like.
202
+ **`trigger_template` is a property of the workflow itself**"the
203
+ reminder goes 24h before the appointment", "the follow-up goes 3 days
204
+ after signup". It is relative to the run and belongs in the definition
205
+ because it is true of every run.
206
+
207
+ Use `trigger_template` for a campaign send time and you are rewriting the
208
+ workflow before every send a full `update()` per campaign, since
209
+ `update()` replaces the resource whole. That was the only option before
210
+ runs became a resource, and it is no longer the right answer.
211
+
212
+ Neither lever is a guarantee of the instant a message leaves: the
213
+ action window / quiet hours (below) can push an action later, and a
214
+ `runtime_filter` can drop it entirely.
178
215
 
179
216
  ### `trigger_template` controls scheduling
180
217
 
@@ -322,6 +359,7 @@ are removed, and re-supplied ones are repositioned.
322
359
  await api.workflows.create(tenantSlug, datalakeSlug, {
323
360
  name: 'Contact-Us Triage',
324
361
  dataset_type: 'generic_table',
362
+ tags: [],
325
363
  // …other workflow fields…
326
364
  workflow_ai_agents: [
327
365
  {
@@ -412,6 +450,8 @@ name required string
412
450
  description optional string
413
451
  dataset_type required string — the dataset this listens on
414
452
  status required enum — 'live' | 'draft' | 'manual' (§5 Create)
453
+ tags required array — string[]; NO default, send [] if
454
+ untagged; feeds the checksum (§2)
415
455
  generic_table_id optional UUID — required iff dataset_type='generic_table'
416
456
  skip_mdm_resolution optional bool — default false; a deliberate
417
457
  choice, NOT a generic-table default (§3)
@@ -486,39 +526,95 @@ are all `workflow_slug`-keyed). Passing a slug to `.get` 422s with
486
526
 
487
527
  ### Run
488
528
 
489
- `.run` evaluates the workflow against a SQL-selected row set. **The
490
- request body is exactly `{ sql_where_clause, manual_override, mode }`
491
- there is no schedule field.** Scheduling is on each action's
492
- `trigger_template` (§2): `.run` fires the pipeline now; the actions it
493
- matches wait for their own trigger instants.
529
+ **`.run` does not run the workflow. It records a run and returns.**
530
+ The work happens when that run *fires* immediately if you did not ask
531
+ for a time, or at `scheduled_at` if you did.
494
532
 
495
533
  ```typescript
496
534
  const { data: run } = await api.workflows.run(
497
- tenantSlug, workflowSlug,
535
+ tenantSlug, datalakeSlug, workflowSlug,
498
536
  {
499
537
  sql_where_clause: `ra.id = '${appointmentId}'`, // run against the regulated tables
500
538
  mode: 'live', // 'live' | 'dry_run'
501
539
  manual_override: false, // see below
540
+ scheduled_at: '2026-08-12T09:00:00+01:00', // OPTIONAL — see below
502
541
  },
503
542
  )
504
- // run.batch_id tagged string (e.g. "manual:<uuid>");
505
- // the prefix distinguishes manual runs from
506
- // cron-driven ones
507
- // run.workflow_run_log_id — UUID — the batch log row (poll via batchLogs)
508
- // run.enqueued_count number of rows the WHERE clause matched
509
- ```
510
-
511
- **`.run`'s response envelope is fire-only — do not trust it to confirm
512
- success or failure.** `enqueued_count` (and any rendered-message field) can
513
- come back empty/`undefined` even when the run genuinely succeeded end to
514
- end observed live: a manual run returned `enqueued_count: undefined` and
515
- a null message parse, yet its workflow execution log showed `filter: true`,
516
- the subject resolved, the action status was `completed`, and a real
517
- provider `external_id` came back (the SMS actually dispatched). The HTTP
518
- 200 only means the run was *accepted*, not that you now know its outcome.
519
- **Always poll `workflow_run_log_id` via `batchLogs` + `workflowLogs`, and
520
- the action's own status, for the actual truth** — never branch logic on
521
- the immediate response body's counts or message fields.
543
+ // run.workflow_run_id UUID of the run. This is the handle for
544
+ // everything else: get, cancel, and the ids below.
545
+ // run.status — 'scheduled' | 'processing' | 'completed'
546
+ // | 'cancelled' | 'failed'
547
+ // run.scheduled_at when it will fire (always present; "now" is a
548
+ // run scheduled for now, not a second code path)
549
+ // run.matched_count — nullable PREVIEW, see the warning below
550
+ ```
551
+
552
+ **`batch_id`, `workflow_run_log_id` and `enqueued_count` are NOT on this
553
+ response.** They described work that had not happened yet: the segment is
554
+ resolved when the run fires, not when it is scheduled, so a run scheduled
555
+ for Friday reaches Friday's rows. `batch_id` and `workflow_run_log_id`
556
+ become readable from `workflowRuns.get` once the run has fired (below);
557
+ `enqueued_count` has no replacement count the rows the fan-out actually
558
+ produced via `workflowLogs`, because a count column can claim three while
559
+ nothing was inserted, and rows cannot.
560
+
561
+ **`matched_count` is a preview, not the audience.** It is what the clause
562
+ matched at schedule time, for sanity-checking the clause you just wrote.
563
+ The segment is resolved *again* at send time with suppressed records
564
+ excluded, so the number that actually goes out can be smaller. Never
565
+ report `matched_count` to a user as "this many people will receive it".
566
+
567
+ #### Scheduling a run
568
+
569
+ `scheduled_at` is an ISO-8601 timestamp **with an offset**
570
+ (`2026-08-12T09:00:00+01:00`). Omit it, or pass `null`, and the run fires
571
+ as soon as a worker picks it up.
572
+
573
+ It is **not** a guarantee of send time. Each action still passes through
574
+ the workflow's action window and its own `trigger_template` (§2), so an
575
+ action may execute later than the run fires — `scheduled_at` says when the
576
+ segment is resolved and the fan-out begins, nothing more.
577
+
578
+ #### Reading a run back — and the trap in it
579
+
580
+ ```typescript
581
+ const { data: state } = await api.workflowRuns.get(tenantSlug, datalakeSlug, runId)
582
+ ```
583
+
584
+ Poll until **`workflow_run_log_id` is a string** — *not* until `status`
585
+ leaves `'scheduled'`. Those are different moments: the run reaches
586
+ `processing` first and writes `workflow_run_log_id` and `batch_id` a beat
587
+ later. A predicate on status alone releases you to read a `null`, and
588
+ because `typeof null === 'object'` in JavaScript the symptom is a baffling
589
+ *"expected string, got object"* rather than an obvious nil.
590
+
591
+ Raise on `status === 'failed'` carrying `failure_reason` instead of
592
+ polling to your deadline — a caller blocked on a run that will never fire
593
+ should learn why on the first read, not thirty seconds later behind a
594
+ generic timeout.
595
+
596
+ **The HTTP 200 from `.run` means the run was *accepted*, not that you know
597
+ its outcome.** For the actual truth, poll the run to get its
598
+ `workflow_run_log_id`, then read `batchLogs` + `workflowLogs` and the
599
+ action's own status. Never branch logic on the immediate response body.
600
+
601
+ #### Listing and cancelling
602
+
603
+ ```typescript
604
+ await api.workflowRuns.list(tenantSlug, datalakeSlug, { page: 1, page_size: 50 })
605
+ await api.workflowRuns.cancel(tenantSlug, datalakeSlug, runId)
606
+ ```
607
+
608
+ `list` is **datalake-scoped, not workflow-scoped** — it answers "what is
609
+ going out from this datalake", across every workflow, which is the question
610
+ a campaign screen actually asks. It takes the full Flop query surface
611
+ (`page`, `page_size`, `order_by`, `order_directions`, `filters`).
612
+
613
+ `cancel` is **refused once a run reaches `processing`**: the fan-out has
614
+ begun and a job already executing cannot be stopped. Cancelling races the
615
+ worker — both sides contend on one conditional update, so exactly one wins
616
+ and the loser is told. Treat a refusal as a normal outcome to surface, not
617
+ an error to retry.
522
618
 
523
619
  `sql_where_clause` accepts a fragment against the dataset's
524
620
  regulated tables (`ra` for appointments, `rp` for patients,
@@ -536,9 +632,11 @@ rejects stays rejected. (The platform pipeline discards the flag before
536
632
  filter and decision evaluation — override is not a "force through"
537
633
  switch.)
538
634
 
539
- A zero-match `sql_where_clause` still returns 200 and still
540
- creates a batch log row (`enqueued_count: 0`) poll
541
- `workflow_run_log_id` uniformly, no special empty case.
635
+ A zero-match `sql_where_clause` still returns 200 and still records a run
636
+ that fires and writes a batch log row poll `workflowRuns.get` for the
637
+ `workflow_run_log_id` uniformly, no special empty case. At schedule time a
638
+ zero-match clause reports `matched_count: 0`, but see the warning above:
639
+ that is a preview of the clause, not proof that nothing was sent.
542
640
 
543
641
  ### Execute (per-row, per-action)
544
642
 
@@ -822,7 +920,8 @@ the datalake (live dispatch only):
822
920
  | enrichment | runs | runs |
823
921
  | action `runtime_filter` | applies | applies |
824
922
  | `manual_override` | dedupe/idempotency bypass only | same |
825
- | answers | `batch_id`, `workflow_run_log_id`, `enqueued_count` | `workflow_execution_log_id`, always `status: 'pending'` |
923
+ | answers | `workflow_run_id`, `status`, `scheduled_at`, `matched_count` (the ids follow from `workflowRuns.get` once it fires) | `workflow_execution_log_id`, always `status: 'pending'` |
924
+ | runs when | the run fires — now, or at `scheduled_at` | immediately |
826
925
 
827
926
  ### The three states a send can be in
828
927