@alvera-ai/platform-sdk 0.10.0-rc.9 → 0.12.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.
Files changed (67) hide show
  1. package/.agent/AGENTS.md +503 -0
  2. package/.agent/account_management.md +456 -0
  3. package/.agent/action_status_updaters.md +264 -0
  4. package/.agent/ai_agents.md +462 -0
  5. package/.agent/ai_sandbox.md +290 -0
  6. package/.agent/async.md +112 -0
  7. package/.agent/connected_apps.md +408 -0
  8. package/.agent/cookbook/_fixtures/README.md +106 -0
  9. package/.agent/cookbook/_fixtures/accounts_receivable/_customers_accounts_receivable_customer.liquid +32 -0
  10. package/.agent/cookbook/_fixtures/accounts_receivable/_customers_accounts_receivable_mdm.liquid +20 -0
  11. package/.agent/cookbook/_fixtures/accounts_receivable/stripe_customers_batch1.csv +5 -0
  12. package/.agent/cookbook/_fixtures/foundation/_lead_submissions_foundation_generic_table.liquid +33 -0
  13. package/.agent/cookbook/_fixtures/foundation/_lead_submissions_foundation_legal_entity.liquid +88 -0
  14. package/.agent/cookbook/_fixtures/foundation/_lead_submissions_foundation_mdm.liquid +48 -0
  15. package/.agent/cookbook/_fixtures/healthcare/_cahps_appointments_healthcare_appointment.liquid +47 -0
  16. package/.agent/cookbook/_fixtures/healthcare/_cahps_appointments_healthcare_mdm.liquid +24 -0
  17. package/.agent/cookbook/_fixtures/healthcare/_cahps_appointments_healthcare_patient.liquid +38 -0
  18. package/.agent/cookbook/_fixtures/healthcare/memorandum-of-association-01.png +0 -0
  19. package/.agent/cookbook/_fixtures/healthcare/sample_two_page.pdf +43 -0
  20. package/.agent/cookbook/_fixtures/payment_risk/_compliance_screenings_payment_risk_compliance_screening.liquid +59 -0
  21. package/.agent/cookbook/_fixtures/payment_risk/_compliance_screenings_payment_risk_mdm.liquid +36 -0
  22. package/.agent/cookbook/_fixtures/payment_risk/_payment_accounts_payment_risk_mdm.liquid +30 -0
  23. package/.agent/cookbook/_fixtures/payment_risk/_payment_accounts_payment_risk_payment_account.liquid +55 -0
  24. package/.agent/cookbook/_setup/accounts_receivable.md +282 -0
  25. package/.agent/cookbook/_setup/foundation.md +277 -0
  26. package/.agent/cookbook/_setup/healthcare.md +279 -0
  27. package/.agent/cookbook/_setup/payment_risk.md +283 -0
  28. package/.agent/cookbook/action-status-updaters.md +212 -0
  29. package/.agent/cookbook/ai-agent-invoke.md +243 -0
  30. package/.agent/cookbook/appointment-review-sms-workflow.md +761 -0
  31. package/.agent/cookbook/birthday-greeting-sms-trigger.md +656 -0
  32. package/.agent/cookbook/bulk-ingest.md +254 -0
  33. package/.agent/cookbook/contact-us-triage-with-llm.md +622 -0
  34. package/.agent/cookbook/custom-tables.md +201 -0
  35. package/.agent/cookbook/dunning-sms-for-delinquent.md +619 -0
  36. package/.agent/cookbook/invite-team.md +194 -0
  37. package/.agent/cookbook/kyc-notification-on-account-activation.md +619 -0
  38. package/.agent/cookbook/rest-fetch.md +246 -0
  39. package/.agent/cookbook/sanctions-screening-with-agent-review.md +733 -0
  40. package/.agent/cookbook/score-leads-with-llm-categorization.md +624 -0
  41. package/.agent/cookbook/system-templates.md +129 -0
  42. package/.agent/cookbook/talk-to-data.md +139 -0
  43. package/.agent/cookbook/triage-prospects-by-priority.md +533 -0
  44. package/.agent/cookbook/welcome-sms-for-customers.md +607 -0
  45. package/.agent/data_activation_clients.md +559 -0
  46. package/.agent/data_sources.md +235 -0
  47. package/.agent/datalakes.md +769 -0
  48. package/.agent/debugging.md +137 -0
  49. package/.agent/errors.md +190 -0
  50. package/.agent/generic_tables.md +351 -0
  51. package/.agent/interoperability_contracts.md +417 -0
  52. package/.agent/mdm.md +293 -0
  53. package/.agent/mutations.md +126 -0
  54. package/.agent/templates.md +98 -0
  55. package/.agent/tool-call-configs.md +90 -0
  56. package/.agent/tools.md +547 -0
  57. package/.agent/type_naming.md +129 -0
  58. package/.agent/workflows.md +617 -0
  59. package/README.md +178 -0
  60. package/dist/bin/platform-sdk.d.mts +1 -0
  61. package/dist/bin/platform-sdk.mjs +106 -0
  62. package/dist/bin/platform-sdk.mjs.map +1 -0
  63. package/dist/index.d.mts +1395 -44063
  64. package/dist/index.d.mts.map +1 -1
  65. package/dist/index.mjs +1274 -7356
  66. package/dist/index.mjs.map +1 -1
  67. package/package.json +18 -9
