@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.
- package/.agent/ai_agents.md +1 -0
- package/.agent/cookbook/_setup/foundation.md +62 -0
- package/.agent/cookbook/_setup/healthcare.md +62 -0
- package/.agent/cookbook/_setup/payments.md +62 -0
- package/.agent/cookbook/_setup/subscription.md +62 -0
- package/.agent/cookbook/appointment-review-sms-workflow.md +16 -8
- package/.agent/cookbook/birthday-greeting-sms-trigger.md +11 -5
- package/.agent/cookbook/contact-us-triage-with-llm.md +11 -5
- package/.agent/cookbook/dunning-sms-for-delinquent.md +16 -8
- package/.agent/cookbook/kyc-notification-on-account-activation.md +16 -8
- package/.agent/cookbook/marketing-campaign-send.md +13 -7
- package/.agent/cookbook/sanctions-screening-with-agent-review.md +11 -5
- package/.agent/cookbook/score-leads-with-llm-categorization.md +11 -5
- package/.agent/cookbook/triage-prospects-by-priority.md +11 -5
- package/.agent/cookbook/welcome-sms-for-customers.md +16 -8
- package/.agent/errors.md +7 -1
- package/.agent/interoperability_contracts.md +58 -0
- package/.agent/tools.md +98 -4
- package/.agent/workflows.md +137 -38
- package/dist/index.d.mts +966 -153
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +133 -5
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
|
@@ -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
|
-
|
|
518
|
-
|
|
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
|
|
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
|
-
|
|
670
|
-
|
|
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
|
-
|
|
490
|
-
|
|
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
|
|
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
|
-
|
|
577
|
-
|
|
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
|
-
|
|
426
|
-
|
|
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
|
|
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
|
-
|
|
507
|
-
|
|
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
|
|
386
|
-
terminal status on its own — poll `batchLogs.refresh`
|
|
387
|
-
leaves `:pending`.
|
|
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
|
-
|
|
397
|
-
|
|
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
|
|
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
|
-
|
|
547
|
-
|
|
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.
|
|
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
|
package/.agent/workflows.md
CHANGED
|
@@ -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
|
-
###
|
|
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
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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
|
-
|
|
490
|
-
|
|
491
|
-
|
|
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.
|
|
505
|
-
//
|
|
506
|
-
//
|
|
507
|
-
//
|
|
508
|
-
// run.
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
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
|
-
|
|
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 | `
|
|
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
|
|