@alvera-ai/platform-sdk 0.10.0-rc.8 → 0.11.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 +499 -0
- package/.agent/account_management.md +456 -0
- package/.agent/action_status_updaters.md +264 -0
- package/.agent/ai_agents.md +462 -0
- package/.agent/ai_sandbox.md +265 -0
- package/.agent/async.md +112 -0
- package/.agent/connected_apps.md +408 -0
- package/.agent/cookbook/_fixtures/README.md +106 -0
- package/.agent/cookbook/_fixtures/accounts_receivable/_customers_accounts_receivable_customer.liquid +32 -0
- package/.agent/cookbook/_fixtures/accounts_receivable/_customers_accounts_receivable_mdm.liquid +20 -0
- package/.agent/cookbook/_fixtures/accounts_receivable/stripe_customers_batch1.csv +5 -0
- package/.agent/cookbook/_fixtures/foundation/_lead_submissions_foundation_generic_table.liquid +33 -0
- package/.agent/cookbook/_fixtures/foundation/_lead_submissions_foundation_legal_entity.liquid +88 -0
- package/.agent/cookbook/_fixtures/foundation/_lead_submissions_foundation_mdm.liquid +48 -0
- package/.agent/cookbook/_fixtures/healthcare/_cahps_appointments_healthcare_appointment.liquid +47 -0
- package/.agent/cookbook/_fixtures/healthcare/_cahps_appointments_healthcare_mdm.liquid +24 -0
- package/.agent/cookbook/_fixtures/healthcare/_cahps_appointments_healthcare_patient.liquid +38 -0
- package/.agent/cookbook/_fixtures/healthcare/memorandum-of-association-01.png +0 -0
- package/.agent/cookbook/_fixtures/healthcare/sample_two_page.pdf +43 -0
- package/.agent/cookbook/_fixtures/payment_risk/_compliance_screenings_payment_risk_compliance_screening.liquid +59 -0
- package/.agent/cookbook/_fixtures/payment_risk/_compliance_screenings_payment_risk_mdm.liquid +36 -0
- package/.agent/cookbook/_fixtures/payment_risk/_payment_accounts_payment_risk_mdm.liquid +30 -0
- package/.agent/cookbook/_fixtures/payment_risk/_payment_accounts_payment_risk_payment_account.liquid +55 -0
- package/.agent/cookbook/_setup/accounts_receivable.md +282 -0
- package/.agent/cookbook/_setup/foundation.md +277 -0
- package/.agent/cookbook/_setup/healthcare.md +279 -0
- package/.agent/cookbook/_setup/payment_risk.md +283 -0
- package/.agent/cookbook/action-status-updaters.md +212 -0
- package/.agent/cookbook/ai-agent-invoke.md +243 -0
- package/.agent/cookbook/appointment-review-sms-workflow.md +761 -0
- package/.agent/cookbook/birthday-greeting-sms-trigger.md +656 -0
- package/.agent/cookbook/bulk-ingest.md +254 -0
- package/.agent/cookbook/contact-us-triage-with-llm.md +622 -0
- package/.agent/cookbook/custom-tables.md +201 -0
- package/.agent/cookbook/dunning-sms-for-delinquent.md +619 -0
- package/.agent/cookbook/invite-team.md +194 -0
- package/.agent/cookbook/kyc-notification-on-account-activation.md +619 -0
- package/.agent/cookbook/rest-fetch.md +246 -0
- package/.agent/cookbook/sanctions-screening-with-agent-review.md +733 -0
- package/.agent/cookbook/score-leads-with-llm-categorization.md +624 -0
- package/.agent/cookbook/system-templates.md +129 -0
- package/.agent/cookbook/triage-prospects-by-priority.md +533 -0
- package/.agent/cookbook/welcome-sms-for-customers.md +607 -0
- package/.agent/data_activation_clients.md +559 -0
- package/.agent/data_sources.md +235 -0
- package/.agent/datalakes.md +714 -0
- package/.agent/debugging.md +137 -0
- package/.agent/errors.md +190 -0
- package/.agent/generic_tables.md +351 -0
- package/.agent/interoperability_contracts.md +417 -0
- package/.agent/mdm.md +293 -0
- package/.agent/mutations.md +126 -0
- package/.agent/templates.md +98 -0
- package/.agent/tool-call-configs.md +90 -0
- package/.agent/tools.md +547 -0
- package/.agent/type_naming.md +129 -0
- package/.agent/workflows.md +617 -0
- package/README.md +178 -0
- package/dist/bin/platform-sdk.d.mts +1 -0
- package/dist/bin/platform-sdk.mjs +106 -0
- package/dist/bin/platform-sdk.mjs.map +1 -0
- package/dist/index.d.mts +1310 -44116
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +1194 -7344
- package/dist/index.mjs.map +1 -1
- package/package.json +19 -10
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "Capability: discover the platform's built-in row-mapping templates"
|
|
3
|
+
summary: A capability walk for system-template discovery. List the Liquid templates the platform ships for your datalake's data domain (`api.templates.systemTemplates`), read the human-readable catalog (`api.templates.metadata`), and pull one template's detail (`api.templates.metadataDetails`) — so you can start an interoperability contract from a platform template instead of hand-writing Liquid. Read-only; creates nothing.
|
|
4
|
+
industry: accounts_receivable
|
|
5
|
+
slug: system-templates
|
|
6
|
+
vitest_source:
|
|
7
|
+
- integration-tests/tests/accounts_receivable/system-templates.test.ts
|
|
8
|
+
- integration-tests/tests/accounts_receivable/bootstrap.test.ts
|
|
9
|
+
status: green
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
# Capability
|
|
13
|
+
|
|
14
|
+
**What you get:** the list of row-mapping Liquid templates the platform already
|
|
15
|
+
ships for your datalake's data domain, so an interoperability contract can start
|
|
16
|
+
from a vetted template instead of hand-written Liquid.
|
|
17
|
+
|
|
18
|
+
`api.templates.*` is read-only discovery. Three calls:
|
|
19
|
+
|
|
20
|
+
- `systemTemplates(tenantSlug, datalakeSlug)` — the machine-readable list:
|
|
21
|
+
`{ data: [{ path, content, output_schema }] }`. The list is **filtered to your
|
|
22
|
+
datalake's data domain** plus globals — a payment-risk or healthcare template
|
|
23
|
+
never appears in an accounts-receivable datalake.
|
|
24
|
+
- `metadata(tenantSlug, datalakeSlug)` — the same catalog as a markdown summary.
|
|
25
|
+
- `metadataDetails(tenantSlug, datalakeSlug, basename, intent)` — one template's
|
|
26
|
+
detail by basename + intent.
|
|
27
|
+
|
|
28
|
+
This is **global** to every data domain — only the anchor template paths below
|
|
29
|
+
differ by domain. See `templates.md` for the reference, and
|
|
30
|
+
`interoperability_contracts.md` for how a template becomes a contract.
|
|
31
|
+
|
|
32
|
+
# Walkthrough
|
|
33
|
+
|
|
34
|
+
The `_setup/accounts_receivable.md` bootstrap already provisioned the tenant +
|
|
35
|
+
datalake and left `api`, `tenantSlug`, and `datalakeSlug` populated. This walk
|
|
36
|
+
starts from there.
|
|
37
|
+
|
|
38
|
+
## 001 — list the system templates in scope for this datalake
|
|
39
|
+
|
|
40
|
+
`systemTemplates` returns every platform-shipped Liquid template visible to this
|
|
41
|
+
datalake: the templates for its data domain (here, `accounts_receivable`) plus
|
|
42
|
+
domain-agnostic globals (e.g. the SNS delivery-status poller). Each entry has a
|
|
43
|
+
`path`, the Liquid `content`, and an `output_schema` (`null` when the template
|
|
44
|
+
has no companion schema). The list is domain-scoped — templates for other data
|
|
45
|
+
domains (`healthcare`, `payment_risk`, `foundation`, …) are filtered out, so you
|
|
46
|
+
only see what is usable here.
|
|
47
|
+
|
|
48
|
+
```typescript
|
|
49
|
+
const { data: templates } = await api.templates.systemTemplates(tenantSlug, datalakeSlug)
|
|
50
|
+
|
|
51
|
+
const paths = (templates.data ?? []).map((t) => t.path)
|
|
52
|
+
if (paths.length === 0) {
|
|
53
|
+
throw new Error('expected the platform to ship at least one system template')
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// The accounts-receivable anchor template is present...
|
|
57
|
+
const arAnchor = 'data_activation/interoperability/accounts_receivable/stripe/customers_accounts_receivable_customer'
|
|
58
|
+
if (!paths.some((p) => p.includes(arAnchor))) {
|
|
59
|
+
throw new Error(`expected the AR customer template (${arAnchor}) in the list`)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ...and templates for other data domains are filtered out of an AR datalake.
|
|
63
|
+
const otherDomains = ['healthcare', 'payment_risk', 'foundation', 'core_banking', 'service_commerce', 'trading']
|
|
64
|
+
const leaked = paths.filter((p) => otherDomains.some((d) => p.split('/').includes(d)))
|
|
65
|
+
if (leaked.length > 0) {
|
|
66
|
+
throw new Error(`cross-domain templates leaked into an AR datalake: ${leaked.join(', ')}`)
|
|
67
|
+
}
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## 002 — read the catalog as markdown
|
|
71
|
+
|
|
72
|
+
`metadata` returns the same catalog as a markdown document — the form an agent
|
|
73
|
+
reads to decide which template to start from. It names the in-scope templates,
|
|
74
|
+
including the AR customer template by basename.
|
|
75
|
+
|
|
76
|
+
```typescript
|
|
77
|
+
const { data: catalog } = await api.templates.metadata(tenantSlug, datalakeSlug)
|
|
78
|
+
if (typeof catalog !== 'string' || catalog.length === 0) {
|
|
79
|
+
throw new Error('expected a non-empty markdown template catalog')
|
|
80
|
+
}
|
|
81
|
+
if (!catalog.includes('customers_accounts_receivable_customer')) {
|
|
82
|
+
throw new Error('expected the AR customer template named in the catalog markdown')
|
|
83
|
+
}
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## 003 — read one template's detail by basename + intent
|
|
87
|
+
|
|
88
|
+
`metadataDetails` pulls a single template's detail. Address it by its
|
|
89
|
+
**basename** (the template name without the path) and its **intent** (the path
|
|
90
|
+
prefix joined with underscores — here `data_activation_interoperability`). The
|
|
91
|
+
result is a markdown body you can show an operator or feed an agent before it
|
|
92
|
+
copies the template into a contract's `template_config`.
|
|
93
|
+
|
|
94
|
+
```typescript
|
|
95
|
+
const { data: detail } = await api.templates.metadataDetails(
|
|
96
|
+
tenantSlug,
|
|
97
|
+
datalakeSlug,
|
|
98
|
+
'customers_accounts_receivable_customer',
|
|
99
|
+
'data_activation_interoperability',
|
|
100
|
+
)
|
|
101
|
+
if (typeof detail !== 'string' || detail.length === 0) {
|
|
102
|
+
throw new Error('expected a non-empty markdown detail for the AR customer template')
|
|
103
|
+
}
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
# Gotchas
|
|
107
|
+
|
|
108
|
+
- **The list is domain-scoped, not global.** `systemTemplates` only returns
|
|
109
|
+
templates for this datalake's data domain plus globals. Calling it on a
|
|
110
|
+
healthcare datalake returns the healthcare templates, never the AR ones —
|
|
111
|
+
there is no "all templates" view.
|
|
112
|
+
- **`output_schema` is often `null`.** A template carries a non-null
|
|
113
|
+
`output_schema` only when it ships a companion schema; most interop templates
|
|
114
|
+
return `null`. Don't treat `null` as an error.
|
|
115
|
+
- **`metadataDetails` keys on basename + intent, not the full path.** Pass the
|
|
116
|
+
bare template name and the underscore-joined path prefix (the "intent"), not
|
|
117
|
+
the slash path from `systemTemplates`.
|
|
118
|
+
- **Discovery only.** None of these calls create or mutate anything — they read
|
|
119
|
+
what the platform ships. To turn a template into a live mapping, copy its
|
|
120
|
+
`content` into an `interoperabilityContracts.create(...)` `template_config`
|
|
121
|
+
(see `interoperability_contracts.md`).
|
|
122
|
+
|
|
123
|
+
# See also
|
|
124
|
+
|
|
125
|
+
- `templates.md` — the `api.templates.*` reference (wire shapes, intents)
|
|
126
|
+
- `interoperability_contracts.md` — how a template becomes a live row mapping
|
|
127
|
+
- `_setup/accounts_receivable.md` — the bootstrap this walk starts from
|
|
128
|
+
- `integration-tests/tests/accounts_receivable/system-templates.test.ts` — the
|
|
129
|
+
green test these calls are lifted from
|
|
@@ -0,0 +1,533 @@
|
|
|
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 accounts-receivable 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: accounts_receivable
|
|
5
|
+
slug: triage-prospects-by-priority
|
|
6
|
+
vitest_source:
|
|
7
|
+
- integration-tests/tests/accounts_receivable/agent-lead-triage.test.ts
|
|
8
|
+
- integration-tests/tests/accounts_receivable/interoperability-contracts.test.ts
|
|
9
|
+
- integration-tests/tests/accounts_receivable/run-dac-single.test.ts
|
|
10
|
+
- integration-tests/tests/accounts_receivable/create-dac.test.ts
|
|
11
|
+
- integration-tests/tests/accounts_receivable/tools.test.ts
|
|
12
|
+
- integration-tests/tests/accounts_receivable/data-sources.test.ts
|
|
13
|
+
- integration-tests/tests/accounts_receivable/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/accounts_receivable/agent-lead-triage.test.ts`.
|
|
44
|
+
The setup file `_setup/accounts_receivable.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!, 'accounts_receivable/_customers_accounts_receivable_customer.liquid'),
|
|
234
|
+
'utf8',
|
|
235
|
+
)
|
|
236
|
+
const mdmTemplate = readFileSync(
|
|
237
|
+
join(process.env.COOKBOOK_FIXTURES_DIR!, 'accounts_receivable/_customers_accounts_receivable_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 → AccountsReceivable 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
|
+
filter_config: {
|
|
297
|
+
type: 'custom',
|
|
298
|
+
body: '{% if customer.phone and customer.tax_id %}true{% endif %}',
|
|
299
|
+
output_schema: { type: 'boolean' },
|
|
300
|
+
},
|
|
301
|
+
decision_config: {
|
|
302
|
+
type: 'custom',
|
|
303
|
+
body: DECISION_CONFIG_BODY,
|
|
304
|
+
output_schema: DECISION_OUTPUT_SCHEMA,
|
|
305
|
+
},
|
|
306
|
+
actions: BANDS.map((band) => ({
|
|
307
|
+
decision_key: band,
|
|
308
|
+
action_type: 'sms',
|
|
309
|
+
tool_id: toolId,
|
|
310
|
+
position: 0,
|
|
311
|
+
trigger_template: 'now',
|
|
312
|
+
idempotency_template: `{{ customer_id }}-${band}-{{ "" | uuid }}`,
|
|
313
|
+
tool_call: {
|
|
314
|
+
tool_call_type: 'sms_request',
|
|
315
|
+
to: { type: 'custom', body: '{{ mdm_output.regulated_customer.phone }}' },
|
|
316
|
+
body: {
|
|
317
|
+
type: 'custom',
|
|
318
|
+
body: `[${band}] {{ mdm_output.regulated_customer.name }}, a note about account {{ customer.customer_number }}.`,
|
|
319
|
+
},
|
|
320
|
+
sms_type: 'transactional',
|
|
321
|
+
},
|
|
322
|
+
})),
|
|
323
|
+
// Nest the triage agent inline. Workflow joins omit output_schema — the
|
|
324
|
+
// server pins it from the agent's input_schema.
|
|
325
|
+
workflow_ai_agents: [
|
|
326
|
+
{
|
|
327
|
+
ai_agent_id: aiAgentId,
|
|
328
|
+
position: 0,
|
|
329
|
+
context_mapping_config: { type: 'custom', body: CONTEXT_MAPPING_BODY },
|
|
330
|
+
},
|
|
331
|
+
],
|
|
332
|
+
})
|
|
333
|
+
workflowId = workflowResp.data.id!
|
|
334
|
+
ctx.workflowSlug = workflowResp.data.slug!
|
|
335
|
+
```
|
|
336
|
+
|
|
337
|
+
## 009 — ingest two Stripe customers
|
|
338
|
+
|
|
339
|
+
Two rows through the manual-upload DAC: one enterprise account, one individual.
|
|
340
|
+
Both carry a phone and a tax id so both pass the filter and reach the agent;
|
|
341
|
+
they differ in `customer_type`, which is exactly what the agent bands on. Each
|
|
342
|
+
ingest returns its own batch id; both are pinned for the run scope.
|
|
343
|
+
|
|
344
|
+
```typescript
|
|
345
|
+
const customerRows = [
|
|
346
|
+
{
|
|
347
|
+
customer_number: `CUS-ENT-${runSuffix}`,
|
|
348
|
+
customer_type: 'enterprise',
|
|
349
|
+
status: 'contracted',
|
|
350
|
+
name: 'Pinnacle Financial Group',
|
|
351
|
+
email: `ap-${runSuffix}@pinnacle.example`,
|
|
352
|
+
phone: '+12125559000',
|
|
353
|
+
tax_id: '84-2156789',
|
|
354
|
+
currency: 'usd',
|
|
355
|
+
source_uri: 'stripe.example.com',
|
|
356
|
+
},
|
|
357
|
+
{
|
|
358
|
+
customer_number: `CUS-IND-${runSuffix}`,
|
|
359
|
+
customer_type: 'individual',
|
|
360
|
+
status: 'contracted',
|
|
361
|
+
name: 'James Whitfield',
|
|
362
|
+
email: `james-${runSuffix}@example.com`,
|
|
363
|
+
phone: '+12025551001',
|
|
364
|
+
tax_id: '123-45-6789',
|
|
365
|
+
currency: 'usd',
|
|
366
|
+
source_uri: 'stripe.example.com',
|
|
367
|
+
},
|
|
368
|
+
]
|
|
369
|
+
|
|
370
|
+
const ingestResults = await Promise.all(
|
|
371
|
+
customerRows.map((row) =>
|
|
372
|
+
api.dataActivationClients.ingest(tenantSlug, datalakeSlug, ctx.dacSlug, { data: row }),
|
|
373
|
+
),
|
|
374
|
+
)
|
|
375
|
+
ctx.customerBatchIds = ingestResults.map((r) => r.data.batch_id!)
|
|
376
|
+
if (ctx.customerBatchIds.length !== 2) {
|
|
377
|
+
throw new Error(`expected 2 ingest batch ids, got ${ctx.customerBatchIds.length}`)
|
|
378
|
+
}
|
|
379
|
+
```
|
|
380
|
+
|
|
381
|
+
## 010 — wait for the two ingest batches to reach steady-state
|
|
382
|
+
|
|
383
|
+
Ingestion is async — poll the DAC's activation logs until both batches show a
|
|
384
|
+
customer row was upserted (`rows_ingested >= 1` and a non-empty `output_files`).
|
|
385
|
+
|
|
386
|
+
```typescript
|
|
387
|
+
const targetBatches = new Set<string>(ctx.customerBatchIds)
|
|
388
|
+
const deadline = Date.now() + 90_000
|
|
389
|
+
let greenCount = 0
|
|
390
|
+
while (Date.now() < deadline) {
|
|
391
|
+
const { data } = await api.dataActivationClients.logs.list(tenantSlug, datalakeSlug, ctx.dacSlug)
|
|
392
|
+
const green = new Set<string>()
|
|
393
|
+
for (const row of (data.data ?? []) as Array<Record<string, unknown>>) {
|
|
394
|
+
const b = row.batch_id
|
|
395
|
+
if (typeof b !== 'string' || !targetBatches.has(b)) continue
|
|
396
|
+
if (typeof row.rows_ingested !== 'number' || row.rows_ingested < 1) continue
|
|
397
|
+
if (!Array.isArray(row.output_files) || row.output_files.length === 0) continue
|
|
398
|
+
green.add(b)
|
|
399
|
+
}
|
|
400
|
+
greenCount = green.size
|
|
401
|
+
if (greenCount === targetBatches.size) break
|
|
402
|
+
await new Promise((r) => setTimeout(r, 1_000))
|
|
403
|
+
}
|
|
404
|
+
if (greenCount !== targetBatches.size) {
|
|
405
|
+
throw new Error(`only ${greenCount}/2 customer batches reached steady-state within 90s`)
|
|
406
|
+
}
|
|
407
|
+
```
|
|
408
|
+
|
|
409
|
+
## 011 — run the workflow against the two customers
|
|
410
|
+
|
|
411
|
+
`workflows.run` drives the full agent-driven pipeline per row: filter → agent
|
|
412
|
+
enrichment (the inference call that bands the customer) → decision (the band
|
|
413
|
+
interpolated into the decision array) → action fan-out. The SQL where-clause
|
|
414
|
+
scopes the run to the two batches (`ra` is the customer dataset alias). The
|
|
415
|
+
window is generous because each row's enrichment is a live LLM call.
|
|
416
|
+
|
|
417
|
+
```typescript
|
|
418
|
+
const batchList = (ctx.customerBatchIds as string[]).map((b) => `'${b}'`).join(', ')
|
|
419
|
+
|
|
420
|
+
const runResp = await api.workflows.run(tenantSlug, datalakeSlug, ctx.workflowSlug, {
|
|
421
|
+
sql_where_clause: `ra.batch_id IN (${batchList})`,
|
|
422
|
+
mode: 'live',
|
|
423
|
+
manual_override: true,
|
|
424
|
+
})
|
|
425
|
+
ctx.runLogId = runResp.data.workflow_run_log_id!
|
|
426
|
+
ctx.runBatchId = runResp.data.batch_id!
|
|
427
|
+
|
|
428
|
+
const deadline = Date.now() + 240_000
|
|
429
|
+
let status: string | null = null
|
|
430
|
+
while (Date.now() < deadline) {
|
|
431
|
+
const { data: log } = await api.workflows.batchLogs.refresh(tenantSlug, datalakeSlug, ctx.workflowSlug, ctx.runLogId)
|
|
432
|
+
status = log.status ?? null
|
|
433
|
+
if (status && status !== 'pending') break
|
|
434
|
+
await new Promise((r) => setTimeout(r, 2_000))
|
|
435
|
+
}
|
|
436
|
+
if (status === 'failed') {
|
|
437
|
+
throw new Error('agent-driven triage workflow run reached :failed')
|
|
438
|
+
}
|
|
439
|
+
if (!status || status === 'pending') {
|
|
440
|
+
throw new Error('workflow run did not leave :pending within 240s')
|
|
441
|
+
}
|
|
442
|
+
```
|
|
443
|
+
|
|
444
|
+
## 012 — verify the agent's band steered the fan-out
|
|
445
|
+
|
|
446
|
+
Each customer produced a Workflow Execution Log. Every WEL has exactly **three**
|
|
447
|
+
action execution logs — one per band — and exactly **one** is matched
|
|
448
|
+
(`:pending` or `:completed`, the band the agent emitted) while the other **two**
|
|
449
|
+
are `:skipped`. A WEL with two matched actions, or zero, would mean the agent's
|
|
450
|
+
output did not actually steer the decision.
|
|
451
|
+
|
|
452
|
+
```typescript
|
|
453
|
+
const { data: wfLogs } = await api.workflows.workflowLogs.list(tenantSlug, datalakeSlug, ctx.workflowSlug)
|
|
454
|
+
const ourWels = (wfLogs.data ?? []).filter(
|
|
455
|
+
(w) => (w as { batch_id?: string }).batch_id === ctx.runBatchId,
|
|
456
|
+
)
|
|
457
|
+
if (ourWels.length !== 2) {
|
|
458
|
+
throw new Error(`expected 2 WELs for the run, got ${ourWels.length}`)
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
for (const wel of ourWels) {
|
|
462
|
+
const welId = (wel as { id?: string }).id
|
|
463
|
+
const aels =
|
|
464
|
+
(wel as { action_execution_logs?: Array<{ status?: string }> }).action_execution_logs ?? []
|
|
465
|
+
if (aels.length !== 3) {
|
|
466
|
+
throw new Error(`WEL ${welId}: expected 3 AELs (one per band), got ${aels.length}`)
|
|
467
|
+
}
|
|
468
|
+
const byStatus: Record<string, number> = {}
|
|
469
|
+
for (const ael of aels) {
|
|
470
|
+
const st = ael.status ?? 'unknown'
|
|
471
|
+
byStatus[st] = (byStatus[st] ?? 0) + 1
|
|
472
|
+
}
|
|
473
|
+
const matched = (byStatus.pending ?? 0) + (byStatus.completed ?? 0)
|
|
474
|
+
if (matched !== 1) {
|
|
475
|
+
throw new Error(`WEL ${welId}: expected exactly 1 matched AEL, got ${matched} — ${JSON.stringify(byStatus)}`)
|
|
476
|
+
}
|
|
477
|
+
if ((byStatus.skipped ?? 0) !== 2) {
|
|
478
|
+
throw new Error(`WEL ${welId}: expected 2 :skipped AELs — ${JSON.stringify(byStatus)}`)
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
```
|
|
482
|
+
|
|
483
|
+
# Branches
|
|
484
|
+
|
|
485
|
+
- **Agent emits an out-of-enum band** — the response schema's
|
|
486
|
+
`enum: ['priority_high','priority_medium','priority_low']` guards the decision
|
|
487
|
+
interpolation. If the model returned an unexpected string the row's execution
|
|
488
|
+
log would land `:failed`. Production deployments either tighten the prompt or
|
|
489
|
+
add a `default` band action as a fallback.
|
|
490
|
+
- **Per-band SMS template variation** — each action carries its own
|
|
491
|
+
`tool_call.body.body` Liquid. The cookbook renders the same body shape with
|
|
492
|
+
the band interpolated for clarity; production scenarios author a distinct
|
|
493
|
+
message per band (white-glove for high, a standard nudge for medium, a
|
|
494
|
+
low-urgency note for low).
|
|
495
|
+
- **The filter still runs** — `customer.phone and customer.tax_id` gates which
|
|
496
|
+
customers reach the agent at all; both rows here qualify, so both are banded.
|
|
497
|
+
A customer missing either field would be `:filtered` before the agent saw it.
|
|
498
|
+
|
|
499
|
+
# Rollback
|
|
500
|
+
|
|
501
|
+
The cookbook doctest harness does not currently tear down created resources. The
|
|
502
|
+
`_setup/accounts_receivable.md` setup file's runSuffix-scoped names keep each run
|
|
503
|
+
isolated; the seeded local DB is cheap to reset (`mix ecto.reset` on the platform
|
|
504
|
+
repo).
|
|
505
|
+
|
|
506
|
+
# Outcome
|
|
507
|
+
|
|
508
|
+
After this cookbook runs green:
|
|
509
|
+
|
|
510
|
+
- An SMS tool, an Ollama chat-completion LLM tool, a Priority Triage AI agent
|
|
511
|
+
(qwen3-vl:8b-instruct, three-band response schema), and an agent-driven
|
|
512
|
+
workflow over the `customer` dataset are all registered
|
|
513
|
+
- Two Stripe customers (enterprise + individual) have been ingested through the
|
|
514
|
+
AR customer chain
|
|
515
|
+
- Running the workflow drove each through the agent: the LLM banded the
|
|
516
|
+
customer, the decision interpolated the band, and the matching band's SMS
|
|
517
|
+
action fired
|
|
518
|
+
- The routing is verified row-by-row — every Workflow Execution Log carries
|
|
519
|
+
exactly one matched action and two `:skipped`, proving the agent's output
|
|
520
|
+
steered the fan-out
|
|
521
|
+
|
|
522
|
+
# See also
|
|
523
|
+
|
|
524
|
+
- `_setup/accounts_receivable.md` — the inlined bootstrap this cookbook starts from
|
|
525
|
+
- `dunning-sms-for-delinquent.md` — the static-decision AR counterpart (same
|
|
526
|
+
customer chain, one fixed action, no agent)
|
|
527
|
+
- `score-leads-with-llm-categorization.md` — the foundation analogue (leads into
|
|
528
|
+
four bands over a generic table)
|
|
529
|
+
- `ai-agent-invoke.md` — the capability doc for the agent + tool surface this uses
|
|
530
|
+
- `.agent/workflows.md` — agent-driven workflow primitive; `workflow_ai_agents`,
|
|
531
|
+
`context_mapping_config`, agent-output decision interpolation
|
|
532
|
+
- `integration-tests/tests/accounts_receivable/agent-lead-triage.test.ts` — the
|
|
533
|
+
anchor green test these snippets are lifted from
|