@alvera-ai/platform-sdk 0.17.0 → 0.18.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/AGENTS.md +82 -144
- package/.agent/account_management.md +2 -2
- package/.agent/action_logs.md +4 -4
- package/.agent/ai_agents.md +28 -21
- package/.agent/ai_sandbox.md +49 -39
- package/.agent/connected_apps.md +3 -3
- package/.agent/cookbook/_fixtures/README.md +1 -1
- package/.agent/cookbook/_fixtures/{foundation → organic-marketing}/_lead_submissions_foundation_generic_table.liquid +1 -1
- package/.agent/cookbook/_fixtures/organic-marketing/_lead_submissions_foundation_legal_entity.liquid +80 -0
- package/.agent/cookbook/_fixtures/{foundation → organic-marketing}/_lead_submissions_foundation_mdm.liquid +2 -1
- package/.agent/cookbook/_fixtures/payments-compliance/_compliance_screenings_generic_table.liquid +57 -0
- package/.agent/cookbook/_fixtures/payments-compliance/_compliance_screenings_legal_entity.liquid +30 -0
- package/.agent/cookbook/_fixtures/payments-compliance/_compliance_screenings_mdm.liquid +44 -0
- package/.agent/cookbook/_fixtures/payments-compliance/_payment_accounts_generic_table.liquid +57 -0
- package/.agent/cookbook/_fixtures/payments-compliance/_payment_accounts_legal_entity.liquid +36 -0
- package/.agent/cookbook/_fixtures/payments-compliance/_payment_accounts_mdm.liquid +41 -0
- package/.agent/cookbook/_fixtures/primary-care-feedback/_cahps_appointments_generic_table.liquid +70 -0
- package/.agent/cookbook/_fixtures/primary-care-feedback/_cahps_appointments_legal_entity.liquid +52 -0
- package/.agent/cookbook/_fixtures/primary-care-feedback/_cahps_appointments_mdm.liquid +42 -0
- package/.agent/cookbook/_fixtures/subscription-saas/_customers_subscription_generic_table.liquid +38 -0
- package/.agent/cookbook/_fixtures/subscription-saas/_customers_subscription_legal_entity.liquid +48 -0
- package/.agent/cookbook/_fixtures/subscription-saas/_customers_subscription_mdm.liquid +49 -0
- package/.agent/cookbook/organic-marketing.md +2801 -0
- package/.agent/cookbook/payments-compliance.md +2180 -0
- package/.agent/cookbook/primary-care.md +2175 -0
- package/.agent/cookbook/subscription-saas.md +2403 -0
- package/.agent/data_activation_clients.md +65 -52
- package/.agent/datalakes.md +338 -171
- package/.agent/errors.md +3 -3
- package/.agent/generic_tables.md +151 -62
- package/.agent/interoperability_contracts.md +57 -22
- package/.agent/mdm.md +136 -153
- package/.agent/messages.md +36 -34
- package/.agent/mock-services.md +1 -1
- package/.agent/mutations.md +2 -2
- package/.agent/templates.md +14 -13
- package/.agent/tools.md +63 -21
- package/.agent/type_naming.md +13 -13
- package/.agent/workflows.md +99 -53
- package/README.md +2 -2
- package/dist/bin/platform-sdk.mjs +33 -47
- package/dist/bin/platform-sdk.mjs.map +1 -1
- package/dist/index.d.mts +565 -379
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +494 -59
- package/dist/index.mjs.map +1 -1
- package/package.json +4 -3
- package/.agent/cookbook/_fixtures/foundation/_lead_submissions_foundation_legal_entity.liquid +0 -88
- package/.agent/cookbook/_fixtures/healthcare/_cahps_appointments_healthcare_appointment.liquid +0 -47
- package/.agent/cookbook/_fixtures/healthcare/_cahps_appointments_healthcare_mdm.liquid +0 -24
- package/.agent/cookbook/_fixtures/healthcare/_cahps_appointments_healthcare_patient.liquid +0 -38
- package/.agent/cookbook/_fixtures/payments/_compliance_screenings_payments_compliance_screening.liquid +0 -59
- package/.agent/cookbook/_fixtures/payments/_compliance_screenings_payments_mdm.liquid +0 -36
- package/.agent/cookbook/_fixtures/payments/_payment_accounts_payments_mdm.liquid +0 -30
- package/.agent/cookbook/_fixtures/payments/_payment_accounts_payments_payment_account.liquid +0 -55
- package/.agent/cookbook/_fixtures/subscription/_customers_subscription_mdm.liquid +0 -20
- package/.agent/cookbook/_setup/foundation.md +0 -359
- package/.agent/cookbook/_setup/healthcare.md +0 -361
- package/.agent/cookbook/_setup/payments.md +0 -365
- package/.agent/cookbook/_setup/subscription.md +0 -364
- package/.agent/cookbook/action-status-updaters.md +0 -278
- package/.agent/cookbook/ai-agent-invoke.md +0 -279
- package/.agent/cookbook/appointment-review-sms-workflow.md +0 -801
- package/.agent/cookbook/birthday-greeting-sms-trigger.md +0 -696
- package/.agent/cookbook/bulk-ingest.md +0 -302
- package/.agent/cookbook/contact-us-triage-with-llm.md +0 -663
- package/.agent/cookbook/dunning-sms-for-delinquent.md +0 -659
- package/.agent/cookbook/generic-tables.md +0 -244
- package/.agent/cookbook/invite-team.md +0 -200
- package/.agent/cookbook/kyc-notification-on-account-activation.md +0 -661
- package/.agent/cookbook/marketing-campaign-send.md +0 -1044
- package/.agent/cookbook/paginated-restapi-poller.md +0 -383
- package/.agent/cookbook/rest-fetch.md +0 -273
- package/.agent/cookbook/sanctions-screening-with-agent-review.md +0 -773
- package/.agent/cookbook/score-leads-with-llm-categorization.md +0 -665
- package/.agent/cookbook/system-templates.md +0 -165
- package/.agent/cookbook/talk-to-data.md +0 -178
- package/.agent/cookbook/triage-prospects-by-priority.md +0 -571
- package/.agent/cookbook/welcome-sms-for-customers.md +0 -647
- /package/.agent/cookbook/_fixtures/{healthcare → primary-care-feedback}/memorandum-of-association-01.png +0 -0
- /package/.agent/cookbook/_fixtures/{healthcare → primary-care-feedback}/sample_two_page.pdf +0 -0
- /package/.agent/cookbook/_fixtures/{subscription → subscription-saas}/_customers_subscription_customer.liquid +0 -0
- /package/.agent/cookbook/_fixtures/{subscription → subscription-saas}/stripe_customers_batch1.csv +0 -0
|
@@ -1,663 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
title: Triage inbound contact-us messages into three priority buckets via an LLM agent
|
|
3
|
-
summary: End-to-end agent-driven workflow — a chat-completion LLM agent classifies each contact-us submission into appointment_request / job_inquiry / flag_spam, the workflow's decision interpolates the agent's category, three SMS actions (one per bucket) fan out, and the run is verified row-by-row so every WEL carries exactly one matched action and two skipped.
|
|
4
|
-
industry: healthcare
|
|
5
|
-
slug: contact-us-triage-with-llm
|
|
6
|
-
vitest_source:
|
|
7
|
-
- integration-tests/tests/healthcare/agent-driven-workflow.test.ts
|
|
8
|
-
- integration-tests/tests/healthcare/generic-tables.test.ts
|
|
9
|
-
- integration-tests/tests/healthcare/tools.test.ts
|
|
10
|
-
- integration-tests/tests/healthcare/bootstrap.test.ts
|
|
11
|
-
status: green
|
|
12
|
-
---
|
|
13
|
-
|
|
14
|
-
# Problem
|
|
15
|
-
|
|
16
|
-
A clinic's "Contact Us" form is a firehose of mixed intent. One
|
|
17
|
-
message asks to book an appointment, the next is a nurse applying
|
|
18
|
-
for a job, the third is promotional spam. Routing all three to the
|
|
19
|
-
same inbox means the appointment request waits behind the spam.
|
|
20
|
-
|
|
21
|
-
A keyword filter handles the obvious cases and misses the rest —
|
|
22
|
-
"I'd love to come in and see Dr. Johnson" has no booking keyword,
|
|
23
|
-
"openings" appears in both a job inquiry and a spam blast. The
|
|
24
|
-
classification is a language task, not a rules task.
|
|
25
|
-
|
|
26
|
-
The Alvera platform's agent-driven workflow primitive turns the
|
|
27
|
-
decision step into an LLM enrichment plus a Liquid interpolation.
|
|
28
|
-
An AI agent receives the submission's free text via a
|
|
29
|
-
context-mapping template, returns a JSON object whose `category`
|
|
30
|
-
field is one of three enum values, and the workflow's
|
|
31
|
-
decision_config interpolates that category into a one-element
|
|
32
|
-
decision array (`["{{ additional_context["agent-slug"].category }}"]`).
|
|
33
|
-
Three SMS actions are registered against the workflow, one per
|
|
34
|
-
bucket; the agent's output picks which one runs.
|
|
35
|
-
|
|
36
|
-
This cookbook walks the **whole** scenario: it provisions the
|
|
37
|
-
generic table, agent, and workflow, ingests three contact-us
|
|
38
|
-
submissions through the generic-table's auto-provisioned default
|
|
39
|
-
DAC, runs the workflow so the agent classifies each row, and
|
|
40
|
-
verifies the routing row-by-row — every Workflow Execution Log
|
|
41
|
-
carries exactly one matched action and two skipped, proving the
|
|
42
|
-
agent's category actually steered the fan-out.
|
|
43
|
-
|
|
44
|
-
The scenario is anchored to
|
|
45
|
-
`platform/integration-tests/tests/healthcare/agent-driven-workflow.test.ts`
|
|
46
|
-
— a green end-to-end test whose positive branch (§1, §3, §4, §6,
|
|
47
|
-
§7) is exactly this shape. The setup file `_setup/healthcare.md`
|
|
48
|
-
already provisioned the tenant + datalake + tenant-scoped client;
|
|
49
|
-
this cookbook starts from there.
|
|
50
|
-
|
|
51
|
-
# Composition
|
|
52
|
-
|
|
53
|
-
| Resource provisioned | Owner |
|
|
54
|
-
|-------------------------------------|-------------|
|
|
55
|
-
| Contact Us submissions dataset | build |
|
|
56
|
-
| SMS tool (SNS-backed) | build |
|
|
57
|
-
| LLM tool (Ollama chat completion) | build |
|
|
58
|
-
| Contact Us Triage AI agent | build |
|
|
59
|
-
| Contact Us Triage workflow | build |
|
|
60
|
-
|
|
61
|
-
The generic table's **default DAC** — auto-provisioned by the
|
|
62
|
-
platform when the generic table is created — is reused for
|
|
63
|
-
ingestion; no separate data source / tool / interop contract / DAC
|
|
64
|
-
is created. The setup file `_setup/healthcare.md` already
|
|
65
|
-
provisioned the tenant + datalake + tenant-scoped client; this
|
|
66
|
-
cookbook starts from there.
|
|
67
|
-
|
|
68
|
-
# Walkthrough
|
|
69
|
-
|
|
70
|
-
## 001 — create the Contact Us submissions generic table
|
|
71
|
-
|
|
72
|
-
The agent-driven workflow targets a Generic Table dataset
|
|
73
|
-
(`dataset_type: 'generic_table'`) rather than a regulated domain
|
|
74
|
-
entity, because contact-us form rows do not fit a canonical FHIR
|
|
75
|
-
entity. The table's columns mirror the shape a website form export
|
|
76
|
-
would produce; the `message` column is what the agent reads, and
|
|
77
|
-
`category` is what the agent eventually writes back in production.
|
|
78
|
-
The server-derived `name` is captured — the workflow run in §009
|
|
79
|
-
addresses the regulated table as `regulated_<name>`.
|
|
80
|
-
|
|
81
|
-
```typescript
|
|
82
|
-
const genericTableResp = await api.genericTables.create(tenantSlug, datalakeSlug, {
|
|
83
|
-
title: `Cookbook Contact Us ${runSuffix}`,
|
|
84
|
-
description: 'Inbound contact-us form submissions the Triage agent classifies.',
|
|
85
|
-
columns: [
|
|
86
|
-
{
|
|
87
|
-
name: 'submission_id',
|
|
88
|
-
title: 'Submission ID',
|
|
89
|
-
type: 'string',
|
|
90
|
-
description: 'Vendor-supplied unique submission id',
|
|
91
|
-
is_unique: true,
|
|
92
|
-
privacy_requirement: 'none',
|
|
93
|
-
},
|
|
94
|
-
{
|
|
95
|
-
name: 'name',
|
|
96
|
-
title: 'Name',
|
|
97
|
-
type: 'string',
|
|
98
|
-
description: 'Submitter full name',
|
|
99
|
-
is_unique: false,
|
|
100
|
-
privacy_requirement: 'tokenize',
|
|
101
|
-
},
|
|
102
|
-
{
|
|
103
|
-
name: 'email',
|
|
104
|
-
title: 'Email',
|
|
105
|
-
type: 'string',
|
|
106
|
-
description: 'Submitter email',
|
|
107
|
-
is_unique: false,
|
|
108
|
-
privacy_requirement: 'tokenize',
|
|
109
|
-
},
|
|
110
|
-
{
|
|
111
|
-
name: 'message',
|
|
112
|
-
title: 'Message',
|
|
113
|
-
type: 'string',
|
|
114
|
-
description: 'Free-text contact-us message — the agent classifies this',
|
|
115
|
-
is_unique: false,
|
|
116
|
-
privacy_requirement: 'none',
|
|
117
|
-
},
|
|
118
|
-
{
|
|
119
|
-
name: 'category',
|
|
120
|
-
title: 'Category',
|
|
121
|
-
type: 'string',
|
|
122
|
-
description: 'Triage bucket assigned by the Contact Us Triage agent',
|
|
123
|
-
is_unique: false,
|
|
124
|
-
privacy_requirement: 'none',
|
|
125
|
-
},
|
|
126
|
-
],
|
|
127
|
-
})
|
|
128
|
-
genericTableId = genericTableResp.data.id!
|
|
129
|
-
ctx.gtName = genericTableResp.data.name!
|
|
130
|
-
```
|
|
131
|
-
|
|
132
|
-
## 002 — create the SMS tool
|
|
133
|
-
|
|
134
|
-
A single SMS tool dispatches every bucket's outbound message; the
|
|
135
|
-
per-bucket SMS body lives on each workflow action, not on the tool.
|
|
136
|
-
SNS-backed LocalStack wiring — `intent: 'sms'` tags it for
|
|
137
|
-
workflow-action use.
|
|
138
|
-
|
|
139
|
-
```typescript
|
|
140
|
-
const smsToolResp = await api.tools.create(tenantSlug, datalakeSlug, {
|
|
141
|
-
name: `Cookbook SMS Tool ${runSuffix}`,
|
|
142
|
-
description: 'SNS-backed SMS dispatcher for the Contact Us Triage workflow, wired to LocalStack.',
|
|
143
|
-
intent: 'sms',
|
|
144
|
-
status: 'active',
|
|
145
|
-
datalake_id: ctx.datalakeId,
|
|
146
|
-
body: {
|
|
147
|
-
tool_body_type: 'sns',
|
|
148
|
-
auth_method: 'access_key',
|
|
149
|
-
region: 'us-east-1',
|
|
150
|
-
phone_number: '+15551234567',
|
|
151
|
-
endpoint_url: 'http://localhost:4566',
|
|
152
|
-
access_key_id: 'test',
|
|
153
|
-
secret_access_key: 'test',
|
|
154
|
-
},
|
|
155
|
-
})
|
|
156
|
-
toolId = smsToolResp.data.id!
|
|
157
|
-
```
|
|
158
|
-
|
|
159
|
-
## 003 — create the LLM tool
|
|
160
|
-
|
|
161
|
-
The Triage agent calls a chat-completion endpoint to classify each
|
|
162
|
-
row. The tool's `intent: 'llm_enrichment'` distinguishes it from
|
|
163
|
-
the SMS tool above. It is a **provider adapter**: `base_body` authors
|
|
164
|
-
the provider's request — here Ollama's native `/api/chat` shape with
|
|
165
|
-
`think: false` and a `format` schema so the model returns clean,
|
|
166
|
-
schema-constrained JSON — and `response_extractor` maps the provider's
|
|
167
|
-
envelope back to the canonical `{ output_json, … }` the platform reads.
|
|
168
|
-
The extractor's `output_schema` is required for an `llm_enrichment`
|
|
169
|
-
tool. The `api_key`/`auth_method` pair satisfies the REST tool's schema
|
|
170
|
-
even though Ollama ignores the header.
|
|
171
|
-
|
|
172
|
-
```typescript
|
|
173
|
-
const ENRICHMENT_OUTPUT_SCHEMA = {
|
|
174
|
-
type: 'object',
|
|
175
|
-
properties: {
|
|
176
|
-
output_json: {},
|
|
177
|
-
input_tokens: { type: ['integer', 'null'] },
|
|
178
|
-
output_tokens: { type: ['integer', 'null'] },
|
|
179
|
-
total_tokens: { type: ['integer', 'null'] },
|
|
180
|
-
explanation: { type: ['string', 'null'] },
|
|
181
|
-
},
|
|
182
|
-
required: ['output_json'],
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
const OLLAMA_BASE_BODY =
|
|
186
|
-
'{"model": "{{ model }}", "messages": [{"role": "user", "content": "{{ rendered_prompt | json_escape }}", "images": [{% for img in images %}{% unless forloop.first %}, {% endunless %}"{{ img.data }}"{% endfor %}]}], "stream": false, "think": false, "options": {"temperature": 0, "num_ctx": 40960}, "format": {{ schema | to_json }}}'
|
|
187
|
-
|
|
188
|
-
const OLLAMA_EXTRACTOR =
|
|
189
|
-
'{"output_json": "{{ msg.message.content | json_escape }}", "explanation": "{{ msg.message.thinking | json_escape }}", "input_tokens": {{ msg.prompt_eval_count | default: 0 }}, "output_tokens": {{ msg.eval_count | default: 0 }}, "total_tokens": {{ msg.prompt_eval_count | default: 0 | plus: msg.eval_count }}}'
|
|
190
|
-
|
|
191
|
-
const llmToolResp = await api.tools.create(tenantSlug, datalakeSlug, {
|
|
192
|
-
name: `Cookbook LLM Tool ${runSuffix}`,
|
|
193
|
-
description: 'Ollama-backed chat-completion adapter for Contact Us triage classification.',
|
|
194
|
-
intent: 'llm_enrichment',
|
|
195
|
-
status: 'active',
|
|
196
|
-
datalake_id: ctx.datalakeId,
|
|
197
|
-
response_extractor: { type: 'custom', body: OLLAMA_EXTRACTOR, output_schema: ENRICHMENT_OUTPUT_SCHEMA },
|
|
198
|
-
body: {
|
|
199
|
-
tool_body_type: 'rest_api',
|
|
200
|
-
base_url: 'http://localhost:11434',
|
|
201
|
-
base_path: { type: 'custom', body: '/api/chat' },
|
|
202
|
-
auth_method: 'api_key',
|
|
203
|
-
api_key: 'stub-key',
|
|
204
|
-
api_key_name: 'Authorization',
|
|
205
|
-
api_key_location: 'header',
|
|
206
|
-
request_type: 'json',
|
|
207
|
-
response_type: 'json',
|
|
208
|
-
timeout_ms: 60_000,
|
|
209
|
-
base_body: { type: 'custom', body: OLLAMA_BASE_BODY },
|
|
210
|
-
},
|
|
211
|
-
})
|
|
212
|
-
ctx.llmToolId = llmToolResp.data.id!
|
|
213
|
-
```
|
|
214
|
-
|
|
215
|
-
## 004 — create the Contact Us Triage AI agent
|
|
216
|
-
|
|
217
|
-
The agent binds three things: the model name (sent on every
|
|
218
|
-
inference call), an input schema the workflow's context-mapping
|
|
219
|
-
must satisfy, and a response schema the agent's output must match.
|
|
220
|
-
The response schema's `enum: ['appointment_request','job_inquiry','flag_spam']`
|
|
221
|
-
constraint is what guards the downstream decision interpolation
|
|
222
|
-
from emitting a category the workflow has no SMS action for.
|
|
223
|
-
`temperature: 0.0` removes sampling noise so identical inputs
|
|
224
|
-
classify identically. `data_access: 'unregulated'` says the agent
|
|
225
|
-
only ever sees the tokenized projection of each row.
|
|
226
|
-
|
|
227
|
-
```typescript
|
|
228
|
-
const TRIAGE_INPUT_SCHEMA = {
|
|
229
|
-
type: 'object',
|
|
230
|
-
properties: {
|
|
231
|
-
msg: { type: 'string' },
|
|
232
|
-
submission_id: { type: 'string' },
|
|
233
|
-
},
|
|
234
|
-
required: ['msg', 'submission_id'],
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
const TRIAGE_RESPONSE_SCHEMA = {
|
|
238
|
-
type: 'object',
|
|
239
|
-
properties: {
|
|
240
|
-
category: {
|
|
241
|
-
type: 'string',
|
|
242
|
-
enum: ['appointment_request', 'job_inquiry', 'flag_spam'],
|
|
243
|
-
},
|
|
244
|
-
},
|
|
245
|
-
required: ['category'],
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
const AGENT_PROMPT_BODY = `You are a triage assistant. Categorize the following message into EXACTLY ONE of these categories:
|
|
249
|
-
|
|
250
|
-
- "appointment_request" — the user wants to book/reschedule a medical appointment
|
|
251
|
-
- "job_inquiry" — the user is asking about employment or job opportunities
|
|
252
|
-
- "flag_spam" — the message is promotional / spam / irrelevant
|
|
253
|
-
|
|
254
|
-
Submission ID: {{ submission_id }}
|
|
255
|
-
Message: {{ msg }}
|
|
256
|
-
|
|
257
|
-
Respond with a JSON object: {"category": "<one of the three categories above>"}`
|
|
258
|
-
|
|
259
|
-
const agentResp = await api.aiAgents.create(tenantSlug, datalakeSlug, {
|
|
260
|
-
name: `Cookbook Contact Us Triage Agent ${runSuffix}`,
|
|
261
|
-
tool_id: ctx.llmToolId,
|
|
262
|
-
model: 'qwen3-vl:8b-instruct',
|
|
263
|
-
data_access: 'unregulated',
|
|
264
|
-
temperature: 0.0,
|
|
265
|
-
max_tokens: 1024,
|
|
266
|
-
enabled: true,
|
|
267
|
-
input_schema: TRIAGE_INPUT_SCHEMA,
|
|
268
|
-
llm_response_schema: TRIAGE_RESPONSE_SCHEMA,
|
|
269
|
-
prompt_config: { type: 'custom', body: AGENT_PROMPT_BODY },
|
|
270
|
-
})
|
|
271
|
-
aiAgentId = agentResp.data.id!
|
|
272
|
-
ctx.agentSlug = agentResp.data.slug!
|
|
273
|
-
```
|
|
274
|
-
|
|
275
|
-
## 005 — create the Contact Us Triage workflow
|
|
276
|
-
|
|
277
|
-
The workflow has the standard shape — filter, decision, actions —
|
|
278
|
-
but two things distinguish it from a static workflow. First, the
|
|
279
|
-
Triage agent is **nested** in the workflow create body (the
|
|
280
|
-
`workflow_ai_agents` array — a `cast_assoc`, not a separate attach
|
|
281
|
-
call), binding it into the workflow's enrichment phase with a Liquid
|
|
282
|
-
`context_mapping_config` that projects each submission row's fields
|
|
283
|
-
into the agent's input schema. Second, the `decision_config` body is a Liquid template
|
|
284
|
-
that interpolates the agent's `category` output (read from
|
|
285
|
-
`additional_context["<agent-slug>"].category`) into a
|
|
286
|
-
single-element decision array. The three `actions` are keyed
|
|
287
|
-
`decision_key: appointment_request|job_inquiry|flag_spam`;
|
|
288
|
-
whichever category the agent emits picks the action that fires,
|
|
289
|
-
leaving the other two `:skipped`.
|
|
290
|
-
|
|
291
|
-
Bracket access (`additional_context["..."]`) is used because the
|
|
292
|
-
agent's slug contains hyphens, which a dotted Liquid lookup could
|
|
293
|
-
misread as subtraction operators.
|
|
294
|
-
|
|
295
|
-
```typescript
|
|
296
|
-
const BUCKETS = ['appointment_request', 'job_inquiry', 'flag_spam'] as const
|
|
297
|
-
|
|
298
|
-
const CONTEXT_MAPPING_BODY = JSON.stringify({
|
|
299
|
-
msg: '{{ event_dataset.message }}',
|
|
300
|
-
submission_id: '{{ event_dataset.submission_id }}',
|
|
301
|
-
})
|
|
302
|
-
|
|
303
|
-
const DECISION_CONFIG_BODY = `["{{ additional_context["${ctx.agentSlug}"].category }}"]`
|
|
304
|
-
const DECISION_OUTPUT_SCHEMA = { type: 'array', items: { type: 'string' } }
|
|
305
|
-
|
|
306
|
-
const workflowResp = await api.workflows.create(tenantSlug, datalakeSlug, {
|
|
307
|
-
name: `Cookbook Contact Us Triage Workflow ${runSuffix}`,
|
|
308
|
-
description: 'Triages contact-us submissions into appointment_request/job_inquiry/flag_spam via an LLM agent; one SMS action per bucket.',
|
|
309
|
-
dataset_type: 'generic_table',
|
|
310
|
-
generic_table_id: genericTableId,
|
|
311
|
-
skip_mdm_resolution: true,
|
|
312
|
-
status: 'live',
|
|
313
|
-
tags: ['support', 'triage'],
|
|
314
|
-
filter_config: {
|
|
315
|
-
type: 'custom',
|
|
316
|
-
body: 'true',
|
|
317
|
-
output_schema: { type: 'boolean' },
|
|
318
|
-
},
|
|
319
|
-
decision_config: {
|
|
320
|
-
type: 'custom',
|
|
321
|
-
body: DECISION_CONFIG_BODY,
|
|
322
|
-
output_schema: DECISION_OUTPUT_SCHEMA,
|
|
323
|
-
},
|
|
324
|
-
actions: BUCKETS.map((bucket) => ({
|
|
325
|
-
decision_key: bucket,
|
|
326
|
-
action_type: 'sms',
|
|
327
|
-
tool_id: toolId,
|
|
328
|
-
position: 0,
|
|
329
|
-
trigger_template: 'now',
|
|
330
|
-
idempotency_template: `{{ subject_id }}-{{ action_id }}-${bucket}`,
|
|
331
|
-
tool_call: {
|
|
332
|
-
tool_call_type: 'sms_request',
|
|
333
|
-
to: { type: 'custom', body: '+15551234567' },
|
|
334
|
-
body: {
|
|
335
|
-
type: 'custom',
|
|
336
|
-
body: `Triage [${bucket}]: {{ event_dataset.message }}`,
|
|
337
|
-
},
|
|
338
|
-
sms_type: 'transactional',
|
|
339
|
-
},
|
|
340
|
-
})),
|
|
341
|
-
// Nest the triage agent inline. Workflow joins omit output_schema —
|
|
342
|
-
// the server pins it from the agent's input_schema.
|
|
343
|
-
workflow_ai_agents: [
|
|
344
|
-
{
|
|
345
|
-
ai_agent_id: aiAgentId,
|
|
346
|
-
position: 0,
|
|
347
|
-
context_mapping_config: { type: 'custom', body: CONTEXT_MAPPING_BODY },
|
|
348
|
-
},
|
|
349
|
-
],
|
|
350
|
-
})
|
|
351
|
-
workflowId = workflowResp.data.id!
|
|
352
|
-
ctx.workflowSlug = workflowResp.data.slug!
|
|
353
|
-
```
|
|
354
|
-
|
|
355
|
-
## 006 — find the generic table's auto-provisioned default DAC
|
|
356
|
-
|
|
357
|
-
Creating a generic table provisions two things automatically: an
|
|
358
|
-
identity interoperability contract and a default Data Activation
|
|
359
|
-
Client bound to it (name pattern `<datalake> <gt_name>
|
|
360
|
-
DataActivationClient`, `tool_call: manual_upload`). No data source,
|
|
361
|
-
tool, interop contract, or DAC needs to be created by hand for
|
|
362
|
-
plain row ingestion into the table — the default DAC is found by
|
|
363
|
-
GT-name convention in the DAC listing.
|
|
364
|
-
|
|
365
|
-
```typescript
|
|
366
|
-
const deadline = Date.now() + 30_000
|
|
367
|
-
let found: { slug?: string | null; name?: string | null } | undefined
|
|
368
|
-
while (Date.now() < deadline && !found) {
|
|
369
|
-
const { data } = await api.dataActivationClients.list(tenantSlug, datalakeSlug)
|
|
370
|
-
found = (data.data ?? []).find((d) => (d.name ?? '').includes(ctx.gtName))
|
|
371
|
-
if (!found) await new Promise((r) => setTimeout(r, 1_000))
|
|
372
|
-
}
|
|
373
|
-
if (!found?.slug) {
|
|
374
|
-
throw new Error(`no default DAC found for GT ${ctx.gtName} within 30s`)
|
|
375
|
-
}
|
|
376
|
-
ctx.dacSlug = found.slug
|
|
377
|
-
```
|
|
378
|
-
|
|
379
|
-
## 007 — ingest three contact-us submissions through the default DAC
|
|
380
|
-
|
|
381
|
-
Three rows, ingested as inline JSON through the default DAC. Each
|
|
382
|
-
carries a deliberately distinct `message` — a clear appointment
|
|
383
|
-
request, an unmistakable job inquiry, and obvious promotional spam
|
|
384
|
-
— so the agent has something unambiguous to classify. The
|
|
385
|
-
`category` column is left unset on ingest; it is the agent's job
|
|
386
|
-
to assign one. Each ingest call returns its own batch id; all
|
|
387
|
-
three are pinned for the run scope in §009.
|
|
388
|
-
|
|
389
|
-
```typescript
|
|
390
|
-
const submissionRows = [
|
|
391
|
-
{
|
|
392
|
-
submission_id: `SUB-APPT-${runSuffix}`,
|
|
393
|
-
name: 'Maria Garcia',
|
|
394
|
-
email: `maria-${runSuffix}@example.com`,
|
|
395
|
-
message:
|
|
396
|
-
'I would like to schedule an appointment with Dr. Johnson next week if possible.',
|
|
397
|
-
},
|
|
398
|
-
{
|
|
399
|
-
submission_id: `SUB-JOB-${runSuffix}`,
|
|
400
|
-
name: 'James Wilson',
|
|
401
|
-
email: `james-${runSuffix}@example.com`,
|
|
402
|
-
message:
|
|
403
|
-
'I am a registered nurse looking for employment opportunities at your clinic.',
|
|
404
|
-
},
|
|
405
|
-
{
|
|
406
|
-
submission_id: `SUB-SPAM-${runSuffix}`,
|
|
407
|
-
name: 'BestDeals2026',
|
|
408
|
-
email: `promo-${runSuffix}@cheapmeds.xyz`,
|
|
409
|
-
message:
|
|
410
|
-
'HUGE DISCOUNT on cheap medications and miracle cures! CLICK HERE NOW for 90% off!',
|
|
411
|
-
},
|
|
412
|
-
]
|
|
413
|
-
|
|
414
|
-
const ingestResults = await Promise.all(
|
|
415
|
-
submissionRows.map((row) =>
|
|
416
|
-
api.dataActivationClients.ingest(tenantSlug, datalakeSlug, ctx.dacSlug, { data: row }),
|
|
417
|
-
),
|
|
418
|
-
)
|
|
419
|
-
ctx.submissionBatchIds = ingestResults.map((r) => r.data.batch_id!)
|
|
420
|
-
if (ctx.submissionBatchIds.length !== 3) {
|
|
421
|
-
throw new Error(`expected 3 ingest batch ids, got ${ctx.submissionBatchIds.length}`)
|
|
422
|
-
}
|
|
423
|
-
```
|
|
424
|
-
|
|
425
|
-
## 008 — wait for the three ingest batches to reach steady-state
|
|
426
|
-
|
|
427
|
-
Ingestion is async — the DAC enqueues per-row jobs that drain into
|
|
428
|
-
the generic table. Poll the DAC's activation logs until every
|
|
429
|
-
batch shows a row with `dataset_updated >= 1` (the submission row
|
|
430
|
-
was upserted into the table).
|
|
431
|
-
|
|
432
|
-
```typescript
|
|
433
|
-
const targetBatches = new Set<string>(ctx.submissionBatchIds)
|
|
434
|
-
const deadline = Date.now() + 90_000
|
|
435
|
-
let greenCount = 0
|
|
436
|
-
while (Date.now() < deadline) {
|
|
437
|
-
const { data } = await api.dataActivationClients.logs.list(tenantSlug, datalakeSlug, ctx.dacSlug)
|
|
438
|
-
const green = new Set<string>()
|
|
439
|
-
for (const row of (data.data ?? []) as Array<Record<string, unknown>>) {
|
|
440
|
-
const b = row.batch_id
|
|
441
|
-
if (typeof b !== 'string' || !targetBatches.has(b)) continue
|
|
442
|
-
if (typeof row.dataset_updated !== 'number' || row.dataset_updated < 1) continue
|
|
443
|
-
green.add(b)
|
|
444
|
-
}
|
|
445
|
-
greenCount = green.size
|
|
446
|
-
if (greenCount === targetBatches.size) break
|
|
447
|
-
await new Promise((r) => setTimeout(r, 1_000))
|
|
448
|
-
}
|
|
449
|
-
if (greenCount !== targetBatches.size) {
|
|
450
|
-
throw new Error(`only ${greenCount}/3 submission batches reached steady-state within 90s`)
|
|
451
|
-
}
|
|
452
|
-
```
|
|
453
|
-
|
|
454
|
-
## 009 — run the workflow against the three submissions
|
|
455
|
-
|
|
456
|
-
`workflows.run` triggers the full agent-driven pipeline per row:
|
|
457
|
-
filter → agent enrichment (the Ollama call that classifies the
|
|
458
|
-
message) → decision (the category interpolated into the decision
|
|
459
|
-
array) → action fan-out. The SQL where-clause scopes the run to
|
|
460
|
-
exactly the three submission ids §007 ingested (`submission_id` is
|
|
461
|
-
the generic table's unique column, so it pins the run directly).
|
|
462
|
-
|
|
463
|
-
The Triage actions use `trigger_template: 'now'`, so they dispatch
|
|
464
|
-
immediately and the run reaches a terminal status — poll
|
|
465
|
-
`batchLogs.refresh` until it leaves `:pending`. The window is
|
|
466
|
-
generous because each row's enrichment is a live LLM inference
|
|
467
|
-
call.
|
|
468
|
-
|
|
469
|
-
```typescript
|
|
470
|
-
const submissionIds = [
|
|
471
|
-
`SUB-APPT-${runSuffix}`,
|
|
472
|
-
`SUB-JOB-${runSuffix}`,
|
|
473
|
-
`SUB-SPAM-${runSuffix}`,
|
|
474
|
-
]
|
|
475
|
-
const idList = submissionIds.map((id) => `'${id}'`).join(', ')
|
|
476
|
-
|
|
477
|
-
const runResp = await api.workflows.run(tenantSlug, datalakeSlug, ctx.workflowSlug, {
|
|
478
|
-
sql_where_clause: `submission_id IN (${idList})`,
|
|
479
|
-
mode: 'live',
|
|
480
|
-
manual_override: true,
|
|
481
|
-
})
|
|
482
|
-
// run-workflow only SCHEDULES the run. The log id and batch id are
|
|
483
|
-
// written when it fires, so read them back via workflowRuns.get.
|
|
484
|
-
const fired = await ctx.waitForFiredRun(datalakeSlug, runResp.data.workflow_run_id)
|
|
485
|
-
ctx.runLogId = fired.workflowRunLogId
|
|
486
|
-
ctx.runBatchId = fired.batchId!
|
|
487
|
-
|
|
488
|
-
const deadline = Date.now() + 240_000
|
|
489
|
-
let status: string | null = null
|
|
490
|
-
while (Date.now() < deadline) {
|
|
491
|
-
const { data: log } = await api.workflows.batchLogs.refresh(tenantSlug, datalakeSlug, ctx.workflowSlug, ctx.runLogId)
|
|
492
|
-
status = log.status ?? null
|
|
493
|
-
if (status && status !== 'pending') break
|
|
494
|
-
await new Promise((r) => setTimeout(r, 2_000))
|
|
495
|
-
}
|
|
496
|
-
if (status === 'failed') {
|
|
497
|
-
throw new Error('agent-driven workflow run reached :failed')
|
|
498
|
-
}
|
|
499
|
-
if (!status || status === 'pending') {
|
|
500
|
-
throw new Error('workflow run did not leave :pending within 240s')
|
|
501
|
-
}
|
|
502
|
-
```
|
|
503
|
-
|
|
504
|
-
## 010 — verify the agent's category steered the fan-out
|
|
505
|
-
|
|
506
|
-
Each submission produced a Workflow Execution Log. Every WEL has
|
|
507
|
-
exactly **three** action execution logs — one per bucket — and
|
|
508
|
-
exactly **one** is matched (`:pending` or `:completed`, the
|
|
509
|
-
category the agent emitted) while the other **two** are
|
|
510
|
-
`:skipped`. A run where a WEL had two matched actions, or zero,
|
|
511
|
-
would mean the agent's output did not actually steer the decision;
|
|
512
|
-
this step fails loudly in that case.
|
|
513
|
-
|
|
514
|
-
```typescript
|
|
515
|
-
const { data: wfLogs } = await api.workflows.workflowLogs.list(tenantSlug, datalakeSlug, ctx.workflowSlug)
|
|
516
|
-
const ourWels = (wfLogs.data ?? []).filter(
|
|
517
|
-
(w) => (w as { batch_id?: string }).batch_id === ctx.runBatchId,
|
|
518
|
-
)
|
|
519
|
-
if (ourWels.length !== 3) {
|
|
520
|
-
throw new Error(`expected 3 WELs for the run, got ${ourWels.length}`)
|
|
521
|
-
}
|
|
522
|
-
|
|
523
|
-
for (const wel of ourWels) {
|
|
524
|
-
const welId = (wel as { id?: string }).id
|
|
525
|
-
const aels =
|
|
526
|
-
(wel as { action_execution_logs?: Array<{ status?: string; decision_key?: string }> })
|
|
527
|
-
.action_execution_logs ?? []
|
|
528
|
-
if (aels.length !== 3) {
|
|
529
|
-
throw new Error(`WEL ${welId}: expected 3 AELs (one per bucket), got ${aels.length}`)
|
|
530
|
-
}
|
|
531
|
-
const byStatus: Record<string, number> = {}
|
|
532
|
-
for (const ael of aels) {
|
|
533
|
-
const st = ael.status ?? 'unknown'
|
|
534
|
-
byStatus[st] = (byStatus[st] ?? 0) + 1
|
|
535
|
-
}
|
|
536
|
-
const matched = (byStatus.pending ?? 0) + (byStatus.completed ?? 0)
|
|
537
|
-
if (matched !== 1) {
|
|
538
|
-
throw new Error(`WEL ${welId}: expected exactly 1 matched AEL, got ${matched} — ${JSON.stringify(byStatus)}`)
|
|
539
|
-
}
|
|
540
|
-
if ((byStatus.skipped ?? 0) !== 2) {
|
|
541
|
-
throw new Error(`WEL ${welId}: expected 2 :skipped AELs — ${JSON.stringify(byStatus)}`)
|
|
542
|
-
}
|
|
543
|
-
const routed = aels.find((a) => a.status === 'pending' || a.status === 'completed')
|
|
544
|
-
if (!routed?.decision_key || !/^(appointment_request|job_inquiry|flag_spam)$/.test(routed.decision_key)) {
|
|
545
|
-
throw new Error(`WEL ${welId}: matched AEL has unexpected decision_key ${routed?.decision_key}`)
|
|
546
|
-
}
|
|
547
|
-
}
|
|
548
|
-
```
|
|
549
|
-
|
|
550
|
-
## 011 — write the integration test
|
|
551
|
-
|
|
552
|
-
End the build with a test you keep: re-read the workflow and prove the
|
|
553
|
-
pipeline still executes — without a side effect. `mode: 'dry_run'` with a
|
|
554
|
-
never-matching selection runs the FULL pipeline (selection → filter →
|
|
555
|
-
decision) and intercepts only the final action call, so no message
|
|
556
|
-
leaves, yet the acknowledgement proves the workflow is runnable. This
|
|
557
|
-
block runs live under `make validate-cookbook`.
|
|
558
|
-
|
|
559
|
-
```typescript
|
|
560
|
-
// Re-GET — the workflow must still be live, or nothing will run.
|
|
561
|
-
const { data: wfRow } = await api.workflows.get(tenantSlug, datalakeSlug, workflowId)
|
|
562
|
-
if (wfRow.status !== 'live') {
|
|
563
|
-
throw new Error(`workflow regressed from live: ${wfRow.status}`)
|
|
564
|
-
}
|
|
565
|
-
// Behavioural probe — a dry run against a selection no row can match:
|
|
566
|
-
// the pipeline executes end-to-end, the final action call is
|
|
567
|
-
// intercepted, and the acknowledgement carries the scheduled run id. The
|
|
568
|
-
// clause speaks this workflow's selection dialect: a GENERIC-TABLE
|
|
569
|
-
// dataset is addressed by its own columns (no `ra.` dataset alias —
|
|
570
|
-
// that alias exists only for system-dataset selections).
|
|
571
|
-
const { data: probeRun } = await api.workflows.run(tenantSlug, datalakeSlug, ctx.workflowSlug, {
|
|
572
|
-
sql_where_clause: "submission_id = 'test-never-matching-submission'",
|
|
573
|
-
mode: 'dry_run',
|
|
574
|
-
manual_override: false,
|
|
575
|
-
})
|
|
576
|
-
// The run-log id does not exist until the run fires — wait, do not read a null.
|
|
577
|
-
const probeFired = await ctx.waitForFiredRun(datalakeSlug, probeRun.workflow_run_id)
|
|
578
|
-
if (probeFired.workflowRunLogId.length === 0) {
|
|
579
|
-
throw new Error('dry-run probe never produced a workflow_run_log_id')
|
|
580
|
-
}
|
|
581
|
-
```
|
|
582
|
-
|
|
583
|
-
If the probe fails in production, escalate with the run response as
|
|
584
|
-
evidence — don't flip the workflow's status or rewrite its configs to
|
|
585
|
-
chase the error.
|
|
586
|
-
|
|
587
|
-
# Branches
|
|
588
|
-
|
|
589
|
-
- **The filter is permissive** — `filter_config.body: 'true'`
|
|
590
|
-
passes every row, so all three submissions reach the agent. The
|
|
591
|
-
interesting routing happens at the decision step, not the
|
|
592
|
-
filter. A production triage workflow might gate on a
|
|
593
|
-
not-yet-handled flag in the filter; that polarity is covered by
|
|
594
|
-
the `appointment-review-sms-workflow` cookbook.
|
|
595
|
-
- **Agent emits an out-of-enum category** — the LLM response
|
|
596
|
-
schema's `enum` constraint guards the decision interpolation. If
|
|
597
|
-
the model returned an unexpected string the workflow row's
|
|
598
|
-
execution log would land `:failed`. The anchor vitest treats
|
|
599
|
-
that as a model-quality failure rather than a platform bug;
|
|
600
|
-
production deployments either tighten the prompt or add a
|
|
601
|
-
default SMS action as a fallback bucket.
|
|
602
|
-
- **Transport failure on the LLM call** — the anchor vitest's §8
|
|
603
|
-
wires a second LLM tool to a deliberately wrong Ollama URL and
|
|
604
|
-
asserts the WEL lands `:failed` with `error_code:
|
|
605
|
-
tool_execution_failed` in its `error.json` artifact. This
|
|
606
|
-
cookbook walks only the happy path; the failure branch is the
|
|
607
|
-
anchor test's own coverage.
|
|
608
|
-
|
|
609
|
-
# Rollback
|
|
610
|
-
|
|
611
|
-
The cookbook doctest harness does not currently tear down created
|
|
612
|
-
resources. The `_setup/healthcare.md` setup file's runSuffix-scoped
|
|
613
|
-
tenant / datalake / user names mean each run is naturally isolated;
|
|
614
|
-
the seeded local DB is cheap to reset (`mix ecto.reset` on the
|
|
615
|
-
platform repo).
|
|
616
|
-
|
|
617
|
-
# Outcome
|
|
618
|
-
|
|
619
|
-
After this cookbook's ten steps run green:
|
|
620
|
-
|
|
621
|
-
- A healthcare tenant exists with a healthcare-domain datalake
|
|
622
|
-
- A Contact Us Generic Table, an SMS tool, a chat-completion LLM
|
|
623
|
-
tool, a Contact Us Triage AI agent (qwen3-vl:8b-instruct, three-category
|
|
624
|
-
response schema), and a Contact Us Triage agent-driven workflow
|
|
625
|
-
are all registered
|
|
626
|
-
- Three contact-us submissions have been ingested through the
|
|
627
|
-
table's auto-provisioned default DAC
|
|
628
|
-
- Running the workflow drove each row through the agent: the LLM
|
|
629
|
-
classified the message, the decision interpolated the category,
|
|
630
|
-
and the matching bucket's SMS action fired
|
|
631
|
-
- The routing is verified row-by-row — every Workflow Execution
|
|
632
|
-
Log carries exactly one matched action and two `:skipped`,
|
|
633
|
-
proving the agent's output actually steered the fan-out
|
|
634
|
-
|
|
635
|
-
The business outcome — inbound contact-us messages triaged by an
|
|
636
|
-
LLM and routed to bucket-specific outreach — is demonstrated
|
|
637
|
-
end-to-end, not merely provisioned.
|
|
638
|
-
|
|
639
|
-
# See also
|
|
640
|
-
|
|
641
|
-
- `_setup/healthcare.md` — the inlined bootstrap that provisions
|
|
642
|
-
the tenant + datalake this cookbook starts from
|
|
643
|
-
- `appointment-review-sms-workflow.md` — the static-decision
|
|
644
|
-
counterpart in healthcare; same SMS wiring, full data-activation
|
|
645
|
-
chain, no agent
|
|
646
|
-
- `score-leads-with-llm-categorization.md` — the foundation
|
|
647
|
-
agent-driven cookbook; identical agent + generic-table shape,
|
|
648
|
-
four bands instead of three buckets
|
|
649
|
-
- `.agent/tools.md` — SMS and chat-completion tool body shapes;
|
|
650
|
-
intent classification
|
|
651
|
-
- `.agent/ai_agents.md` — AI agent registration shape; input +
|
|
652
|
-
response schemas; prompt config
|
|
653
|
-
- `.agent/workflows.md` — standard and agent-driven workflow
|
|
654
|
-
primitives; `ai_agents`, `context_mapping_config`, and
|
|
655
|
-
agent-output decision interpolation
|
|
656
|
-
- `.agent/generic_tables.md` — generic-table creation and the
|
|
657
|
-
auto-provisioned identity contract + default DAC
|
|
658
|
-
- `integration-tests/tests/healthcare/agent-driven-workflow.test.ts` —
|
|
659
|
-
the anchor green test (positive branch §1, §3, §4, §6, §7) these
|
|
660
|
-
snippets are lifted from
|
|
661
|
-
- `integration-tests/tests/healthcare/generic-tables.test.ts`,
|
|
662
|
-
`tools.test.ts` — the per-resource create snippets are lifted
|
|
663
|
-
from
|