@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,665 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
title: Score inbound leads into four bands via an LLM agent and route each band to a tailored SMS
|
|
3
|
-
summary: End-to-end agent-driven workflow — a chat-completion LLM agent classifies each lead submission into hot/warm/cold/spam, the workflow's decision interpolates the agent's band, four SMS actions (one per band) fan out, and the run is verified row-by-row so every WEL carries exactly one matched action and three skipped.
|
|
4
|
-
industry: foundation
|
|
5
|
-
slug: score-leads-with-llm-categorization
|
|
6
|
-
vitest_source:
|
|
7
|
-
- integration-tests/tests/foundation/agent-driven-workflow.test.ts
|
|
8
|
-
- integration-tests/tests/foundation/generic-tables.test.ts
|
|
9
|
-
- integration-tests/tests/foundation/tools.test.ts
|
|
10
|
-
- integration-tests/tests/foundation/bootstrap.test.ts
|
|
11
|
-
status: green
|
|
12
|
-
---
|
|
13
|
-
|
|
14
|
-
# Problem
|
|
15
|
-
|
|
16
|
-
The previous foundation cookbook (`birthday-greeting-sms-trigger`)
|
|
17
|
-
demonstrated a static decision: the workflow's decision_config was a
|
|
18
|
-
literal Liquid array naming one fixed decision key, and a single SMS
|
|
19
|
-
action fired for every matched row. Many real outbound engagements
|
|
20
|
-
need the opposite: the action that fires depends on what the row
|
|
21
|
-
says, not just on the fact that it matched the filter.
|
|
22
|
-
|
|
23
|
-
A B2B lead-scoring scenario is the canonical shape. A submission on a
|
|
24
|
-
contact-us form might be a hot prospect ready to buy, a warm explorer
|
|
25
|
-
who needs nurture, a cold tire-kicker, or outright spam. The same
|
|
26
|
-
workflow should pick the right tone of SMS for each. The
|
|
27
|
-
classification is a language task, not a rules task — applying a
|
|
28
|
-
hand-coded keyword filter would either over-fit the training set or
|
|
29
|
-
miss every variation of phrasing.
|
|
30
|
-
|
|
31
|
-
The Alvera platform's agent-driven workflow primitive flips the
|
|
32
|
-
decision step into an LLM enrichment plus a Liquid interpolation. An
|
|
33
|
-
AI agent receives the row's free-text via a context-mapping template,
|
|
34
|
-
returns a JSON object whose `band` field is one of the four enum
|
|
35
|
-
values, and the workflow's decision_config interpolates that band
|
|
36
|
-
into a one-element decision array
|
|
37
|
-
(`["{{ additional_context["agent-slug"].band }}"]`). Four SMS actions
|
|
38
|
-
are registered against the workflow, one per band; the agent's output
|
|
39
|
-
picks which one runs.
|
|
40
|
-
|
|
41
|
-
This cookbook walks the **whole** scenario: it provisions the custom
|
|
42
|
-
dataset, agent, and workflow, ingests lead rows through the
|
|
43
|
-
generic-table's auto-provisioned default DAC, runs the workflow so the
|
|
44
|
-
agent classifies each row, and verifies the routing row-by-row —
|
|
45
|
-
every Workflow Execution Log carries exactly one matched action and
|
|
46
|
-
three skipped, proving the agent's band actually steered the fan-out.
|
|
47
|
-
|
|
48
|
-
The scenario is anchored to
|
|
49
|
-
`platform/integration-tests/tests/foundation/agent-driven-workflow.test.ts`
|
|
50
|
-
— a green end-to-end test (§1–§5). The setup file `_setup/foundation.md`
|
|
51
|
-
already provisioned the tenant + datalake + tenant-scoped client; this
|
|
52
|
-
cookbook starts from there.
|
|
53
|
-
|
|
54
|
-
# Composition
|
|
55
|
-
|
|
56
|
-
| Resource provisioned | Owner |
|
|
57
|
-
|----------------------------------|-------------|
|
|
58
|
-
| Lead Submissions generic table | build |
|
|
59
|
-
| SMS tool (SNS-backed) | build |
|
|
60
|
-
| LLM tool (Ollama chat completion)| build |
|
|
61
|
-
| Score Lead AI agent | build |
|
|
62
|
-
| Score Lead workflow | build |
|
|
63
|
-
|
|
64
|
-
The generic table's **default DAC** — auto-provisioned by the
|
|
65
|
-
platform when the generic table is created — is reused for ingestion;
|
|
66
|
-
no separate data source / tool / interop contract / DAC is created.
|
|
67
|
-
The setup file `_setup/foundation.md` already provisioned the tenant +
|
|
68
|
-
datalake + tenant-scoped client; this cookbook starts from there.
|
|
69
|
-
|
|
70
|
-
# Walkthrough
|
|
71
|
-
|
|
72
|
-
## 001 — create the Lead Submissions generic table
|
|
73
|
-
|
|
74
|
-
The agent-driven workflow targets a Generic Table dataset
|
|
75
|
-
(`dataset_type: 'generic_table'`) rather than a regulated domain
|
|
76
|
-
entity, because lead-capture form rows do not fit a canonical industry
|
|
77
|
-
entity. The table's columns mirror the shape an Airtable/Google-Sheets
|
|
78
|
-
export would produce; the `message` and `lead_source` columns are what
|
|
79
|
-
the agent reads, and `band` is what the agent eventually writes back
|
|
80
|
-
in production. The server-derived `name` is captured — the workflow
|
|
81
|
-
run in §009 addresses the regulated table as `regulated_<name>`.
|
|
82
|
-
|
|
83
|
-
```typescript
|
|
84
|
-
const genericTableResp = await api.genericTables.create(tenantSlug, datalakeSlug, {
|
|
85
|
-
title: `Cookbook Lead Submissions ${runSuffix}`,
|
|
86
|
-
description: 'Inbound lead-capture form rows the Score Lead agent classifies.',
|
|
87
|
-
columns: [
|
|
88
|
-
{
|
|
89
|
-
name: 'submission_id',
|
|
90
|
-
title: 'Submission ID',
|
|
91
|
-
type: 'string',
|
|
92
|
-
description: 'Vendor-supplied unique submission id',
|
|
93
|
-
is_unique: true,
|
|
94
|
-
privacy_requirement: 'none',
|
|
95
|
-
},
|
|
96
|
-
{
|
|
97
|
-
name: 'name',
|
|
98
|
-
title: 'Name',
|
|
99
|
-
type: 'string',
|
|
100
|
-
description: 'Submitter full name',
|
|
101
|
-
is_unique: false,
|
|
102
|
-
privacy_requirement: 'tokenize',
|
|
103
|
-
},
|
|
104
|
-
{
|
|
105
|
-
name: 'email',
|
|
106
|
-
title: 'Email',
|
|
107
|
-
type: 'string',
|
|
108
|
-
description: 'Submitter email — also the legal-entity identifier',
|
|
109
|
-
is_unique: false,
|
|
110
|
-
privacy_requirement: 'tokenize',
|
|
111
|
-
},
|
|
112
|
-
{
|
|
113
|
-
name: 'message',
|
|
114
|
-
title: 'Message',
|
|
115
|
-
type: 'string',
|
|
116
|
-
description: 'Free-text lead message — the agent classifies this',
|
|
117
|
-
is_unique: false,
|
|
118
|
-
privacy_requirement: 'none',
|
|
119
|
-
},
|
|
120
|
-
{
|
|
121
|
-
name: 'lead_source',
|
|
122
|
-
title: 'Lead Source',
|
|
123
|
-
type: 'string',
|
|
124
|
-
description: 'utm_source / sheet tab id',
|
|
125
|
-
is_unique: false,
|
|
126
|
-
privacy_requirement: 'none',
|
|
127
|
-
},
|
|
128
|
-
{
|
|
129
|
-
name: 'band',
|
|
130
|
-
title: 'Band',
|
|
131
|
-
type: 'string',
|
|
132
|
-
description: 'Lead-scoring band assigned by Score Lead agent',
|
|
133
|
-
is_unique: false,
|
|
134
|
-
privacy_requirement: 'none',
|
|
135
|
-
},
|
|
136
|
-
],
|
|
137
|
-
})
|
|
138
|
-
genericTableId = genericTableResp.data.id!
|
|
139
|
-
ctx.gtName = genericTableResp.data.name!
|
|
140
|
-
```
|
|
141
|
-
|
|
142
|
-
## 002 — create the SMS tool
|
|
143
|
-
|
|
144
|
-
A single SMS tool dispatches every band's outbound message; the
|
|
145
|
-
per-band SMS body lives on each workflow action, not on the tool. Same
|
|
146
|
-
SNS-backed LocalStack wiring as the birthday cookbook — `intent: 'sms'`
|
|
147
|
-
tags it for workflow-action use.
|
|
148
|
-
|
|
149
|
-
```typescript
|
|
150
|
-
const smsToolResp = await api.tools.create(tenantSlug, datalakeSlug, {
|
|
151
|
-
name: `Cookbook SMS Tool ${runSuffix}`,
|
|
152
|
-
description: 'SNS-backed SMS dispatcher for the Score Lead workflow, wired to LocalStack.',
|
|
153
|
-
intent: 'sms',
|
|
154
|
-
status: 'active',
|
|
155
|
-
datalake_id: ctx.datalakeId,
|
|
156
|
-
body: {
|
|
157
|
-
tool_body_type: 'sns',
|
|
158
|
-
auth_method: 'access_key',
|
|
159
|
-
region: 'us-east-1',
|
|
160
|
-
phone_number: '+15551234567',
|
|
161
|
-
endpoint_url: 'http://localhost:4566',
|
|
162
|
-
access_key_id: 'test',
|
|
163
|
-
secret_access_key: 'test',
|
|
164
|
-
},
|
|
165
|
-
})
|
|
166
|
-
toolId = smsToolResp.data.id!
|
|
167
|
-
```
|
|
168
|
-
|
|
169
|
-
## 003 — create the LLM tool
|
|
170
|
-
|
|
171
|
-
The Score Lead agent calls a chat-completion endpoint to classify each
|
|
172
|
-
row. The tool's `intent: 'llm_enrichment'` distinguishes it from the
|
|
173
|
-
SMS tool above. It is a **provider adapter**: `base_body` authors the
|
|
174
|
-
provider's request — here Ollama's native `/api/chat` shape with
|
|
175
|
-
`think: false` and a `format` schema so the model returns clean,
|
|
176
|
-
schema-constrained JSON — and `response_extractor` maps the provider's
|
|
177
|
-
envelope back to the canonical `{ output_json, … }` the platform reads.
|
|
178
|
-
The extractor's `output_schema` is required for an `llm_enrichment`
|
|
179
|
-
tool. The `api_key`/`auth_method` pair satisfies the REST tool's schema
|
|
180
|
-
even though Ollama ignores the header.
|
|
181
|
-
|
|
182
|
-
```typescript
|
|
183
|
-
const ENRICHMENT_OUTPUT_SCHEMA = {
|
|
184
|
-
type: 'object',
|
|
185
|
-
properties: {
|
|
186
|
-
output_json: {},
|
|
187
|
-
input_tokens: { type: ['integer', 'null'] },
|
|
188
|
-
output_tokens: { type: ['integer', 'null'] },
|
|
189
|
-
total_tokens: { type: ['integer', 'null'] },
|
|
190
|
-
explanation: { type: ['string', 'null'] },
|
|
191
|
-
},
|
|
192
|
-
required: ['output_json'],
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
const OLLAMA_BASE_BODY =
|
|
196
|
-
'{"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 }}}'
|
|
197
|
-
|
|
198
|
-
const OLLAMA_EXTRACTOR =
|
|
199
|
-
'{"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 }}}'
|
|
200
|
-
|
|
201
|
-
const llmToolResp = await api.tools.create(tenantSlug, datalakeSlug, {
|
|
202
|
-
name: `Cookbook LLM Tool ${runSuffix}`,
|
|
203
|
-
description: 'Ollama-backed chat-completion adapter for Score Lead classification.',
|
|
204
|
-
intent: 'llm_enrichment',
|
|
205
|
-
status: 'active',
|
|
206
|
-
datalake_id: ctx.datalakeId,
|
|
207
|
-
response_extractor: { type: 'custom', body: OLLAMA_EXTRACTOR, output_schema: ENRICHMENT_OUTPUT_SCHEMA },
|
|
208
|
-
body: {
|
|
209
|
-
tool_body_type: 'rest_api',
|
|
210
|
-
base_url: 'http://localhost:11434',
|
|
211
|
-
base_path: { type: 'custom', body: '/api/chat' },
|
|
212
|
-
auth_method: 'api_key',
|
|
213
|
-
api_key: 'stub-key',
|
|
214
|
-
api_key_name: 'Authorization',
|
|
215
|
-
api_key_location: 'header',
|
|
216
|
-
request_type: 'json',
|
|
217
|
-
response_type: 'json',
|
|
218
|
-
timeout_ms: 60_000,
|
|
219
|
-
base_body: { type: 'custom', body: OLLAMA_BASE_BODY },
|
|
220
|
-
},
|
|
221
|
-
})
|
|
222
|
-
ctx.llmToolId = llmToolResp.data.id!
|
|
223
|
-
```
|
|
224
|
-
|
|
225
|
-
## 004 — create the Score Lead AI agent
|
|
226
|
-
|
|
227
|
-
The agent binds three things: the model name (sent on every inference
|
|
228
|
-
call), an input schema the workflow's context-mapping must satisfy,
|
|
229
|
-
and a response schema the agent's output must match. The response
|
|
230
|
-
schema's `enum: ['hot','warm','cold','spam']` constraint is what
|
|
231
|
-
guards the downstream decision interpolation from emitting a band the
|
|
232
|
-
workflow has no SMS action for. `temperature: 0.0` removes sampling
|
|
233
|
-
noise so identical inputs classify identically. `data_access:
|
|
234
|
-
'unregulated'` says the agent only ever sees the tokenized projection
|
|
235
|
-
of each row, never the regulated identifiers.
|
|
236
|
-
|
|
237
|
-
```typescript
|
|
238
|
-
const SCORE_INPUT_SCHEMA = {
|
|
239
|
-
type: 'object',
|
|
240
|
-
properties: {
|
|
241
|
-
name: { type: 'string' },
|
|
242
|
-
message: { type: 'string' },
|
|
243
|
-
lead_source: { type: 'string' },
|
|
244
|
-
},
|
|
245
|
-
required: ['name', 'message'],
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
const SCORE_RESPONSE_SCHEMA = {
|
|
249
|
-
type: 'object',
|
|
250
|
-
properties: {
|
|
251
|
-
band: { type: 'string', enum: ['hot', 'warm', 'cold', 'spam'] },
|
|
252
|
-
},
|
|
253
|
-
required: ['band'],
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
const AGENT_PROMPT_BODY = `You are a B2B lead-scoring assistant. Classify the following lead's intent into EXACTLY ONE of these bands:
|
|
257
|
-
|
|
258
|
-
- "hot" — clear buying intent, decision maker, ready to engage
|
|
259
|
-
- "warm" — interested but exploratory, requires nurture
|
|
260
|
-
- "cold" — generic/lukewarm interest, low conversion signal
|
|
261
|
-
- "spam" — promotional, irrelevant, or low-quality submission
|
|
262
|
-
|
|
263
|
-
Lead name: {{ name }}
|
|
264
|
-
Lead message: {{ message }}
|
|
265
|
-
Lead source: {{ lead_source }}
|
|
266
|
-
|
|
267
|
-
Respond with a JSON object: {"band": "<one of hot|warm|cold|spam>"}`
|
|
268
|
-
|
|
269
|
-
const agentResp = await api.aiAgents.create(tenantSlug, datalakeSlug, {
|
|
270
|
-
name: `Cookbook Score Lead Agent ${runSuffix}`,
|
|
271
|
-
tool_id: ctx.llmToolId,
|
|
272
|
-
model: 'qwen3-vl:8b-instruct',
|
|
273
|
-
data_access: 'unregulated',
|
|
274
|
-
temperature: 0.0,
|
|
275
|
-
max_tokens: 1024,
|
|
276
|
-
enabled: true,
|
|
277
|
-
input_schema: SCORE_INPUT_SCHEMA,
|
|
278
|
-
llm_response_schema: SCORE_RESPONSE_SCHEMA,
|
|
279
|
-
prompt_config: { type: 'custom', body: AGENT_PROMPT_BODY },
|
|
280
|
-
})
|
|
281
|
-
aiAgentId = agentResp.data.id!
|
|
282
|
-
ctx.agentSlug = agentResp.data.slug!
|
|
283
|
-
```
|
|
284
|
-
|
|
285
|
-
## 005 — create the Score Lead workflow
|
|
286
|
-
|
|
287
|
-
The workflow has the standard shape — filter, decision, actions — but
|
|
288
|
-
two things distinguish it from the birthday cookbook's static
|
|
289
|
-
workflow. First, the Score Lead agent is **nested** in the workflow
|
|
290
|
-
create body (the `workflow_ai_agents` array — a `cast_assoc`, not a
|
|
291
|
-
separate attach call), binding it into the workflow's enrichment phase
|
|
292
|
-
with a Liquid `context_mapping_config` that projects each lead row's
|
|
293
|
-
fields into the agent's input schema.
|
|
294
|
-
Second, the `decision_config` body is a Liquid template that
|
|
295
|
-
interpolates the agent's `band` output (read from
|
|
296
|
-
`additional_context["<agent-slug>"].band`) into a single-element
|
|
297
|
-
decision array. The four `actions` are keyed `decision_key:
|
|
298
|
-
hot|warm|cold|spam`; whichever band the agent emits picks the action
|
|
299
|
-
that fires, leaving the other three `:skipped`.
|
|
300
|
-
|
|
301
|
-
Bracket access (`additional_context["..."]`) is required because the
|
|
302
|
-
agent's slug contains hyphens, which Solid's dot parser would
|
|
303
|
-
otherwise read as subtraction operators.
|
|
304
|
-
|
|
305
|
-
```typescript
|
|
306
|
-
const BANDS = ['hot', 'warm', 'cold', 'spam'] as const
|
|
307
|
-
|
|
308
|
-
const CONTEXT_MAPPING_BODY = JSON.stringify({
|
|
309
|
-
name: '{{ event_dataset.name }}',
|
|
310
|
-
message: '{{ event_dataset.message }}',
|
|
311
|
-
lead_source: '{{ event_dataset.lead_source }}',
|
|
312
|
-
})
|
|
313
|
-
|
|
314
|
-
const DECISION_CONFIG_BODY = `["{{ additional_context["${ctx.agentSlug}"].band }}"]`
|
|
315
|
-
const DECISION_OUTPUT_SCHEMA = { type: 'array', items: { type: 'string' } }
|
|
316
|
-
|
|
317
|
-
const workflowResp = await api.workflows.create(tenantSlug, datalakeSlug, {
|
|
318
|
-
name: `Cookbook Score Lead Workflow ${runSuffix}`,
|
|
319
|
-
description: 'Classifies lead_submissions into hot/warm/cold/spam via an LLM agent; one SMS action per band.',
|
|
320
|
-
dataset_type: 'generic_table',
|
|
321
|
-
generic_table_id: genericTableId,
|
|
322
|
-
skip_mdm_resolution: true,
|
|
323
|
-
status: 'live',
|
|
324
|
-
tags: ['leads', 'llm'],
|
|
325
|
-
filter_config: {
|
|
326
|
-
type: 'custom',
|
|
327
|
-
body: 'true',
|
|
328
|
-
output_schema: { type: 'boolean' },
|
|
329
|
-
},
|
|
330
|
-
decision_config: {
|
|
331
|
-
type: 'custom',
|
|
332
|
-
body: DECISION_CONFIG_BODY,
|
|
333
|
-
output_schema: DECISION_OUTPUT_SCHEMA,
|
|
334
|
-
},
|
|
335
|
-
actions: BANDS.map((band) => ({
|
|
336
|
-
decision_key: band,
|
|
337
|
-
action_type: 'sms',
|
|
338
|
-
tool_id: toolId,
|
|
339
|
-
position: 0,
|
|
340
|
-
trigger_template: 'now',
|
|
341
|
-
idempotency_template: `{{ subject_id }}-{{ action_id }}-${band}`,
|
|
342
|
-
tool_call: {
|
|
343
|
-
tool_call_type: 'sms_request',
|
|
344
|
-
to: { type: 'custom', body: '+15551234567' },
|
|
345
|
-
body: {
|
|
346
|
-
type: 'custom',
|
|
347
|
-
body: `Lead band [${band}]: {{ event_dataset.message }}`,
|
|
348
|
-
},
|
|
349
|
-
sms_type: 'transactional',
|
|
350
|
-
},
|
|
351
|
-
})),
|
|
352
|
-
// Nest the scoring agent inline. Workflow joins omit output_schema —
|
|
353
|
-
// the server pins it from the agent's input_schema.
|
|
354
|
-
workflow_ai_agents: [
|
|
355
|
-
{
|
|
356
|
-
ai_agent_id: aiAgentId,
|
|
357
|
-
position: 0,
|
|
358
|
-
context_mapping_config: { type: 'custom', body: CONTEXT_MAPPING_BODY },
|
|
359
|
-
},
|
|
360
|
-
],
|
|
361
|
-
})
|
|
362
|
-
workflowId = workflowResp.data.id!
|
|
363
|
-
ctx.workflowSlug = workflowResp.data.slug!
|
|
364
|
-
```
|
|
365
|
-
|
|
366
|
-
## 006 — find the generic table's auto-provisioned default DAC
|
|
367
|
-
|
|
368
|
-
Creating a generic table provisions two things automatically: an
|
|
369
|
-
identity interoperability contract and a default Data Activation
|
|
370
|
-
Client bound to it (name pattern
|
|
371
|
-
`<datalake> <gt_name> DataActivationClient`, `tool_call:
|
|
372
|
-
manual_upload`). No data source, tool, interop contract, or DAC needs
|
|
373
|
-
to be created by hand for plain row ingestion into the table — the
|
|
374
|
-
default DAC is found by GT-name convention in the DAC listing.
|
|
375
|
-
|
|
376
|
-
```typescript
|
|
377
|
-
const deadline = Date.now() + 30_000
|
|
378
|
-
let found: { slug?: string | null; name?: string | null } | undefined
|
|
379
|
-
while (Date.now() < deadline && !found) {
|
|
380
|
-
const { data } = await api.dataActivationClients.list(tenantSlug, datalakeSlug)
|
|
381
|
-
found = (data.data ?? []).find((d) => (d.name ?? '').includes(ctx.gtName))
|
|
382
|
-
if (!found) await new Promise((r) => setTimeout(r, 1_000))
|
|
383
|
-
}
|
|
384
|
-
if (!found?.slug) {
|
|
385
|
-
throw new Error(`no default DAC found for GT ${ctx.gtName} within 30s`)
|
|
386
|
-
}
|
|
387
|
-
ctx.dacSlug = found.slug
|
|
388
|
-
```
|
|
389
|
-
|
|
390
|
-
## 007 — ingest three lead rows through the default DAC
|
|
391
|
-
|
|
392
|
-
Three rows, ingested as inline JSON through the default DAC. Each
|
|
393
|
-
carries a deliberately distinct `message` — a clear buying signal, a
|
|
394
|
-
lukewarm browse, and obvious promotional spam — so the agent has
|
|
395
|
-
something unambiguous to classify. The `band` column is left unset on
|
|
396
|
-
ingest; it is the agent's job to assign one. Each ingest call returns
|
|
397
|
-
its own batch id; all three are pinned for the run scope in §009.
|
|
398
|
-
|
|
399
|
-
```typescript
|
|
400
|
-
const leadRows = [
|
|
401
|
-
{
|
|
402
|
-
submission_id: `LEAD-HOT-${runSuffix}`,
|
|
403
|
-
name: 'Dana Decisive',
|
|
404
|
-
email: `dana-${runSuffix}@example.com`,
|
|
405
|
-
message:
|
|
406
|
-
'We have budget approved and need to roll out to 500 seats this quarter — can we start onboarding next week?',
|
|
407
|
-
lead_source: 'cookbook_score_leads',
|
|
408
|
-
},
|
|
409
|
-
{
|
|
410
|
-
submission_id: `LEAD-COLD-${runSuffix}`,
|
|
411
|
-
name: 'Sam Browsing',
|
|
412
|
-
email: `sam-${runSuffix}@example.com`,
|
|
413
|
-
message: 'Just browsing — found your site via a blog post. No particular need right now.',
|
|
414
|
-
lead_source: 'cookbook_score_leads',
|
|
415
|
-
},
|
|
416
|
-
{
|
|
417
|
-
submission_id: `LEAD-SPAM-${runSuffix}`,
|
|
418
|
-
name: 'Promo Bot',
|
|
419
|
-
email: `promo-${runSuffix}@example.com`,
|
|
420
|
-
message:
|
|
421
|
-
'BUY CHEAP FOLLOWERS NOW!!! 90% off SEO backlinks, crypto giveaways, click here click here!!!',
|
|
422
|
-
lead_source: 'cookbook_score_leads',
|
|
423
|
-
},
|
|
424
|
-
]
|
|
425
|
-
|
|
426
|
-
const ingestResults = await Promise.all(
|
|
427
|
-
leadRows.map((row) =>
|
|
428
|
-
api.dataActivationClients.ingest(tenantSlug, datalakeSlug, ctx.dacSlug, { data: row }),
|
|
429
|
-
),
|
|
430
|
-
)
|
|
431
|
-
ctx.leadBatchIds = ingestResults.map((r) => r.data.batch_id!)
|
|
432
|
-
if (ctx.leadBatchIds.length !== 3) {
|
|
433
|
-
throw new Error(`expected 3 ingest batch ids, got ${ctx.leadBatchIds.length}`)
|
|
434
|
-
}
|
|
435
|
-
```
|
|
436
|
-
|
|
437
|
-
## 008 — wait for the three ingest batches to reach steady-state
|
|
438
|
-
|
|
439
|
-
Ingestion is async — the DAC enqueues per-row jobs that drain into the
|
|
440
|
-
generic table. Poll the DAC's activation logs until every batch shows
|
|
441
|
-
a row with `dataset_updated >= 1` (the lead row was upserted into the
|
|
442
|
-
table).
|
|
443
|
-
|
|
444
|
-
```typescript
|
|
445
|
-
const targetBatches = new Set<string>(ctx.leadBatchIds)
|
|
446
|
-
const deadline = Date.now() + 90_000
|
|
447
|
-
let greenCount = 0
|
|
448
|
-
while (Date.now() < deadline) {
|
|
449
|
-
const { data } = await api.dataActivationClients.logs.list(tenantSlug, datalakeSlug, ctx.dacSlug)
|
|
450
|
-
const green = new Set<string>()
|
|
451
|
-
for (const row of (data.data ?? []) as Array<Record<string, unknown>>) {
|
|
452
|
-
const b = row.batch_id
|
|
453
|
-
if (typeof b !== 'string' || !targetBatches.has(b)) continue
|
|
454
|
-
if (typeof row.dataset_updated !== 'number' || row.dataset_updated < 1) continue
|
|
455
|
-
green.add(b)
|
|
456
|
-
}
|
|
457
|
-
greenCount = green.size
|
|
458
|
-
if (greenCount === targetBatches.size) break
|
|
459
|
-
await new Promise((r) => setTimeout(r, 1_000))
|
|
460
|
-
}
|
|
461
|
-
if (greenCount !== targetBatches.size) {
|
|
462
|
-
throw new Error(`only ${greenCount}/3 lead-row batches reached steady-state within 90s`)
|
|
463
|
-
}
|
|
464
|
-
```
|
|
465
|
-
|
|
466
|
-
## 009 — run the workflow against the three lead rows
|
|
467
|
-
|
|
468
|
-
`workflows.run` triggers the full agent-driven pipeline per row:
|
|
469
|
-
filter → agent enrichment (the Ollama call that classifies the lead)
|
|
470
|
-
→ decision (the band interpolated into the decision array) → action
|
|
471
|
-
fan-out. The SQL where-clause scopes the run to exactly the three
|
|
472
|
-
batches §007 ingested (`regulated_<gt_name>` is the regulated mirror
|
|
473
|
-
the run-query addresses).
|
|
474
|
-
|
|
475
|
-
Unlike the birthday workflow, the Score Lead actions use
|
|
476
|
-
`trigger_template: 'now'`, so they dispatch immediately and the run
|
|
477
|
-
reaches a terminal status — poll `batchLogs.refresh` until it leaves
|
|
478
|
-
`:pending`. The window is generous because each row's enrichment is a
|
|
479
|
-
live LLM inference call.
|
|
480
|
-
|
|
481
|
-
```typescript
|
|
482
|
-
const regulatedTable = `regulated_${ctx.gtName}`
|
|
483
|
-
const batchList = (ctx.leadBatchIds as string[]).map((b) => `'${b}'`).join(', ')
|
|
484
|
-
|
|
485
|
-
const runResp = await api.workflows.run(tenantSlug, datalakeSlug, ctx.workflowSlug, {
|
|
486
|
-
sql_where_clause: `${regulatedTable}.batch_id IN (${batchList})`,
|
|
487
|
-
mode: 'live',
|
|
488
|
-
manual_override: true,
|
|
489
|
-
})
|
|
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!
|
|
495
|
-
|
|
496
|
-
const deadline = Date.now() + 240_000
|
|
497
|
-
let status: string | null = null
|
|
498
|
-
while (Date.now() < deadline) {
|
|
499
|
-
const { data: log } = await api.workflows.batchLogs.refresh(tenantSlug, datalakeSlug, ctx.workflowSlug, ctx.runLogId)
|
|
500
|
-
status = log.status ?? null
|
|
501
|
-
if (status && status !== 'pending') break
|
|
502
|
-
await new Promise((r) => setTimeout(r, 2_000))
|
|
503
|
-
}
|
|
504
|
-
if (status === 'failed') {
|
|
505
|
-
throw new Error('agent-driven workflow run reached :failed')
|
|
506
|
-
}
|
|
507
|
-
if (!status || status === 'pending') {
|
|
508
|
-
throw new Error('workflow run did not leave :pending within 240s')
|
|
509
|
-
}
|
|
510
|
-
```
|
|
511
|
-
|
|
512
|
-
## 010 — verify the agent's band steered the fan-out
|
|
513
|
-
|
|
514
|
-
This is the assertion the old shallow cookbook could never make,
|
|
515
|
-
because it never ran the workflow. Each lead row produced a Workflow
|
|
516
|
-
Execution Log. Every WEL has exactly **four** action execution logs —
|
|
517
|
-
one per band — and exactly **one** is matched (`:pending` or
|
|
518
|
-
`:completed`, the band the agent emitted) while the other **three**
|
|
519
|
-
are `:skipped`. A run where a WEL had two matched actions, or zero,
|
|
520
|
-
would mean the agent's output did not actually steer the decision;
|
|
521
|
-
this step fails loudly in that case.
|
|
522
|
-
|
|
523
|
-
```typescript
|
|
524
|
-
const { data: wfLogs } = await api.workflows.workflowLogs.list(tenantSlug, datalakeSlug, ctx.workflowSlug)
|
|
525
|
-
const ourWels = (wfLogs.data ?? []).filter(
|
|
526
|
-
(w) => (w as { batch_id?: string }).batch_id === ctx.runBatchId,
|
|
527
|
-
)
|
|
528
|
-
if (ourWels.length !== 3) {
|
|
529
|
-
throw new Error(`expected 3 WELs for the run, got ${ourWels.length}`)
|
|
530
|
-
}
|
|
531
|
-
|
|
532
|
-
for (const wel of ourWels) {
|
|
533
|
-
const welId = (wel as { id?: string }).id
|
|
534
|
-
const aels =
|
|
535
|
-
(wel as { action_execution_logs?: Array<{ status?: string }> }).action_execution_logs ?? []
|
|
536
|
-
if (aels.length !== 4) {
|
|
537
|
-
throw new Error(`WEL ${welId}: expected 4 AELs (one per band), got ${aels.length}`)
|
|
538
|
-
}
|
|
539
|
-
const byStatus: Record<string, number> = {}
|
|
540
|
-
for (const ael of aels) {
|
|
541
|
-
const st = ael.status ?? 'unknown'
|
|
542
|
-
byStatus[st] = (byStatus[st] ?? 0) + 1
|
|
543
|
-
}
|
|
544
|
-
const matched = (byStatus.pending ?? 0) + (byStatus.completed ?? 0)
|
|
545
|
-
if (matched !== 1) {
|
|
546
|
-
throw new Error(`WEL ${welId}: expected exactly 1 matched AEL, got ${matched} — ${JSON.stringify(byStatus)}`)
|
|
547
|
-
}
|
|
548
|
-
if ((byStatus.skipped ?? 0) !== 3) {
|
|
549
|
-
throw new Error(`WEL ${welId}: expected 3 :skipped AELs — ${JSON.stringify(byStatus)}`)
|
|
550
|
-
}
|
|
551
|
-
}
|
|
552
|
-
```
|
|
553
|
-
|
|
554
|
-
## 011 — write the integration test
|
|
555
|
-
|
|
556
|
-
End the build with a test you keep: re-read the workflow and prove the
|
|
557
|
-
pipeline still executes — without a side effect. `mode: 'dry_run'` with a
|
|
558
|
-
never-matching selection runs the FULL pipeline (selection → filter →
|
|
559
|
-
decision) and intercepts only the final action call, so no message
|
|
560
|
-
leaves, yet the acknowledgement proves the workflow is runnable. This
|
|
561
|
-
block runs live under `make validate-cookbook`.
|
|
562
|
-
|
|
563
|
-
```typescript
|
|
564
|
-
// Re-GET — the workflow must still be live, or nothing will run.
|
|
565
|
-
const { data: wfRow } = await api.workflows.get(tenantSlug, datalakeSlug, workflowId)
|
|
566
|
-
if (wfRow.status !== 'live') {
|
|
567
|
-
throw new Error(`workflow regressed from live: ${wfRow.status}`)
|
|
568
|
-
}
|
|
569
|
-
// Behavioural probe — a dry run against a selection no row can match:
|
|
570
|
-
// the pipeline executes end-to-end, the final action call is
|
|
571
|
-
// intercepted, and the acknowledgement carries the scheduled run id. The
|
|
572
|
-
// clause speaks this workflow's selection dialect: a GENERIC-TABLE
|
|
573
|
-
// dataset is addressed by its own columns (no `ra.` dataset alias —
|
|
574
|
-
// that alias exists only for system-dataset selections).
|
|
575
|
-
const { data: probeRun } = await api.workflows.run(tenantSlug, datalakeSlug, ctx.workflowSlug, {
|
|
576
|
-
sql_where_clause: "submission_id = 'test-never-matching-submission'",
|
|
577
|
-
mode: 'dry_run',
|
|
578
|
-
manual_override: false,
|
|
579
|
-
})
|
|
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')
|
|
584
|
-
}
|
|
585
|
-
```
|
|
586
|
-
|
|
587
|
-
If the probe fails in production, escalate with the run response as
|
|
588
|
-
evidence — don't flip the workflow's status or rewrite its configs to
|
|
589
|
-
chase the error.
|
|
590
|
-
|
|
591
|
-
# Branches
|
|
592
|
-
|
|
593
|
-
- **The filter is permissive** — `filter_config.body: 'true'` passes
|
|
594
|
-
every row, so all three lead rows reach the agent. The interesting
|
|
595
|
-
routing happens at the decision step, not the filter. A production
|
|
596
|
-
scoring workflow might gate on `lead_source` or a not-yet-contacted
|
|
597
|
-
flag in the filter; that polarity is covered by the standard-workflow
|
|
598
|
-
cookbook.
|
|
599
|
-
- **Agent emits an out-of-enum band** — the LLM response schema's
|
|
600
|
-
`enum: ['hot','warm','cold','spam']` constraint guards the decision
|
|
601
|
-
interpolation. If the model returned an unexpected string the
|
|
602
|
-
workflow row's execution log would land `:failed`. The anchor vitest
|
|
603
|
-
treats that as a model-quality failure rather than a platform bug;
|
|
604
|
-
production deployments either tighten the prompt or add a `default`
|
|
605
|
-
SMS action as a fallback band.
|
|
606
|
-
- **Per-band SMS template variation** — each action in the `actions`
|
|
607
|
-
array carries its own `tool_call.body.body` Liquid template. The
|
|
608
|
-
cookbook renders the same body shape with the band name interpolated
|
|
609
|
-
for clarity; production scenarios would author distinct messages per
|
|
610
|
-
band (a thank-you for hot, a nurture link for warm, an
|
|
611
|
-
unsubscribe-friendly close for cold, no message at all for spam by
|
|
612
|
-
omitting that action).
|
|
613
|
-
|
|
614
|
-
# Rollback
|
|
615
|
-
|
|
616
|
-
The cookbook doctest harness does not currently tear down created
|
|
617
|
-
resources. The `_setup/foundation.md` setup file's runSuffix-scoped
|
|
618
|
-
tenant / datalake / user names mean each run is naturally isolated;
|
|
619
|
-
the seeded local DB is cheap to reset (`mix ecto.reset` on the
|
|
620
|
-
platform repo).
|
|
621
|
-
|
|
622
|
-
# Outcome
|
|
623
|
-
|
|
624
|
-
After this cookbook's ten steps run green:
|
|
625
|
-
|
|
626
|
-
- A foundation tenant exists with a foundation-domain datalake
|
|
627
|
-
- A Lead Submissions Generic Table, an SMS tool, a chat-completion LLM
|
|
628
|
-
tool, a Score Lead AI agent (qwen3-vl:8b-instruct, four-band response schema),
|
|
629
|
-
and a Score Lead agent-driven workflow are all registered
|
|
630
|
-
- Three lead rows have been ingested through the table's
|
|
631
|
-
auto-provisioned default DAC
|
|
632
|
-
- Running the workflow drove each row through the agent: the LLM
|
|
633
|
-
classified the lead, the decision interpolated the band, and the
|
|
634
|
-
matching band's SMS action fired
|
|
635
|
-
- The routing is verified row-by-row — every Workflow Execution Log
|
|
636
|
-
carries exactly one matched action and three `:skipped`, proving the
|
|
637
|
-
agent's output actually steered the fan-out
|
|
638
|
-
|
|
639
|
-
The business outcome — inbound leads classified by an LLM and routed
|
|
640
|
-
to band-specific outreach — is demonstrated end-to-end, not merely
|
|
641
|
-
provisioned.
|
|
642
|
-
|
|
643
|
-
# See also
|
|
644
|
-
|
|
645
|
-
- `_setup/foundation.md` — the inlined bootstrap that provisions the
|
|
646
|
-
tenant + datalake this cookbook starts from
|
|
647
|
-
- `birthday-greeting-sms-trigger.md` — the static-decision counterpart
|
|
648
|
-
in foundation; same SMS wiring, full data-activation chain, no agent
|
|
649
|
-
- `.agent/tools.md` — SMS and chat-completion tool body shapes; intent
|
|
650
|
-
classification
|
|
651
|
-
- `.agent/ai_agents.md` — AI agent registration shape; input + response
|
|
652
|
-
schemas; prompt config
|
|
653
|
-
- `.agent/workflows.md` — standard and agent-driven workflow
|
|
654
|
-
primitives; `ai_agents`, `context_mapping_config`, and agent-output
|
|
655
|
-
decision interpolation
|
|
656
|
-
- `.agent/generic_tables.md` — generic-table creation and the
|
|
657
|
-
auto-provisioned identity contract + default DAC
|
|
658
|
-
- `.agent/ai_sandbox.md` — bounds on the Liquid sandbox the
|
|
659
|
-
context-mapping and decision templates execute inside
|
|
660
|
-
- `integration-tests/tests/foundation/agent-driven-workflow.test.ts` —
|
|
661
|
-
the anchor green test (§1–§5) these snippets are lifted from
|
|
662
|
-
- `integration-tests/tests/foundation/generic-tables.test.ts` — §2 the
|
|
663
|
-
Generic Table create snippet is lifted from
|
|
664
|
-
- `integration-tests/tests/foundation/tools.test.ts` — the SMS tool
|
|
665
|
-
create snippet is lifted from
|