@alvera-ai/platform-sdk 0.15.0 → 0.16.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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