@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,571 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
title: Triage inbound AR prospects into priority bands via an LLM agent and route each to a tailored SMS
|
|
3
|
-
summary: End-to-end agent-driven workflow over the subscription customer dataset — a chat-completion LLM agent classifies each customer into a priority band (high/medium/low) by account type, the workflow's decision interpolates the agent's band, three SMS actions (one per band) fan out, and the run is verified row-by-row so every Workflow Execution Log carries exactly one matched action and two skipped.
|
|
4
|
-
industry: subscription
|
|
5
|
-
slug: triage-prospects-by-priority
|
|
6
|
-
vitest_source:
|
|
7
|
-
- integration-tests/tests/subscription/agent-lead-triage.test.ts
|
|
8
|
-
- integration-tests/tests/subscription/interoperability-contracts.test.ts
|
|
9
|
-
- integration-tests/tests/subscription/run-dac-single.test.ts
|
|
10
|
-
- integration-tests/tests/subscription/create-dac.test.ts
|
|
11
|
-
- integration-tests/tests/subscription/tools.test.ts
|
|
12
|
-
- integration-tests/tests/subscription/data-sources.test.ts
|
|
13
|
-
- integration-tests/tests/subscription/bootstrap.test.ts
|
|
14
|
-
status: green
|
|
15
|
-
---
|
|
16
|
-
|
|
17
|
-
# Problem
|
|
18
|
-
|
|
19
|
-
The `dunning-sms-for-delinquent` cookbook fired the **same** action for every
|
|
20
|
-
customer that passed its filter. Many AR engagements need the opposite: which
|
|
21
|
-
outreach fires should depend on **what kind of account** the customer is — a
|
|
22
|
-
high-value enterprise account gets a white-glove message, a self-serve
|
|
23
|
-
individual gets a standard nudge, a slow-paying government account gets a
|
|
24
|
-
low-urgency note. That banding is a judgement call over the account, not a
|
|
25
|
-
fixed rule.
|
|
26
|
-
|
|
27
|
-
The Alvera platform's agent-driven workflow turns the decision step into an LLM
|
|
28
|
-
classification plus a Liquid interpolation. An AI agent reads each customer's
|
|
29
|
-
type, returns a JSON object whose `priority_band` is one of three enum values,
|
|
30
|
-
and the workflow's `decision_config` interpolates that band into a one-element
|
|
31
|
-
decision array (`["{{ additional_context["agent-slug"].priority_band }}"]`).
|
|
32
|
-
Three SMS actions are registered, one per band; the agent's output picks which
|
|
33
|
-
one runs.
|
|
34
|
-
|
|
35
|
-
This cookbook walks the **whole** scenario: it provisions the SMS tool, the LLM
|
|
36
|
-
tool, the triage agent, the AR customer ingestion chain, and the agent-driven
|
|
37
|
-
workflow; ingests two Stripe customers (an enterprise and an individual);
|
|
38
|
-
runs the workflow so the agent bands each one; and verifies the routing
|
|
39
|
-
row-by-row — every Workflow Execution Log carries exactly one matched action
|
|
40
|
-
and two `:skipped`.
|
|
41
|
-
|
|
42
|
-
The scenario is anchored to
|
|
43
|
-
`platform/integration-tests/tests/subscription/agent-lead-triage.test.ts`.
|
|
44
|
-
The setup file `_setup/subscription.md` already provisioned the tenant +
|
|
45
|
-
datalake + tenant-scoped client; this cookbook starts from there.
|
|
46
|
-
|
|
47
|
-
# Composition
|
|
48
|
-
|
|
49
|
-
| Resource provisioned | Owner |
|
|
50
|
-
|--------------------------------------------|-------|
|
|
51
|
-
| SMS tool (SNS-backed) | build |
|
|
52
|
-
| LLM tool (Ollama chat-completion adapter) | build |
|
|
53
|
-
| Priority Triage AI agent | build |
|
|
54
|
-
| Stripe data source | build |
|
|
55
|
-
| Manual Upload tool | build |
|
|
56
|
-
| Stripe Customer interop contract | build |
|
|
57
|
-
| Manual-upload DAC | build |
|
|
58
|
-
| Priority Triage workflow | build |
|
|
59
|
-
|
|
60
|
-
# Walkthrough
|
|
61
|
-
|
|
62
|
-
## 001 — create the SMS tool
|
|
63
|
-
|
|
64
|
-
A single SMS tool dispatches every band's message; the per-band body lives on
|
|
65
|
-
each workflow action. SNS-backed, pointed at LocalStack locally; `intent: 'sms'`
|
|
66
|
-
tags it for workflow actions.
|
|
67
|
-
|
|
68
|
-
```typescript
|
|
69
|
-
const smsToolResp = await api.tools.create(tenantSlug, datalakeSlug, {
|
|
70
|
-
name: `Cookbook SMS Tool ${runSuffix}`,
|
|
71
|
-
description: 'SNS-backed SMS dispatcher for the priority-triage workflow, wired to LocalStack.',
|
|
72
|
-
intent: 'sms',
|
|
73
|
-
status: 'active',
|
|
74
|
-
datalake_id: ctx.datalakeId,
|
|
75
|
-
body: {
|
|
76
|
-
tool_body_type: 'sns',
|
|
77
|
-
auth_method: 'access_key',
|
|
78
|
-
region: 'us-east-1',
|
|
79
|
-
phone_number: '+15551234567',
|
|
80
|
-
endpoint_url: 'http://localhost:4566',
|
|
81
|
-
access_key_id: 'test',
|
|
82
|
-
secret_access_key: 'test',
|
|
83
|
-
},
|
|
84
|
-
})
|
|
85
|
-
toolId = smsToolResp.data.id!
|
|
86
|
-
```
|
|
87
|
-
|
|
88
|
-
## 002 — create the LLM tool
|
|
89
|
-
|
|
90
|
-
The triage agent calls a chat-completion endpoint. This tool is a **provider
|
|
91
|
-
adapter**: `base_body` authors Ollama's native `/api/chat` request with
|
|
92
|
-
`think: false` and a `format` schema so the model returns clean,
|
|
93
|
-
schema-constrained JSON, and `response_extractor` maps the response back to the
|
|
94
|
-
canonical `{ output_json, … }`. The extractor's `output_schema` is required for
|
|
95
|
-
an `llm_enrichment` tool.
|
|
96
|
-
|
|
97
|
-
```typescript
|
|
98
|
-
const ENRICHMENT_OUTPUT_SCHEMA = {
|
|
99
|
-
type: 'object',
|
|
100
|
-
properties: {
|
|
101
|
-
output_json: {},
|
|
102
|
-
input_tokens: { type: ['integer', 'null'] },
|
|
103
|
-
output_tokens: { type: ['integer', 'null'] },
|
|
104
|
-
total_tokens: { type: ['integer', 'null'] },
|
|
105
|
-
explanation: { type: ['string', 'null'] },
|
|
106
|
-
},
|
|
107
|
-
required: ['output_json'],
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
const OLLAMA_BASE_BODY =
|
|
111
|
-
'{"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 }}}'
|
|
112
|
-
|
|
113
|
-
const OLLAMA_EXTRACTOR =
|
|
114
|
-
'{"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 }}}'
|
|
115
|
-
|
|
116
|
-
const llmToolResp = await api.tools.create(tenantSlug, datalakeSlug, {
|
|
117
|
-
name: `Cookbook LLM Tool ${runSuffix}`,
|
|
118
|
-
description: 'Ollama-backed chat-completion adapter for AR priority triage.',
|
|
119
|
-
intent: 'llm_enrichment',
|
|
120
|
-
status: 'active',
|
|
121
|
-
datalake_id: ctx.datalakeId,
|
|
122
|
-
response_extractor: { type: 'custom', body: OLLAMA_EXTRACTOR, output_schema: ENRICHMENT_OUTPUT_SCHEMA },
|
|
123
|
-
body: {
|
|
124
|
-
tool_body_type: 'rest_api',
|
|
125
|
-
base_url: 'http://localhost:11434',
|
|
126
|
-
base_path: { type: 'custom', body: '/api/chat' },
|
|
127
|
-
auth_method: 'api_key',
|
|
128
|
-
api_key: 'stub-key',
|
|
129
|
-
api_key_name: 'Authorization',
|
|
130
|
-
api_key_location: 'header',
|
|
131
|
-
request_type: 'json',
|
|
132
|
-
response_type: 'json',
|
|
133
|
-
timeout_ms: 60_000,
|
|
134
|
-
base_body: { type: 'custom', body: OLLAMA_BASE_BODY },
|
|
135
|
-
},
|
|
136
|
-
})
|
|
137
|
-
ctx.llmToolId = llmToolResp.data.id!
|
|
138
|
-
```
|
|
139
|
-
|
|
140
|
-
## 003 — create the Priority Triage agent
|
|
141
|
-
|
|
142
|
-
The agent binds the model, an `input_schema` the workflow's context-mapping must
|
|
143
|
-
satisfy, and an `llm_response_schema` whose `enum: ['priority_high',
|
|
144
|
-
'priority_medium', 'priority_low']` guards the decision interpolation from
|
|
145
|
-
emitting a band the workflow has no action for. `temperature: 0.0` makes
|
|
146
|
-
identical inputs classify identically. `data_access: 'unregulated'` keeps the
|
|
147
|
-
agent on the tokenized projection.
|
|
148
|
-
|
|
149
|
-
```typescript
|
|
150
|
-
const TRIAGE_INPUT_SCHEMA = {
|
|
151
|
-
type: 'object',
|
|
152
|
-
properties: {
|
|
153
|
-
customer_number: { type: 'string' },
|
|
154
|
-
customer_type: { type: 'string' },
|
|
155
|
-
},
|
|
156
|
-
required: ['customer_number', 'customer_type'],
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
const TRIAGE_RESPONSE_SCHEMA = {
|
|
160
|
-
type: 'object',
|
|
161
|
-
properties: {
|
|
162
|
-
priority_band: { type: 'string', enum: ['priority_high', 'priority_medium', 'priority_low'] },
|
|
163
|
-
},
|
|
164
|
-
required: ['priority_band'],
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
const TRIAGE_PROMPT_BODY = `You are an AR customer-priority triage assistant. Classify the customer's dunning priority into EXACTLY ONE band by account type:
|
|
168
|
-
|
|
169
|
-
- "priority_high" — enterprise (large account, high balance, white-glove outreach)
|
|
170
|
-
- "priority_medium" — individual (standard self-serve account)
|
|
171
|
-
- "priority_low" — government (long payment cycles, low urgency)
|
|
172
|
-
|
|
173
|
-
Customer number: {{ customer_number }}
|
|
174
|
-
Customer type: {{ customer_type }}
|
|
175
|
-
|
|
176
|
-
Respond with JSON: {"priority_band": "<one of priority_high|priority_medium|priority_low>"}`
|
|
177
|
-
|
|
178
|
-
const agentResp = await api.aiAgents.create(tenantSlug, datalakeSlug, {
|
|
179
|
-
name: `Cookbook Priority Triage Agent ${runSuffix}`,
|
|
180
|
-
tool_id: ctx.llmToolId,
|
|
181
|
-
model: 'qwen3-vl:8b-instruct',
|
|
182
|
-
data_access: 'unregulated',
|
|
183
|
-
temperature: 0.0,
|
|
184
|
-
max_tokens: 1024,
|
|
185
|
-
enabled: true,
|
|
186
|
-
input_schema: TRIAGE_INPUT_SCHEMA,
|
|
187
|
-
llm_response_schema: TRIAGE_RESPONSE_SCHEMA,
|
|
188
|
-
prompt_config: { type: 'custom', body: TRIAGE_PROMPT_BODY },
|
|
189
|
-
})
|
|
190
|
-
aiAgentId = agentResp.data.id!
|
|
191
|
-
ctx.agentSlug = agentResp.data.slug!
|
|
192
|
-
```
|
|
193
|
-
|
|
194
|
-
## 004 — create the Stripe data source
|
|
195
|
-
|
|
196
|
-
```typescript
|
|
197
|
-
const dataSourceResp = await api.dataSources.create(tenantSlug, datalakeSlug, {
|
|
198
|
-
name: `Cookbook Stripe Source ${runSuffix}`,
|
|
199
|
-
uri: 'stripe.example.com',
|
|
200
|
-
description: 'Stripe billing system — origin of the customer rows the triage workflow runs on.',
|
|
201
|
-
status: 'active',
|
|
202
|
-
is_default: false,
|
|
203
|
-
})
|
|
204
|
-
dataSourceId = dataSourceResp.data.id!
|
|
205
|
-
```
|
|
206
|
-
|
|
207
|
-
## 005 — create the Manual Upload tool
|
|
208
|
-
|
|
209
|
-
```typescript
|
|
210
|
-
const manualUploadToolResp = await api.tools.create(tenantSlug, datalakeSlug, {
|
|
211
|
-
name: `Cookbook Manual Upload Tool ${runSuffix}`,
|
|
212
|
-
description: 'Manual-upload data-exchange tool — backs the DAC that ingests Stripe customer rows.',
|
|
213
|
-
intent: 'data_exchange',
|
|
214
|
-
status: 'active',
|
|
215
|
-
datalake_id: ctx.datalakeId,
|
|
216
|
-
data_source_id: dataSourceId,
|
|
217
|
-
body: { tool_body_type: 'manual_upload' },
|
|
218
|
-
})
|
|
219
|
-
ctx.manualUploadToolId = manualUploadToolResp.data.id!
|
|
220
|
-
```
|
|
221
|
-
|
|
222
|
-
## 006 — create the Stripe Customer interoperability contract
|
|
223
|
-
|
|
224
|
-
The contract maps each inbound Stripe customer row into a `Customer` upsert.
|
|
225
|
-
It loads the production Stripe customer + MDM templates from the vendored
|
|
226
|
-
fixtures. `customer_type` flows through to `mdm_output.customer.customer_type`,
|
|
227
|
-
which §008's context-mapping feeds the agent.
|
|
228
|
-
|
|
229
|
-
```typescript
|
|
230
|
-
const { readFileSync } = await import('node:fs')
|
|
231
|
-
const { join } = await import('node:path')
|
|
232
|
-
const customerTemplate = readFileSync(
|
|
233
|
-
join(process.env.COOKBOOK_FIXTURES_DIR!, 'subscription/_customers_subscription_customer.liquid'),
|
|
234
|
-
'utf8',
|
|
235
|
-
)
|
|
236
|
-
const mdmTemplate = readFileSync(
|
|
237
|
-
join(process.env.COOKBOOK_FIXTURES_DIR!, 'subscription/_customers_subscription_mdm.liquid'),
|
|
238
|
-
'utf8',
|
|
239
|
-
)
|
|
240
|
-
|
|
241
|
-
const contractResp = await api.interoperabilityContracts.create(tenantSlug, datalakeSlug, {
|
|
242
|
-
name: `Cookbook Stripe Customer Contract ${runSuffix}`,
|
|
243
|
-
description: 'Stripe customers → Subscription Customer (custom Liquid + MDM input).',
|
|
244
|
-
resource_type: 'customer',
|
|
245
|
-
template_config: { type: 'custom', body: customerTemplate },
|
|
246
|
-
mdm_input_config: { type: 'custom', body: mdmTemplate },
|
|
247
|
-
generic_table_id: null,
|
|
248
|
-
})
|
|
249
|
-
interopContractId = contractResp.data.id!
|
|
250
|
-
```
|
|
251
|
-
|
|
252
|
-
## 007 — create the manual-upload DAC
|
|
253
|
-
|
|
254
|
-
```typescript
|
|
255
|
-
const dacResp = await api.dataActivationClients.create(tenantSlug, datalakeSlug, {
|
|
256
|
-
name: `Cookbook Stripe Customer DAC ${runSuffix}`,
|
|
257
|
-
description: 'Manual-upload DAC — ingests Stripe customer rows into Customer via the interop contract.',
|
|
258
|
-
tool_id: ctx.manualUploadToolId,
|
|
259
|
-
data_source_id: dataSourceId,
|
|
260
|
-
tool_call: { tool_call_type: 'manual_upload' },
|
|
261
|
-
interoperability_contract_ids: [interopContractId],
|
|
262
|
-
})
|
|
263
|
-
dacId = dacResp.data.id!
|
|
264
|
-
ctx.dacSlug = dacResp.data.slug!
|
|
265
|
-
```
|
|
266
|
-
|
|
267
|
-
## 008 — create the Priority Triage workflow
|
|
268
|
-
|
|
269
|
-
The workflow targets the AR `customer` dataset. Two things make it
|
|
270
|
-
agent-driven. First, the triage agent is **nested** in the create body (the
|
|
271
|
-
`workflow_ai_agents` array) with a `context_mapping_config` that projects each
|
|
272
|
-
customer's fields into the agent's input schema. Second, the `decision_config`
|
|
273
|
-
interpolates the agent's `priority_band` (read from
|
|
274
|
-
`additional_context["<agent-slug>"]`, bracket access because the slug has
|
|
275
|
-
hyphens) into a one-element decision array. The three `actions` are keyed by
|
|
276
|
-
band; whichever the agent emits fires, leaving the other two `:skipped`. The
|
|
277
|
-
filter keeps only SMS-reachable, KYC-complete customers
|
|
278
|
-
(`customer.phone and customer.tax_id`).
|
|
279
|
-
|
|
280
|
-
```typescript
|
|
281
|
-
const BANDS = ['priority_high', 'priority_medium', 'priority_low'] as const
|
|
282
|
-
|
|
283
|
-
const CONTEXT_MAPPING_BODY = JSON.stringify({
|
|
284
|
-
customer_number: '{{ customer.customer_number }}',
|
|
285
|
-
customer_type: '{{ mdm_output.customer.customer_type }}',
|
|
286
|
-
})
|
|
287
|
-
|
|
288
|
-
const DECISION_CONFIG_BODY = `["{{ additional_context["${ctx.agentSlug}"].priority_band }}"]`
|
|
289
|
-
const DECISION_OUTPUT_SCHEMA = { type: 'array', items: { type: 'string' } }
|
|
290
|
-
|
|
291
|
-
const workflowResp = await api.workflows.create(tenantSlug, datalakeSlug, {
|
|
292
|
-
name: `Cookbook Priority Triage Workflow ${runSuffix}`,
|
|
293
|
-
description: 'Bands AR customers into priority_high/medium/low via an LLM agent; one SMS action per band.',
|
|
294
|
-
dataset_type: 'customer',
|
|
295
|
-
status: 'live',
|
|
296
|
-
tags: ['ar', 'triage'],
|
|
297
|
-
filter_config: {
|
|
298
|
-
type: 'custom',
|
|
299
|
-
body: '{% if customer.phone and customer.tax_id %}true{% endif %}',
|
|
300
|
-
output_schema: { type: 'boolean' },
|
|
301
|
-
},
|
|
302
|
-
decision_config: {
|
|
303
|
-
type: 'custom',
|
|
304
|
-
body: DECISION_CONFIG_BODY,
|
|
305
|
-
output_schema: DECISION_OUTPUT_SCHEMA,
|
|
306
|
-
},
|
|
307
|
-
actions: BANDS.map((band) => ({
|
|
308
|
-
decision_key: band,
|
|
309
|
-
action_type: 'sms',
|
|
310
|
-
tool_id: toolId,
|
|
311
|
-
position: 0,
|
|
312
|
-
trigger_template: 'now',
|
|
313
|
-
idempotency_template: `{{ customer_id }}-${band}`,
|
|
314
|
-
tool_call: {
|
|
315
|
-
tool_call_type: 'sms_request',
|
|
316
|
-
to: { type: 'custom', body: '{{ mdm_output.regulated_customer.phone }}' },
|
|
317
|
-
body: {
|
|
318
|
-
type: 'custom',
|
|
319
|
-
body: `[${band}] {{ mdm_output.regulated_customer.name }}, a note about account {{ customer.customer_number }}.`,
|
|
320
|
-
},
|
|
321
|
-
sms_type: 'transactional',
|
|
322
|
-
},
|
|
323
|
-
})),
|
|
324
|
-
// Nest the triage agent inline. Workflow joins omit output_schema — the
|
|
325
|
-
// server pins it from the agent's input_schema.
|
|
326
|
-
workflow_ai_agents: [
|
|
327
|
-
{
|
|
328
|
-
ai_agent_id: aiAgentId,
|
|
329
|
-
position: 0,
|
|
330
|
-
context_mapping_config: { type: 'custom', body: CONTEXT_MAPPING_BODY },
|
|
331
|
-
},
|
|
332
|
-
],
|
|
333
|
-
})
|
|
334
|
-
workflowId = workflowResp.data.id!
|
|
335
|
-
ctx.workflowSlug = workflowResp.data.slug!
|
|
336
|
-
```
|
|
337
|
-
|
|
338
|
-
## 009 — ingest two Stripe customers
|
|
339
|
-
|
|
340
|
-
Two rows through the manual-upload DAC: one enterprise account, one individual.
|
|
341
|
-
Both carry a phone and a tax id so both pass the filter and reach the agent;
|
|
342
|
-
they differ in `customer_type`, which is exactly what the agent bands on. Each
|
|
343
|
-
ingest returns its own batch id; both are pinned for the run scope.
|
|
344
|
-
|
|
345
|
-
```typescript
|
|
346
|
-
const customerRows = [
|
|
347
|
-
{
|
|
348
|
-
customer_number: `CUS-ENT-${runSuffix}`,
|
|
349
|
-
customer_type: 'enterprise',
|
|
350
|
-
status: 'contracted',
|
|
351
|
-
name: 'Pinnacle Financial Group',
|
|
352
|
-
email: `ap-${runSuffix}@pinnacle.example`,
|
|
353
|
-
phone: '+12125559000',
|
|
354
|
-
tax_id: '84-2156789',
|
|
355
|
-
currency: 'usd',
|
|
356
|
-
source_uri: 'stripe.example.com',
|
|
357
|
-
},
|
|
358
|
-
{
|
|
359
|
-
customer_number: `CUS-IND-${runSuffix}`,
|
|
360
|
-
customer_type: 'individual',
|
|
361
|
-
status: 'contracted',
|
|
362
|
-
name: 'James Whitfield',
|
|
363
|
-
email: `james-${runSuffix}@example.com`,
|
|
364
|
-
phone: '+12025551001',
|
|
365
|
-
tax_id: '123-45-6789',
|
|
366
|
-
currency: 'usd',
|
|
367
|
-
source_uri: 'stripe.example.com',
|
|
368
|
-
},
|
|
369
|
-
]
|
|
370
|
-
|
|
371
|
-
const ingestResults = await Promise.all(
|
|
372
|
-
customerRows.map((row) =>
|
|
373
|
-
api.dataActivationClients.ingest(tenantSlug, datalakeSlug, ctx.dacSlug, { data: row }),
|
|
374
|
-
),
|
|
375
|
-
)
|
|
376
|
-
ctx.customerBatchIds = ingestResults.map((r) => r.data.batch_id!)
|
|
377
|
-
if (ctx.customerBatchIds.length !== 2) {
|
|
378
|
-
throw new Error(`expected 2 ingest batch ids, got ${ctx.customerBatchIds.length}`)
|
|
379
|
-
}
|
|
380
|
-
```
|
|
381
|
-
|
|
382
|
-
## 010 — wait for the two ingest batches to reach steady-state
|
|
383
|
-
|
|
384
|
-
Ingestion is async — poll the DAC's activation logs until both batches show a
|
|
385
|
-
customer row was upserted (`rows_ingested >= 1` and a non-empty `output_files`).
|
|
386
|
-
|
|
387
|
-
```typescript
|
|
388
|
-
const targetBatches = new Set<string>(ctx.customerBatchIds)
|
|
389
|
-
const deadline = Date.now() + 90_000
|
|
390
|
-
let greenCount = 0
|
|
391
|
-
while (Date.now() < deadline) {
|
|
392
|
-
const { data } = await api.dataActivationClients.logs.list(tenantSlug, datalakeSlug, ctx.dacSlug)
|
|
393
|
-
const green = new Set<string>()
|
|
394
|
-
for (const row of (data.data ?? []) as Array<Record<string, unknown>>) {
|
|
395
|
-
const b = row.batch_id
|
|
396
|
-
if (typeof b !== 'string' || !targetBatches.has(b)) continue
|
|
397
|
-
if (typeof row.rows_ingested !== 'number' || row.rows_ingested < 1) continue
|
|
398
|
-
if (!Array.isArray(row.output_files) || row.output_files.length === 0) continue
|
|
399
|
-
green.add(b)
|
|
400
|
-
}
|
|
401
|
-
greenCount = green.size
|
|
402
|
-
if (greenCount === targetBatches.size) break
|
|
403
|
-
await new Promise((r) => setTimeout(r, 1_000))
|
|
404
|
-
}
|
|
405
|
-
if (greenCount !== targetBatches.size) {
|
|
406
|
-
throw new Error(`only ${greenCount}/2 customer batches reached steady-state within 90s`)
|
|
407
|
-
}
|
|
408
|
-
```
|
|
409
|
-
|
|
410
|
-
## 011 — run the workflow against the two customers
|
|
411
|
-
|
|
412
|
-
`workflows.run` drives the full agent-driven pipeline per row: filter → agent
|
|
413
|
-
enrichment (the inference call that bands the customer) → decision (the band
|
|
414
|
-
interpolated into the decision array) → action fan-out. The SQL where-clause
|
|
415
|
-
scopes the run to the two batches (`ra` is the customer dataset alias). The
|
|
416
|
-
window is generous because each row's enrichment is a live LLM call.
|
|
417
|
-
|
|
418
|
-
```typescript
|
|
419
|
-
const batchList = (ctx.customerBatchIds as string[]).map((b) => `'${b}'`).join(', ')
|
|
420
|
-
|
|
421
|
-
const runResp = await api.workflows.run(tenantSlug, datalakeSlug, ctx.workflowSlug, {
|
|
422
|
-
sql_where_clause: `ra.batch_id IN (${batchList})`,
|
|
423
|
-
mode: 'live',
|
|
424
|
-
manual_override: true,
|
|
425
|
-
})
|
|
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!
|
|
431
|
-
|
|
432
|
-
const deadline = Date.now() + 240_000
|
|
433
|
-
let status: string | null = null
|
|
434
|
-
while (Date.now() < deadline) {
|
|
435
|
-
const { data: log } = await api.workflows.batchLogs.refresh(tenantSlug, datalakeSlug, ctx.workflowSlug, ctx.runLogId)
|
|
436
|
-
status = log.status ?? null
|
|
437
|
-
if (status && status !== 'pending') break
|
|
438
|
-
await new Promise((r) => setTimeout(r, 2_000))
|
|
439
|
-
}
|
|
440
|
-
if (status === 'failed') {
|
|
441
|
-
throw new Error('agent-driven triage workflow run reached :failed')
|
|
442
|
-
}
|
|
443
|
-
if (!status || status === 'pending') {
|
|
444
|
-
throw new Error('workflow run did not leave :pending within 240s')
|
|
445
|
-
}
|
|
446
|
-
```
|
|
447
|
-
|
|
448
|
-
## 012 — verify the agent's band steered the fan-out
|
|
449
|
-
|
|
450
|
-
Each customer produced a Workflow Execution Log. Every WEL has exactly **three**
|
|
451
|
-
action execution logs — one per band — and exactly **one** is matched
|
|
452
|
-
(`:pending` or `:completed`, the band the agent emitted) while the other **two**
|
|
453
|
-
are `:skipped`. A WEL with two matched actions, or zero, would mean the agent's
|
|
454
|
-
output did not actually steer the decision.
|
|
455
|
-
|
|
456
|
-
```typescript
|
|
457
|
-
const { data: wfLogs } = await api.workflows.workflowLogs.list(tenantSlug, datalakeSlug, ctx.workflowSlug)
|
|
458
|
-
const ourWels = (wfLogs.data ?? []).filter(
|
|
459
|
-
(w) => (w as { batch_id?: string }).batch_id === ctx.runBatchId,
|
|
460
|
-
)
|
|
461
|
-
if (ourWels.length !== 2) {
|
|
462
|
-
throw new Error(`expected 2 WELs for the run, got ${ourWels.length}`)
|
|
463
|
-
}
|
|
464
|
-
|
|
465
|
-
for (const wel of ourWels) {
|
|
466
|
-
const welId = (wel as { id?: string }).id
|
|
467
|
-
const aels =
|
|
468
|
-
(wel as { action_execution_logs?: Array<{ status?: string }> }).action_execution_logs ?? []
|
|
469
|
-
if (aels.length !== 3) {
|
|
470
|
-
throw new Error(`WEL ${welId}: expected 3 AELs (one per band), got ${aels.length}`)
|
|
471
|
-
}
|
|
472
|
-
const byStatus: Record<string, number> = {}
|
|
473
|
-
for (const ael of aels) {
|
|
474
|
-
const st = ael.status ?? 'unknown'
|
|
475
|
-
byStatus[st] = (byStatus[st] ?? 0) + 1
|
|
476
|
-
}
|
|
477
|
-
const matched = (byStatus.pending ?? 0) + (byStatus.completed ?? 0)
|
|
478
|
-
if (matched !== 1) {
|
|
479
|
-
throw new Error(`WEL ${welId}: expected exactly 1 matched AEL, got ${matched} — ${JSON.stringify(byStatus)}`)
|
|
480
|
-
}
|
|
481
|
-
if ((byStatus.skipped ?? 0) !== 2) {
|
|
482
|
-
throw new Error(`WEL ${welId}: expected 2 :skipped AELs — ${JSON.stringify(byStatus)}`)
|
|
483
|
-
}
|
|
484
|
-
}
|
|
485
|
-
```
|
|
486
|
-
|
|
487
|
-
## 013 — write the integration test
|
|
488
|
-
|
|
489
|
-
End the build with a test you keep: re-read the workflow and prove the
|
|
490
|
-
pipeline still executes — without a side effect. `mode: 'dry_run'` with a
|
|
491
|
-
never-matching selection runs the FULL pipeline (selection → filter →
|
|
492
|
-
decision) and intercepts only the final action call, so no message
|
|
493
|
-
leaves, yet the acknowledgement proves the workflow is runnable. This
|
|
494
|
-
block runs live under `make validate-cookbook`.
|
|
495
|
-
|
|
496
|
-
```typescript
|
|
497
|
-
// Re-GET — the workflow must still be live, or nothing will run.
|
|
498
|
-
const { data: wfRow } = await api.workflows.get(tenantSlug, datalakeSlug, workflowId)
|
|
499
|
-
if (wfRow.status !== 'live') {
|
|
500
|
-
throw new Error(`workflow regressed from live: ${wfRow.status}`)
|
|
501
|
-
}
|
|
502
|
-
// Behavioural probe — a dry run against a selection no row can match:
|
|
503
|
-
// the pipeline executes end-to-end, the final action call is
|
|
504
|
-
// intercepted, and the acknowledgement carries the scheduled run id.
|
|
505
|
-
const { data: probeRun } = await api.workflows.run(tenantSlug, datalakeSlug, ctx.workflowSlug, {
|
|
506
|
-
sql_where_clause: "ra.batch_id = 'test-never-matching-batch'",
|
|
507
|
-
mode: 'dry_run',
|
|
508
|
-
manual_override: false,
|
|
509
|
-
})
|
|
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')
|
|
514
|
-
}
|
|
515
|
-
```
|
|
516
|
-
|
|
517
|
-
If the probe fails in production, escalate with the run response as
|
|
518
|
-
evidence — don't flip the workflow's status or rewrite its configs to
|
|
519
|
-
chase the error.
|
|
520
|
-
|
|
521
|
-
# Branches
|
|
522
|
-
|
|
523
|
-
- **Agent emits an out-of-enum band** — the response schema's
|
|
524
|
-
`enum: ['priority_high','priority_medium','priority_low']` guards the decision
|
|
525
|
-
interpolation. If the model returned an unexpected string the row's execution
|
|
526
|
-
log would land `:failed`. Production deployments either tighten the prompt or
|
|
527
|
-
add a `default` band action as a fallback.
|
|
528
|
-
- **Per-band SMS template variation** — each action carries its own
|
|
529
|
-
`tool_call.body.body` Liquid. The cookbook renders the same body shape with
|
|
530
|
-
the band interpolated for clarity; production scenarios author a distinct
|
|
531
|
-
message per band (white-glove for high, a standard nudge for medium, a
|
|
532
|
-
low-urgency note for low).
|
|
533
|
-
- **The filter still runs** — `customer.phone and customer.tax_id` gates which
|
|
534
|
-
customers reach the agent at all; both rows here qualify, so both are banded.
|
|
535
|
-
A customer missing either field would be `:filtered` before the agent saw it.
|
|
536
|
-
|
|
537
|
-
# Rollback
|
|
538
|
-
|
|
539
|
-
The cookbook doctest harness does not currently tear down created resources. The
|
|
540
|
-
`_setup/subscription.md` setup file's runSuffix-scoped names keep each run
|
|
541
|
-
isolated; the seeded local DB is cheap to reset (`mix ecto.reset` on the platform
|
|
542
|
-
repo).
|
|
543
|
-
|
|
544
|
-
# Outcome
|
|
545
|
-
|
|
546
|
-
After this cookbook runs green:
|
|
547
|
-
|
|
548
|
-
- An SMS tool, an Ollama chat-completion LLM tool, a Priority Triage AI agent
|
|
549
|
-
(qwen3-vl:8b-instruct, three-band response schema), and an agent-driven
|
|
550
|
-
workflow over the `customer` dataset are all registered
|
|
551
|
-
- Two Stripe customers (enterprise + individual) have been ingested through the
|
|
552
|
-
AR customer chain
|
|
553
|
-
- Running the workflow drove each through the agent: the LLM banded the
|
|
554
|
-
customer, the decision interpolated the band, and the matching band's SMS
|
|
555
|
-
action fired
|
|
556
|
-
- The routing is verified row-by-row — every Workflow Execution Log carries
|
|
557
|
-
exactly one matched action and two `:skipped`, proving the agent's output
|
|
558
|
-
steered the fan-out
|
|
559
|
-
|
|
560
|
-
# See also
|
|
561
|
-
|
|
562
|
-
- `_setup/subscription.md` — the inlined bootstrap this cookbook starts from
|
|
563
|
-
- `dunning-sms-for-delinquent.md` — the static-decision AR counterpart (same
|
|
564
|
-
customer chain, one fixed action, no agent)
|
|
565
|
-
- `score-leads-with-llm-categorization.md` — the foundation analogue (leads into
|
|
566
|
-
four bands over a generic table)
|
|
567
|
-
- `ai-agent-invoke.md` — the capability doc for the agent + tool surface this uses
|
|
568
|
-
- `.agent/workflows.md` — agent-driven workflow primitive; `workflow_ai_agents`,
|
|
569
|
-
`context_mapping_config`, agent-output decision interpolation
|
|
570
|
-
- `integration-tests/tests/subscription/agent-lead-triage.test.ts` — the
|
|
571
|
-
anchor green test these snippets are lifted from
|