@@ -0,0 +1,617 @@
1
+ # Workflows
2
+
3
+ A **workflow** is the event-driven decision pipeline that fires on
4
+ top of ingested dataset rows. Every workflow has the same shape:
5
+
6
+ ```
7
+ dataset row event
8
+ │
9
+ ▼
10
+ filter — does this row qualify? (Liquid expression → boolean)
11
+ │
12
+ ▼
13
+ enrichment — optional AI step that derives extra context (see §3)
14
+ │
15
+ ▼
16
+ decision — which action key fires? (Liquid array of strings, OR
17
+ an AI agent's output)
18
+ │
19
+ ▼
20
+ action(s) — scheduled tool calls keyed by decision_key
21
+ (SMS, email, voice, export, ...)
22
+ ```
23
+
24
+ SDK namespace: `api.workflows`. Sub-namespaces for run-time logs:
25
+
26
+ ```
27
+ api.workflows.batchLogs — one log row per workflow run (batch)
28
+ api.workflows.workflowLogs — one log row per dataset row processed
29
+ (workflow execution logs)
30
+ ```
31
+
32
+ Workflows are **Datalake-DB-resident** — the parent datalake must
33
+ be `status: 'ready'` and every referenced resource (tools,
34
+ connected apps, AI agents) must exist before you `create()`.
35
+
36
+ ## 1. Wire shape
37
+
38
+ ```typescript
39
+ import type {
40
+ WorkflowRequestWritable,
41
+ WorkflowResponse,
42
+ } from '@alvera-ai/platform-sdk'
43
+
44
+ const { data: created } = await api.workflows.create(
45
+ tenantSlug,
46
+ datalakeSlug,
47
+ {
48
+ name: 'Appointment Review SMS',
49
+ description: 'Send a review-request SMS after a fulfilled appointment',
50
+ dataset_type: 'appointment', // the dataset this workflow listens on
51
+ status: 'live', // 'live' | 'draft'
52
+ filter_config: {
53
+ type: 'custom',
54
+ body: `{% if appointment.source_uri == "12345.example.com" %}true{% endif %}`,
55
+ output_schema: '{"type":"boolean"}',
56
+ },
57
+ decision_config: {
58
+ type: 'custom',
59
+ body: `["send_appointment_review_sms"]`,
60
+ output_schema: '{"type":"array","items":{"type":"string"}}',
61
+ },
62
+ context_datasets: [
63
+ {
64
+ dataset_type: 'message',
65
+ where_clause: `rm.patient_id = '{{ patient_id }}' AND rm.sent_at > NOW() - INTERVAL '6 months'`,
66
+ limit: 1,
67
+ position: 0,
68
+ },
69
+ ],
70
+ actions: [
71
+ {
72
+ action_type: 'sms',
73
+ tool_id: smsToolId,
74
+ decision_key: 'send_appointment_review_sms',
75
+ position: 0,
76
+ trigger_template: 'now', // 'now' = next available slot
77
+ idempotency_template:
78
+ '{{ patient_id }}-{{ appointment.unregulated_appointment_id }}-{{ decision_key }}',
79
+ connected_app_id: connectedAppId,
80
+ connected_app_route: '/forms/review',
81
+ connected_app_metadata_template:
82
+ '{"appointment_id":"{{ appointment.unregulated_appointment_id }}"}',
83
+ tool_call: {
84
+ tool_call_type: 'sms_request',
85
+ to: { type: 'custom', body: '{{ patient_phone }}' },
86
+ body: { type: 'custom', body: 'Hi {{ patient_first_name }}, please share your feedback at {{ connected_app_form_url }}' },
87
+ sms_type: 'transactional',
88
+ },
89
+ },
90
+ ],
91
+ },
92
+ )
93
+ ```
94
+
95
+ `actions`, `context_datasets`, and `workflow_ai_agents` are all
96
+ inline arrays — one `create()` builds the entire workflow body, agents
97
+ included (see §3).
98
+
99
+ ## 2. Rules the type cannot encode
100
+
101
+ ### `filter_config` / `decision_config` are Liquid expressions with typed output
102
+
103
+ Both configs follow the same shape: `{ type, body, output_schema }`.
104
+
105
+ - `filter_config.body` renders to a boolean — the literal string
106
+ `"true"` (non-empty) means PASS; an empty render means SKIP.
107
+ (Same inverted-semantics rule as `interoperability_contracts.md`
108
+ §2 `filter_template`.)
109
+ - `decision_config.body` renders to a JSON array of decision keys.
110
+ Each rendered key fires the matching `action` (by
111
+ `action.decision_key`).
112
+
113
+ `output_schema` is a JSON-schema string that the platform uses
114
+ to validate the rendered output before invoking the next stage.
115
+
116
+ ### `update()` replaces inline arrays whole
117
+
118
+ `actions` and `context_datasets` are replaced whole by `update()`.
119
+ `update()` replaces the whole array — resupply the full set; any
120
+ item you omit is removed. Always re-supply the full array on every
121
+ update, even when you're only changing `description`.
122
+
123
+ Factor the workflow body construction into a helper that
124
+ accepts the changing fields and returns the full body — same
125
+ pattern used for datalake updates (see `datalakes.md` §5).
126
+
127
+ ### `idempotency_template` prevents duplicate action firings
128
+
129
+ An action's `idempotency_template` renders to a string per
130
+ dataset row; the platform deduplicates so the same idempotency
131
+ key never fires twice. Typical recipe:
132
+
133
+ ```
134
+ '{{ <subject_id> }}-{{ <event_id> }}-{{ decision_key }}'
135
+ ```
136
+
137
+ The placeholder names depend on the workflow's `dataset_type`
138
+ (patient_id + appointment.unregulated_appointment_id for an
139
+ appointment-driven workflow, customer_id + invoice.id for an
140
+ invoice-driven workflow, etc.).
141
+
142
+ ### `trigger_template` controls scheduling
143
+
144
+ A Liquid template rendering to a datetime or one of these
145
+ literals:
146
+
147
+ ```
148
+ 'now' — schedule at the next available slot at-or-after current time
149
+ '' — same as 'now' (empty string also routes to the now-slot)
150
+ ISO 8601 — schedule at the named instant
151
+ ```
152
+
153
+ The action is queued through the platform's scheduling layer
154
+ (action windows, throttling), so even `'now'` may resolve to a
155
+ slot some minutes in the future under load.
156
+
157
+ ### Action-window + queue-throttle render `'now'` non-literal
158
+
159
+ Avoid templates like `{{ "" | now | date: "%Y-%m-%d..." }}` —
160
+ they render a literal current timestamp, which then collides
161
+ with the action-window clamp + queue throttle and pushes the
162
+ slot forward unpredictably. Use the literal `'now'` sentinel
163
+ when you want "fire as soon as possible".
164
+
165
+ ## 3. Agent-driven workflows: AI enrichment + AI-emitted decisions
166
+
167
+ When `decision_config.type` references an AI agent (instead of
168
+ `'custom'` with a literal Liquid body), the workflow gains an
169
+ **enrichment** step between filter and decision:
170
+
171
+ ```
172
+ filter
173
+ │
174
+ ▼
175
+ enrichment — each ai_agent runs, producing typed output
176
+ that the decision template can reference
177
+ │
178
+ ▼
179
+ decision — AI agent emits the decision_key array
180
+ (or a custom decision template references
181
+ agent outputs via Liquid)
182
+ │
183
+ ▼
184
+ action(s)
185
+ ```
186
+
187
+ Agents are **nested directly on the workflow body** — the create and
188
+ update body accept a `workflow_ai_agents` array. There is
189
+ no separate attach/detach endpoint. The agents **fold into the
190
+ workflow's own checksum** — there is no per-join checksum. `position` is
191
+ required, and workflow joins **omit `output_schema`** (the server pins
192
+ it from the agent's `input_schema`). The agent create body (prompt
193
+ config, `input_schema` / `llm_response_schema`, runtime failure codes)
194
+ lives in `ai_agents.md` §3. A workflow `get()` surfaces the nested agents as
195
+ `workflow_ai_agents: [...]`.
196
+
197
+ `update()` replaces the whole array: the body must
198
+ re-supply every agent you want to keep — agents absent from the array
199
+ are removed, and re-supplied ones are repositioned.
200
+
201
+ ```typescript
202
+ await api.workflows.create(tenantSlug, datalakeSlug, {
203
+ name: 'Contact-Us Triage',
204
+ dataset_type: 'generic_table',
205
+ // …other workflow fields…
206
+ workflow_ai_agents: [
207
+ {
208
+ ai_agent_id: aiAgentId, // UUID from api.aiAgents.create
209
+ position: 0, // ordering when multiple agents fire; REQUIRED
210
+ context_mapping_config: {
211
+ type: 'custom',
212
+ // Liquid renders to a JSON object whose keys match the referenced
213
+ // agent's input_schema. Values reference the inbound row's fields
214
+ // via `event_dataset.<field>`. Workflow joins OMIT output_schema
215
+ // — the server pins it from the agent's input_schema.
216
+ body: JSON.stringify({
217
+ msg: '{{ event_dataset.message }}',
218
+ submission_id: '{{ event_dataset.submission_id }}',
219
+ }),
220
+ },
221
+ },
222
+ ],
223
+ })
224
+ ```
225
+
226
+ The agent's parsed output is exposed in the workflow's runtime
227
+ context at `additional_context.<agent_slug>.<field>` (property
228
+ paths match the agent's `llm_response_schema`). Decision and
229
+ action templates reference it via Liquid:
230
+
231
+ ```liquid
232
+ {{ additional_context.contact-us-triage-categorizer.category }}
233
+ ```
234
+
235
+ A workflow with no attached agents observably runs the enrichment
236
+ stage but emits `{ status: 'skipped' }` in the per-step artifact
237
+ (see §6).
238
+
239
+ ### `event_dataset` is the workflow accessor for the inbound row
240
+
241
+ Workflow templates (filter, context mappings, action bodies)
242
+ reference the inbound dataset row via the `event_dataset`
243
+ variable. Field paths match the columns of the workflow's
244
+ `dataset_type`:
245
+
246
+ ```
247
+ dataset_type: 'appointment' → event_dataset.id, event_dataset.start, ...
248
+ dataset_type: 'generic_table' → event_dataset.<column_name> for each
249
+ column on the bound table
250
+ ```
251
+
252
+ For generic-table-bound workflows, `event_dataset` carries the
253
+ table's columns directly (no separate `row` indirection).
254
+
255
+ ### `skip_mdm_resolution: true` for generic-table workflows
256
+
257
+ Workflows bound to a `dataset_type: 'generic_table'` typically
258
+ set `skip_mdm_resolution: true` on the workflow body. The flag
259
+ opts out of the platform's entity-resolution step on the inbound
260
+ row, which doesn't apply when the row IS the subject (no canonical
261
+ entity to resolve to). Workflows on entity-anchored datasets
262
+ (`patient`, `customer`, `legal_entity`, etc.) leave the flag at
263
+ its default `false`.
264
+
265
+ ## 4. Field ownership
266
+
267
+ **Server-derived (Response-only).** Universal set from
268
+ `type_naming.md`, plus:
269
+
270
+ ```
271
+ actions[*].id UUID per inline action row
272
+ context_datasets[*].id UUID per inline context-dataset row
273
+ ```
274
+
275
+ **Caller-supplied (round-trip).**
276
+
277
+ ```
278
+ name required string
279
+ description optional string
280
+ dataset_type required string — the dataset this listens on
281
+ status required enum — 'live' | 'draft'
282
+ generic_table_id optional UUID — required iff dataset_type='generic_table'
283
+ skip_mdm_resolution optional bool — default false; set true for
284
+ generic-table-bound workflows
285
+ filter_config required embed — { type, body, output_schema }
286
+ decision_config required embed — same shape
287
+ context_datasets required array — inline; replace-on-PUT
288
+ actions required array — inline; replace-on-PUT
289
+ workflow_ai_agents optional array — inline; replace-on-PUT (see §3)
290
+ ```
291
+
292
+ **Write-only (Request-only).** None.
293
+
294
+ ## 5. Lifecycle
295
+
296
+ ### Create
297
+
298
+ Synchronous. The workflow is immediately runnable when
299
+ `status: 'live'`. `status: 'draft'` is a save-without-arming
300
+ flag — drafts can be re-saved via `update()` but won't fire on
301
+ dataset events.
302
+
303
+ ### Read shapes
304
+
305
+ ```
306
+ .list(tenantSlug, datalakeSlug) Paged: { data, meta }
307
+ .get(tenantSlug, datalakeSlug, idOrSlug) One row
308
+ .metadata(tenantSlug, datalakeSlug, idOrSlug) Agent-facing metadata
309
+ ```
310
+
311
+ ### Run
312
+
313
+ `.run` evaluates the workflow against a SQL-selected row set:
314
+
315
+ ```typescript
316
+ const { data: run } = await api.workflows.run(
317
+ tenantSlug, workflowSlug,
318
+ {
319
+ sql_where_clause: `ra.id = '${appointmentId}'`, // run against the regulated tables
320
+ mode: 'live', // 'live' | 'dry_run'
321
+ manual_override: false, // see below
322
+ },
323
+ )
324
+ // run.batch_id — tagged string (e.g. "manual:<uuid>");
325
+ // the prefix distinguishes manual runs from
326
+ // cron-driven ones
327
+ // run.workflow_run_log_id — UUID — the batch log row (poll via batchLogs)
328
+ // run.enqueued_count — number of rows the WHERE clause matched
329
+ ```
330
+
331
+ `sql_where_clause` accepts a fragment against the dataset's
332
+ regulated tables (`ra` for appointments, `rp` for patients,
333
+ etc. — same alias scheme as `data_activation_clients.md` §6.5).
334
+ The id column is ambiguous across joins; always prefix
335
+ (`ra.id`, `rp.id`).
336
+
337
+ `manual_override: true` **skips BOTH the filter expression AND
338
+ the idempotency check** — the workflow fires regardless of
339
+ filter outcome and regardless of whether the idempotency tuple
340
+ already fired for this row. Useful for forced re-runs in test
341
+ contexts; avoid in production.
342
+
343
+ ### Execute (per-row, per-action)
344
+
345
+ `.execute` skips filter + decision evaluation entirely and
346
+ fires one specific action against one specific dataset row:
347
+
348
+ ```typescript
349
+ const { data: result } = await api.workflows.execute(
350
+ tenantSlug, workflowSlug,
351
+ {
352
+ dataset_id: unregulatedAppointmentId, // UNREGULATED id (not regulated)
353
+ decision_key: 'send_appointment_review_sms',
354
+ mode: 'live', // 'live' | 'dry_run' (same enum as .run)
355
+ manual_override: true, // load-bearing on re-fires
356
+ },
357
+ )
358
+ // result.status — 'pending' | 'completed' | 'filtered' | 'failed'
359
+ // result.scheduled_count — number of action jobs scheduled
360
+ // result.workflow_execution_log_id — UUID of the execution-log row
361
+ ```
362
+
363
+ Note that `dataset_id` is the **unregulated** id, not the
364
+ regulated one. The unregulated tier is what the workflow
365
+ runtime reads its action context from; passing the regulated
366
+ id will fail to resolve. Issue a `dataAccessMode: 'unregulated'`
367
+ search to obtain the right id.
368
+
369
+ `manual_override: true` on `.execute` sets the unique-key
370
+ constraint to `false` on the per-row job, allowing re-fires
371
+ on rows that have already been processed (otherwise the new
372
+ log row sits in `:pending` forever, deduplicated by the
373
+ sampled-event uniqueness check).
374
+
375
+ ### Update
376
+
377
+ `update()` replaces the whole resource — resupply the full
378
+ body; there's no partial update. The whole-array replace applies
379
+ to all inline arrays (see §2).
380
+
381
+ ### Delete
382
+
383
+ `delete()` removes the workflow. In-flight runs decouple their
384
+ lifecycle from the workflow row; deleting one mid-run rejects
385
+ with a 422.
386
+
387
+ ## 6. Run-time logs
388
+
389
+ ### Batch logs — `api.workflows.batchLogs`
390
+
391
+ One row per workflow run (per `.run` call). Lifecycle:
392
+
393
+ ```
394
+ :pending → :completed | :failed | :partial
395
+ ```
396
+
397
+ - `:completed` — every row processed terminally
398
+ - `:failed` — every row's processing errored
399
+ - `:partial` — mixed (some rows succeeded, some filtered, some failed)
400
+
401
+ Methods:
402
+
403
+ ```
404
+ .list(tenantSlug, workflowSlug) Paged
405
+ .get(tenantSlug, workflowSlug, runLogId) One row
406
+ .refresh(tenantSlug, workflowSlug, runLogId) Force a status recompute
407
+ (per-row jobs are async; status
408
+ only flips after explicit refresh)
409
+ .start(tenantSlug, workflowSlug, runLogId) Begin polling for this batch
410
+ .stop(tenantSlug, workflowSlug, runLogId) Stop polling
411
+ ```
412
+
413
+ The `.refresh` verb is **load-bearing** for synchronous
414
+ progress checks — `.get` returns the cached status, but the
415
+ underlying per-row jobs may have completed since the last
416
+ refresh. Call `.refresh` in your poll loop.
417
+
418
+ ### Workflow execution logs — `api.workflows.workflowLogs`
419
+
420
+ One row per dataset row evaluated. Status enum:
421
+
422
+ ```
423
+ :pending → :executing → :completed | :filtered | :failed
424
+ ```
425
+
426
+ Each log carries:
427
+
428
+ - `id`, `workflow_id`, `batch_id` — join keys
429
+ - `status` — terminal state
430
+ - `actions_total` — number of actions wired on the workflow
431
+ - `actions_pending` — actions scheduled but not yet completed by
432
+ their background jobs
433
+ - `actions_completed` — actions whose background job has finished
434
+ - `actions_skipped` — siblings of the matched action that didn't
435
+ fire (the decision template returned one key; other actions
436
+ with non-matching `decision_key` get :skipped)
437
+ - `error_message` — coarse failure summary (e.g.
438
+ `"AI enrichment failed: <agent_name>"`); rich detail lives
439
+ in `error.json` (§ Per-step artifacts below)
440
+ - `action_execution_logs` — nested rows, one per action that fired
441
+ (`message_body` virtual field is rendered when read via the
442
+ `dataAccessMode: 'regulated'` mode)
443
+
444
+ Methods:
445
+
446
+ ```
447
+ .list(tenantSlug, workflowSlug) Paged
448
+ .get(tenantSlug, workflowSlug, logId, { dataAccessMode? })
449
+ .download(tenantSlug, workflowSlug, logId) Downloads the run's artifact
450
+ ```
451
+
452
+ The `dataAccessMode` query parameter on `.get` controls how
453
+ `message_body` is rendered:
454
+ - `'regulated'` — raw rendered body (with the resolved short
455
+ path `/t/<token>`)
456
+ - `'unregulated'` — tokenised display body
457
+
458
+ ### Per-step artifacts (event, filter, enrichment, error)
459
+
460
+ For each workflow execution, the platform writes JSON artifacts
461
+ to the datalake's regulated cloud-storage bucket. The keys
462
+ follow a fixed convention:
463
+
464
+ ```
465
+ workflows/<workflow_id>/executions/<execution_log_id>/<step>.json
466
+ ```
467
+
468
+ Where `<step>` is one of: `event` | `filter` | `enrichment` |
469
+ `error`.
470
+
471
+ Fetch them via the datalake's `createDownloadLink`:
472
+
473
+ ```typescript
474
+ const { data: link } = await api.datalakes.createDownloadLink(
475
+ tenantSlug, datalakeSlug,
476
+ { bucket: regulatedBucket, key: 'workflows/.../filter.json' },
477
+ )
478
+ const artifact = await (await fetch(link.url)).json()
479
+ ```
480
+
481
+ Artifact bodies:
482
+
483
+ ```
484
+ event.json { event_dataset: { ... full inbound row snapshot ... } }
485
+ filter.json { filter_expression, filter_result: boolean }
486
+ enrichment.json Success-with-agents:
487
+ {
488
+ status: 'completed',
489
+ <agent_slug_1>: { status: 'completed',
490
+ output: { ...matches llm_response_schema... } },
491
+ <agent_slug_2>: { ... },
492
+ }
493
+ Skipped (no ai_agents on the workflow):
494
+ { status: 'skipped' }
495
+ error.json Only present on :failed runs:
496
+ {
497
+ error_code: string,
498
+ stage: string, // 'enrichment' | 'decision' | 'action'
499
+ ai_agent_slug?: string, // present for enrichment failures
500
+ error_message: string, // coarse summary (mirrors WEL.error_message)
501
+ detail: string, // rich provider context (URL, HTTP status, ...)
502
+ // ONLY in error.json, NOT mirrored to the WEL row
503
+ }
504
+ ```
505
+
506
+ The PASS / REJECT branches of the filter both emit
507
+ `filter.json` — the body just differs in `filter_result`.
508
+
509
+ ### `error.json` error codes
510
+
511
+ When `stage: 'enrichment'`, the agent-related codes are
512
+ documented in `ai_agents.md` §7. The full set of `error_code`
513
+ values across stages:
514
+
515
+ ```
516
+ tool_execution_failed enrichment LLM tool's HTTP call failed
517
+ json_decode_failed enrichment provider returned non-JSON
518
+ context_mapping_failed enrichment mapping output didn't match agent's input_schema
519
+ ai_agent_disabled enrichment agent's enabled: false
520
+ ai_agent_not_found enrichment agent was deleted but workflow still references it
521
+ decision_template_failed decision Liquid render or schema validation failed
522
+ action_template_failed action action body Liquid render failed
523
+ ```
524
+
525
+ The `detail` field carries rich context (URL, HTTP status,
526
+ transport error) that lives ONLY in `error.json`. The
527
+ workflow execution log row's `error_message` is intentionally
528
+ coarse so the database column doesn't grow with arbitrary
529
+ provider responses. Always read `error.json` for diagnostics.
530
+
531
+ ## 7. Connected apps as action sinks
532
+
533
+ Workflows that send messages (SMS, email) typically link to a
534
+ **connected app** — a registered consumer of the action's
535
+ rendered URL. See `api.connectedApps`:
536
+
537
+ ```typescript
538
+ await api.connectedApps.create(tenantSlug, datalakeSlug, {
539
+ name: 'Review form',
540
+ description: '...',
541
+ mode: 'self_hosted', // 'self_hosted' | 'managed'
542
+ urls: [{ url: 'https://app.example.com', is_primary: true, label: 'production' }],
543
+ })
544
+ ```
545
+
546
+ Action wiring on the workflow:
547
+ - `connected_app_id` — UUID of the bound connected app
548
+ - `connected_app_route` — path on the app (e.g. `/forms/review`)
549
+ - `connected_app_metadata_template` — Liquid template rendering
550
+ a JSON object that the recipient can introspect after
551
+ resolving the token
552
+
553
+ At fire time, the action's message body's `{{ connected_app_form_url }}`
554
+ expands to `https://<app-primary>/t/<short_path>`. The recipient
555
+ hits that URL; the connected app calls
556
+ `api.connectedApps.resolvePage` with the `short_path` to fetch
557
+ the metadata, then `api.connectedApps.updateMessageTracking` to
558
+ record `opened_at` / `form_submitted_at`.
559
+
560
+ ## 8. Gotchas
561
+
562
+ 1. **`filter_config` follows the same inverted-render rule as
563
+ interoperability contracts: non-empty PASSES, empty SKIPS.**
564
+ Same wording, same trap. See
565
+ `interoperability_contracts.md` §2 for the worked example.
566
+
567
+ 2. **`update()` silently drops omitted arrays.** Workflow
568
+ updates must re-supply `actions` and `context_datasets`
569
+ verbatim — otherwise the existing rows are deleted. Factor the
570
+ body construction so create and update share one builder.
571
+ (Nested `workflow_ai_agents` follow the same whole-array-replace
572
+ rule — re-supply the full set on every `update()`; see §3.)
573
+
574
+ 3. **`sql_where_clause` ids require an alias prefix.** `id` is
575
+ ambiguous across the multi-JOIN base query; use
576
+ `<base_alias>.id` (`ra.id`, `rp.id`, `rc.id`, etc.).
577
+
578
+ 4. **`.execute` takes the UNREGULATED dataset id, `.run` takes
579
+ the REGULATED id.** This asymmetry traps consumers who
580
+ reuse the id they got from a regulated-mode search. Issue
581
+ a fresh search with `dataAccessMode: 'unregulated'` before
582
+ calling `.execute`.
583
+
584
+ 5. **`manual_override: true` on `.execute` is required for
585
+ re-fires.** Without it, the per-row job dedupes against the
586
+ sampled-event uniqueness check and the new log row sits in
587
+ `:pending` indefinitely. The override flips the job's
588
+ unique-constraint behavior so the re-fire is allowed.
589
+
590
+ 6. **`batchLogs.get` returns cached status; call `.refresh`.**
591
+ Per-row jobs are async; `.get` does not recompute. Poll
592
+ `.refresh(runLogId)` in your wait loop.
593
+
594
+ 7. **`trigger_template: 'now'` (literal) is different from
595
+ `'{{ "" | now | date: ... }}'`.** The literal sentinel
596
+ routes to the next available scheduling slot directly;
597
+ the rendered timestamp gets clamped against the action
598
+ window + queue throttle and pushed forward unpredictably.
599
+
600
+ 8. **Enrichment runs for every workflow, even without agents.**
601
+ For standard (non-agent) workflows, `enrichment.json` lands
602
+ with `{ status: 'skipped' }`. Don't treat the artifact's
603
+ absence as the "no AI" signal — read its body.
604
+
605
+ 9. **Use `| to_json`, NEVER `| json`, in Liquid bodies.** When
606
+ serializing nested values into a JSON payload (most commonly
607
+ in an agent's `context_mapping_config`, but also any
608
+ TemplateConfig body that must produce valid JSON), the
609
+ correct filter is `| to_json`. `| json` is NOT in the
610
+ platform's allowlist of custom filters (see `ai_sandbox.md`
611
+ §2 for the closed eleven-filter set). Using it produces
612
+ structurally-invalid JSON output silently — the downstream
613
+ JSON parse step then rejects the rendered string with a
614
+ generic decode error far from the Liquid template's location,
615
+ making it expensive to diagnose. Always write
616
+ `{{ event_dataset | to_json }}`, not
617
+ `{{ event_dataset | json }}`.