@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.
Files changed (83) hide show
  1. package/.agent/AGENTS.md +82 -144
  2. package/.agent/account_management.md +2 -2
  3. package/.agent/action_logs.md +4 -4
  4. package/.agent/ai_agents.md +28 -21
  5. package/.agent/ai_sandbox.md +49 -39
  6. package/.agent/connected_apps.md +3 -3
  7. package/.agent/cookbook/_fixtures/README.md +1 -1
  8. package/.agent/cookbook/_fixtures/{foundation → organic-marketing}/_lead_submissions_foundation_generic_table.liquid +1 -1
  9. package/.agent/cookbook/_fixtures/organic-marketing/_lead_submissions_foundation_legal_entity.liquid +80 -0
  10. package/.agent/cookbook/_fixtures/{foundation → organic-marketing}/_lead_submissions_foundation_mdm.liquid +2 -1
  11. package/.agent/cookbook/_fixtures/payments-compliance/_compliance_screenings_generic_table.liquid +57 -0
  12. package/.agent/cookbook/_fixtures/payments-compliance/_compliance_screenings_legal_entity.liquid +30 -0
  13. package/.agent/cookbook/_fixtures/payments-compliance/_compliance_screenings_mdm.liquid +44 -0
  14. package/.agent/cookbook/_fixtures/payments-compliance/_payment_accounts_generic_table.liquid +57 -0
  15. package/.agent/cookbook/_fixtures/payments-compliance/_payment_accounts_legal_entity.liquid +36 -0
  16. package/.agent/cookbook/_fixtures/payments-compliance/_payment_accounts_mdm.liquid +41 -0
  17. package/.agent/cookbook/_fixtures/primary-care-feedback/_cahps_appointments_generic_table.liquid +70 -0
  18. package/.agent/cookbook/_fixtures/primary-care-feedback/_cahps_appointments_legal_entity.liquid +52 -0
  19. package/.agent/cookbook/_fixtures/primary-care-feedback/_cahps_appointments_mdm.liquid +42 -0
  20. package/.agent/cookbook/_fixtures/subscription-saas/_customers_subscription_generic_table.liquid +38 -0
  21. package/.agent/cookbook/_fixtures/subscription-saas/_customers_subscription_legal_entity.liquid +48 -0
  22. package/.agent/cookbook/_fixtures/subscription-saas/_customers_subscription_mdm.liquid +49 -0
  23. package/.agent/cookbook/organic-marketing.md +2801 -0
  24. package/.agent/cookbook/payments-compliance.md +2180 -0
  25. package/.agent/cookbook/primary-care.md +2175 -0
  26. package/.agent/cookbook/subscription-saas.md +2403 -0
  27. package/.agent/data_activation_clients.md +65 -52
  28. package/.agent/datalakes.md +338 -171
  29. package/.agent/errors.md +3 -3
  30. package/.agent/generic_tables.md +151 -62
  31. package/.agent/interoperability_contracts.md +57 -22
  32. package/.agent/mdm.md +136 -153
  33. package/.agent/messages.md +36 -34
  34. package/.agent/mock-services.md +1 -1
  35. package/.agent/mutations.md +2 -2
  36. package/.agent/templates.md +14 -13
  37. package/.agent/tools.md +63 -21
  38. package/.agent/type_naming.md +13 -13
  39. package/.agent/workflows.md +99 -53
  40. package/README.md +2 -2
  41. package/dist/bin/platform-sdk.mjs +33 -47
  42. package/dist/bin/platform-sdk.mjs.map +1 -1
  43. package/dist/index.d.mts +565 -379
  44. package/dist/index.d.mts.map +1 -1
  45. package/dist/index.mjs +494 -59
  46. package/dist/index.mjs.map +1 -1
  47. package/package.json +4 -3
  48. package/.agent/cookbook/_fixtures/foundation/_lead_submissions_foundation_legal_entity.liquid +0 -88
  49. package/.agent/cookbook/_fixtures/healthcare/_cahps_appointments_healthcare_appointment.liquid +0 -47
  50. package/.agent/cookbook/_fixtures/healthcare/_cahps_appointments_healthcare_mdm.liquid +0 -24
  51. package/.agent/cookbook/_fixtures/healthcare/_cahps_appointments_healthcare_patient.liquid +0 -38
  52. package/.agent/cookbook/_fixtures/payments/_compliance_screenings_payments_compliance_screening.liquid +0 -59
  53. package/.agent/cookbook/_fixtures/payments/_compliance_screenings_payments_mdm.liquid +0 -36
  54. package/.agent/cookbook/_fixtures/payments/_payment_accounts_payments_mdm.liquid +0 -30
  55. package/.agent/cookbook/_fixtures/payments/_payment_accounts_payments_payment_account.liquid +0 -55
  56. package/.agent/cookbook/_fixtures/subscription/_customers_subscription_mdm.liquid +0 -20
  57. package/.agent/cookbook/_setup/foundation.md +0 -359
  58. package/.agent/cookbook/_setup/healthcare.md +0 -361
  59. package/.agent/cookbook/_setup/payments.md +0 -365
  60. package/.agent/cookbook/_setup/subscription.md +0 -364
  61. package/.agent/cookbook/action-status-updaters.md +0 -278
  62. package/.agent/cookbook/ai-agent-invoke.md +0 -279
  63. package/.agent/cookbook/appointment-review-sms-workflow.md +0 -801
  64. package/.agent/cookbook/birthday-greeting-sms-trigger.md +0 -696
  65. package/.agent/cookbook/bulk-ingest.md +0 -302
  66. package/.agent/cookbook/contact-us-triage-with-llm.md +0 -663
  67. package/.agent/cookbook/dunning-sms-for-delinquent.md +0 -659
  68. package/.agent/cookbook/generic-tables.md +0 -244
  69. package/.agent/cookbook/invite-team.md +0 -200
  70. package/.agent/cookbook/kyc-notification-on-account-activation.md +0 -661
  71. package/.agent/cookbook/marketing-campaign-send.md +0 -1044
  72. package/.agent/cookbook/paginated-restapi-poller.md +0 -383
  73. package/.agent/cookbook/rest-fetch.md +0 -273
  74. package/.agent/cookbook/sanctions-screening-with-agent-review.md +0 -773
  75. package/.agent/cookbook/score-leads-with-llm-categorization.md +0 -665
  76. package/.agent/cookbook/system-templates.md +0 -165
  77. package/.agent/cookbook/talk-to-data.md +0 -178
  78. package/.agent/cookbook/triage-prospects-by-priority.md +0 -571
  79. package/.agent/cookbook/welcome-sms-for-customers.md +0 -647
  80. /package/.agent/cookbook/_fixtures/{healthcare → primary-care-feedback}/memorandum-of-association-01.png +0 -0
  81. /package/.agent/cookbook/_fixtures/{healthcare → primary-care-feedback}/sample_two_page.pdf +0 -0
  82. /package/.agent/cookbook/_fixtures/{subscription → subscription-saas}/_customers_subscription_customer.liquid +0 -0
  83. /package/.agent/cookbook/_fixtures/{subscription → subscription-saas}/stripe_customers_batch1.csv +0 -0
@@ -1,773 +0,0 @@
1
- ---
2
- title: Disambiguate gray-zone sanctions screenings via an LLM agent
3
- summary: End-to-end agent-driven workflow — a chat-completion LLM agent disambiguates gray-zone-scored sanctions screenings into a verified/blocked verdict, the workflow's decision interpolates that verdict, two SMS actions (one per verdict) fan out, and the run is verified row-by-row. A clearly-clear screening is filtered out before the agent ever sees it.
4
- industry: payments
5
- slug: sanctions-screening-with-agent-review
6
- vitest_source:
7
- - integration-tests/tests/payments/sanctions-review-workflow.test.ts
8
- - integration-tests/tests/payments/interoperability-contracts.test.ts
9
- - integration-tests/tests/payments/run-dac-single.test.ts
10
- - integration-tests/tests/payments/create-dac.test.ts
11
- - integration-tests/tests/payments/tools.test.ts
12
- - integration-tests/tests/payments/data-sources.test.ts
13
- - integration-tests/tests/payments/bootstrap.test.ts
14
- status: green
15
- ---
16
-
17
- # Problem
18
-
19
- A sanctions-screening provider returns a match score for every
20
- name it checks against a watchlist. The extremes are easy: a
21
- score near zero is a clean pass, a score near one is a confirmed
22
- hit. The middle band — a name close enough to a sanctioned entity
23
- to be plausible, different enough to be a likely false positive —
24
- is where a compliance analyst spends their day.
25
-
26
- A fixed threshold cannot resolve the gray zone: set it low and
27
- every near-miss blocks a legitimate customer; set it high and a
28
- real match slips through. The disambiguation is a judgement call
29
- on the specific name, not a number comparison.
30
-
31
- The Alvera platform's agent-driven workflow primitive turns that
32
- judgement call into an LLM enrichment plus a Liquid
33
- interpolation. A filter narrows the workflow to *only* gray-zone
34
- scores; an AI agent receives the screening's entity name and
35
- score via a context-mapping template, returns a JSON object whose
36
- `verdict` field is `verified` or `blocked`; and the workflow's
37
- decision_config interpolates that verdict into a one-element
38
- decision array. Two SMS actions are registered against the
39
- workflow, one per verdict; the agent's output picks which one
40
- runs. Auto-clear and auto-block screenings never reach the agent
41
- — the filter rejects them.
42
-
43
- This cookbook walks the **whole** scenario: it provisions the
44
- agent and workflow, ingests two compliance-screening events
45
- through the production data-activation chain (one gray-zone, one
46
- clearly clear), runs the workflow so the gray-zone filter routes
47
- each row, and verifies the routing — the gray-zone screening
48
- reaches the agent and fans out to a verdict-keyed SMS, the
49
- clearly-clear screening is filtered out before any agent call.
50
-
51
- The scenario is anchored to
52
- `platform/integration-tests/tests/payments/sanctions-review-workflow.test.ts`
53
- — a green end-to-end test. The setup file `_setup/payments.md`
54
- already provisioned the tenant + datalake + tenant-scoped client;
55
- this cookbook starts from there.
56
-
57
- # Composition
58
-
59
- | Resource provisioned | Owner |
60
- |-------------------------------------|-------------|
61
- | SMS tool (SNS-backed) | build |
62
- | LLM tool (Ollama chat completion) | build |
63
- | Sanctions Review AI agent | build |
64
- | Sanctions Review workflow | build |
65
- | Atomic FI data source | build |
66
- | Manual Upload tool | build |
67
- | Compliance Screening interop contract | build |
68
- | Manual-upload DAC | build |
69
-
70
- The setup file `_setup/payments.md` already provisioned the
71
- tenant + datalake + tenant-scoped client; this cookbook starts
72
- from there.
73
-
74
- # Walkthrough
75
-
76
- ## 001 — create the SMS tool
77
-
78
- The Sanctions Review workflow's actions invoke an SMS tool. The
79
- tool's `body.tool_body_type: 'sns'` means it routes via AWS SNS;
80
- local dev points it at LocalStack on `http://localhost:4566` via
81
- `endpoint_url` so no real AWS credentials are needed. The
82
- `intent: 'sms'` tags this tool for workflow actions that send SMS
83
- (versus `data_exchange` for ingestion tools).
84
-
85
- ```typescript
86
- const smsToolResp = await api.tools.create(tenantSlug, datalakeSlug, {
87
- name: `Cookbook SMS Tool ${runSuffix}`,
88
- description: 'SNS-backed SMS dispatcher for the Sanctions Review workflow, wired to LocalStack.',
89
- intent: 'sms',
90
- status: 'active',
91
- datalake_id: ctx.datalakeId,
92
- body: {
93
- tool_body_type: 'sns',
94
- auth_method: 'access_key',
95
- region: 'us-east-1',
96
- phone_number: '+15551234567',
97
- endpoint_url: 'http://localhost:4566',
98
- access_key_id: 'test',
99
- secret_access_key: 'test',
100
- },
101
- })
102
- toolId = smsToolResp.data.id!
103
- ```
104
-
105
- ## 002 — create the LLM tool
106
-
107
- The Sanctions Review agent calls a chat-completion endpoint to
108
- disambiguate each gray-zone screening. The tool's
109
- `intent: 'llm_enrichment'` distinguishes it from the SMS tool above.
110
- It is a **provider adapter**: `base_body` authors the provider's
111
- request — here Ollama's native `/api/chat` shape with `think: false`
112
- and a `format` schema so the model returns clean, schema-constrained
113
- JSON — and `response_extractor` maps the provider's envelope back to
114
- the canonical `{ output_json, … }` the platform reads. The extractor's
115
- `output_schema` is required for an `llm_enrichment` tool. The
116
- `api_key`/`auth_method` pair satisfies the REST tool's schema even
117
- though Ollama ignores the header.
118
-
119
- ```typescript
120
- const ENRICHMENT_OUTPUT_SCHEMA = {
121
- type: 'object',
122
- properties: {
123
- output_json: {},
124
- input_tokens: { type: ['integer', 'null'] },
125
- output_tokens: { type: ['integer', 'null'] },
126
- total_tokens: { type: ['integer', 'null'] },
127
- explanation: { type: ['string', 'null'] },
128
- },
129
- required: ['output_json'],
130
- }
131
-
132
- const OLLAMA_BASE_BODY =
133
- '{"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 }}}'
134
-
135
- const OLLAMA_EXTRACTOR =
136
- '{"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 }}}'
137
-
138
- const llmToolResp = await api.tools.create(tenantSlug, datalakeSlug, {
139
- name: `Cookbook LLM Tool ${runSuffix}`,
140
- description: 'Ollama-backed chat-completion adapter for sanctions-screening disambiguation.',
141
- intent: 'llm_enrichment',
142
- status: 'active',
143
- datalake_id: ctx.datalakeId,
144
- response_extractor: { type: 'custom', body: OLLAMA_EXTRACTOR, output_schema: ENRICHMENT_OUTPUT_SCHEMA },
145
- body: {
146
- tool_body_type: 'rest_api',
147
- base_url: 'http://localhost:11434',
148
- base_path: { type: 'custom', body: '/api/chat' },
149
- auth_method: 'api_key',
150
- api_key: 'stub-key',
151
- api_key_name: 'Authorization',
152
- api_key_location: 'header',
153
- request_type: 'json',
154
- response_type: 'json',
155
- timeout_ms: 60_000,
156
- base_body: { type: 'custom', body: OLLAMA_BASE_BODY },
157
- },
158
- })
159
- ctx.llmToolId = llmToolResp.data.id!
160
- ```
161
-
162
- ## 003 — create the Sanctions Review AI agent
163
-
164
- The agent binds three things: the model name, an input schema the
165
- workflow's context-mapping must satisfy, and a response schema the
166
- agent's output must match. The response schema's
167
- `enum: ['verified','blocked']` constraint guards the downstream
168
- decision interpolation from emitting a verdict the workflow has no
169
- SMS action for. `temperature: 0.0` removes sampling noise.
170
-
171
- `data_access: 'regulated'` is load-bearing here: the screened
172
- entity's name is a `TokenizedDataType` field — tokenized on the
173
- unregulated side. The agent needs the plaintext name to make a
174
- judgement call, so it reads the *regulated* projection of the
175
- compliance-screening row.
176
-
177
- ```typescript
178
- const AGENT_INPUT_SCHEMA = {
179
- type: 'object',
180
- properties: {
181
- screening_id: { type: 'string' },
182
- screened_entity_name: { type: 'string' },
183
- match_score: { type: 'number' },
184
- },
185
- required: ['screening_id', 'screened_entity_name', 'match_score'],
186
- }
187
-
188
- const AGENT_RESPONSE_SCHEMA = {
189
- type: 'object',
190
- properties: {
191
- verdict: { type: 'string', enum: ['verified', 'blocked'] },
192
- comments: { type: 'string' },
193
- },
194
- required: ['verdict', 'comments'],
195
- }
196
-
197
- const AGENT_PROMPT_BODY = `You are a sanctions-review disambiguation assistant. A name-match against a sanctions list has come in with a gray-zone score (close enough to be plausible, ambiguous enough to need a human-style judgement call). Decide whether this is a true match ("blocked") or a false positive on a similar name ("verified").
198
-
199
- Screening id: {{ screening_id }}
200
- Screened entity name: {{ screened_entity_name }}
201
- Match score: {{ match_score }}
202
-
203
- Respond with JSON: {"verdict": "verified" | "blocked", "comments": "<one-sentence rationale>"}
204
-
205
- For this cookbook fixture, the entity name is a deliberately disambiguating non-sanctioned identity — respond with "verified".`
206
-
207
- const agentResp = await api.aiAgents.create(tenantSlug, datalakeSlug, {
208
- name: `Cookbook Sanctions Review Agent ${runSuffix}`,
209
- tool_id: ctx.llmToolId,
210
- model: 'qwen3-vl:8b-instruct',
211
- data_access: 'regulated',
212
- temperature: 0.0,
213
- max_tokens: 256,
214
- enabled: true,
215
- input_schema: AGENT_INPUT_SCHEMA,
216
- llm_response_schema: AGENT_RESPONSE_SCHEMA,
217
- prompt_config: { type: 'custom', body: AGENT_PROMPT_BODY },
218
- })
219
- aiAgentId = agentResp.data.id!
220
- ctx.agentSlug = agentResp.data.slug!
221
- ```
222
-
223
- ## 004 — create the Sanctions Review workflow
224
-
225
- The workflow has the standard shape — filter, decision, actions —
226
- plus the Sanctions Review agent **nested** in the create body (the
227
- `workflow_ai_agents` array — a `cast_assoc`, not a separate attach
228
- call) to bind it into the enrichment phase. Three pieces are worth
229
- noting.
230
-
231
- The **filter** is the gray-zone band: it passes only screenings
232
- whose `screening_score` is at least `0.80` and below `0.95`. A
233
- score below `0.80` is an auto-clear and a score at/above `0.95` is
234
- an auto-block — neither needs the agent, so the filter rejects
235
- them before any LLM call.
236
-
237
- The **context_mapping** projects each screening into the agent's
238
- input schema. `match_score` is interpolated *without* surrounding
239
- quotes so it renders as a raw JSON number — the input schema
240
- declares it `number`, and a quoted `"0.88"` would fail schema
241
- validation. `screened_entity_name` is read from the regulated
242
- projection so the agent sees the plaintext name.
243
-
244
- The **decision_config** interpolates the agent's `verdict` (read
245
- from `additional_context["<agent-slug>"].verdict`) into a
246
- single-element decision array. The two `actions` are keyed
247
- `decision_key: verified|blocked`; whichever verdict the agent
248
- emits picks the action that fires, leaving the other `:skipped`.
249
- Bracket access is used because the agent slug contains hyphens.
250
-
251
- ```typescript
252
- const VERDICTS = ['verified', 'blocked'] as const
253
-
254
- const GRAY_ZONE_FILTER =
255
- '{% if compliance_screening.screening_score >= 0.80 ' +
256
- 'and compliance_screening.screening_score < 0.95 %}true{% endif %}'
257
-
258
- const CONTEXT_MAPPING_BODY =
259
- '{' +
260
- '"screening_id": "{{ compliance_screening.id }}",' +
261
- '"screened_entity_name": "{{ regulated_compliance_screening.screened_entity_name }}",' +
262
- '"match_score": {{ compliance_screening.screening_score }}' +
263
- '}'
264
-
265
- const DECISION_CONFIG_BODY = `["{{ additional_context["${ctx.agentSlug}"].verdict }}"]`
266
- const DECISION_OUTPUT_SCHEMA = { type: 'array', items: { type: 'string' } }
267
-
268
- const workflowResp = await api.workflows.create(tenantSlug, datalakeSlug, {
269
- name: `Cookbook Sanctions Review Workflow ${runSuffix}`,
270
- description: 'Runs an LLM disambiguation pass on gray-zone-scored sanctions matches and fires a verdict-keyed SMS.',
271
- dataset_type: 'compliance_screening',
272
- status: 'live',
273
- tags: ['compliance', 'sanctions'],
274
- filter_config: {
275
- type: 'custom',
276
- body: GRAY_ZONE_FILTER,
277
- output_schema: { type: 'boolean' },
278
- },
279
- decision_config: {
280
- type: 'custom',
281
- body: DECISION_CONFIG_BODY,
282
- output_schema: DECISION_OUTPUT_SCHEMA,
283
- },
284
- actions: VERDICTS.map((verdict) => ({
285
- decision_key: verdict,
286
- action_type: 'sms',
287
- tool_id: toolId,
288
- position: 0,
289
- trigger_template: 'now',
290
- idempotency_template: `{{ compliance_screening.id }}-${verdict}`,
291
- tool_call: {
292
- tool_call_type: 'sms_request',
293
- to: { type: 'custom', body: '+15550000000' },
294
- body: {
295
- type: 'custom',
296
- body: `[${verdict}] {{ additional_context["${ctx.agentSlug}"].comments }}`,
297
- },
298
- sms_type: 'transactional',
299
- },
300
- })),
301
- // Nest the sanctions-review agent inline. Workflow joins omit
302
- // output_schema — the server pins it from the agent's input_schema.
303
- workflow_ai_agents: [
304
- {
305
- ai_agent_id: aiAgentId,
306
- position: 0,
307
- context_mapping_config: { type: 'custom', body: CONTEXT_MAPPING_BODY },
308
- },
309
- ],
310
- })
311
- workflowId = workflowResp.data.id!
312
- ctx.workflowSlug = workflowResp.data.slug!
313
- ```
314
-
315
- ## 005 — create the Atomic FI data source
316
-
317
- A workflow runs on rows; rows arrive through the data-activation
318
- chain. The chain's first link is a `DataSource` — a registration
319
- of where the rows originate. The `uri` is the system-of-record
320
- address; it flows into each ingested row's `source_uri`.
321
-
322
- ```typescript
323
- const dataSourceResp = await api.dataSources.create(tenantSlug, datalakeSlug, {
324
- name: `Cookbook Atomic FI Source ${runSuffix}`,
325
- uri: 'api.atomic.fi',
326
- description: 'Atomic FI compliance API — origin of the compliance-screening rows the workflow runs on.',
327
- status: 'active',
328
- is_default: false,
329
- })
330
- dataSourceId = dataSourceResp.data.id!
331
- ```
332
-
333
- ## 006 — create the Manual Upload tool
334
-
335
- The Data Activation Client needs a tool. For inline-JSON ingest a
336
- `manual_upload` tool is the minimal choice — `intent:
337
- 'data_exchange'` distinguishes it from the SMS tool, and
338
- `tool_body_type: 'manual_upload'` needs no endpoint or credential
339
- wiring (the rows arrive in the ingest call body, not by the tool
340
- fetching them).
341
-
342
- ```typescript
343
- const manualUploadToolResp = await api.tools.create(tenantSlug, datalakeSlug, {
344
- name: `Cookbook Manual Upload Tool ${runSuffix}`,
345
- description: 'Manual-upload data-exchange tool — backs the DAC that ingests compliance-screening rows.',
346
- intent: 'data_exchange',
347
- status: 'active',
348
- datalake_id: ctx.datalakeId,
349
- data_source_id: dataSourceId,
350
- body: { tool_body_type: 'manual_upload' },
351
- })
352
- ctx.manualUploadToolId = manualUploadToolResp.data.id!
353
- ```
354
-
355
- ## 007 — create the Compliance Screening interoperability contract
356
-
357
- The interoperability contract is the row-shaping rule: a Liquid
358
- template that maps an inbound Atomic FI compliance-screening row
359
- into a payments `ComplianceScreening` upsert. This cookbook
360
- loads the production Atomic FI compliance-screening + MDM
361
- templates from the vendored fixtures directory.
362
-
363
- `template_config` shapes the ComplianceScreening resource —
364
- including the `screened_entity_name` field the changeset
365
- tokenizes automatically. `mdm_input_config` shapes the MDM input
366
- — the `account_holder_id` plus the entity name the platform's
367
- master-data resolution keys on to find-or-create the LegalEntity
368
- + AccountHolder pair the screening belongs to.
369
-
370
- ```typescript
371
- const { readFileSync } = await import('node:fs')
372
- const { join } = await import('node:path')
373
- const csTemplate = readFileSync(
374
- join(process.env.COOKBOOK_FIXTURES_DIR!, 'payments/_compliance_screenings_payments_compliance_screening.liquid'),
375
- 'utf8',
376
- )
377
- const mdmTemplate = readFileSync(
378
- join(process.env.COOKBOOK_FIXTURES_DIR!, 'payments/_compliance_screenings_payments_mdm.liquid'),
379
- 'utf8',
380
- )
381
-
382
- const contractResp = await api.interoperabilityContracts.create(tenantSlug, datalakeSlug, {
383
- name: `Cookbook Atomic FI Compliance Screening Contract ${runSuffix}`,
384
- description: 'Atomic FI compliance-screenings → Payments ComplianceScreening (custom Liquid + MDM input).',
385
- resource_type: 'compliance_screening',
386
- template_config: { type: 'custom', body: csTemplate },
387
- mdm_input_config: { type: 'custom', body: mdmTemplate },
388
- generic_table_id: null,
389
- })
390
- interopContractId = contractResp.data.id!
391
- ```
392
-
393
- ## 008 — create the manual-upload DAC
394
-
395
- The Data Activation Client binds the three preceding pieces — the
396
- manual-upload tool, the data source, and the interop contract —
397
- into one ingestion endpoint. `tool_call.tool_call_type:
398
- 'manual_upload'` selects the inline-JSON ingest path. The
399
- server-derived `slug` is the handle §009 ingests rows against.
400
-
401
- ```typescript
402
- const dacResp = await api.dataActivationClients.create(tenantSlug, datalakeSlug, {
403
- name: `Cookbook Compliance Screening DAC ${runSuffix}`,
404
- description: 'Manual-upload DAC — ingests compliance-screening events into ComplianceScreening via the interop contract.',
405
- tool_id: ctx.manualUploadToolId,
406
- data_source_id: dataSourceId,
407
- tool_call: { tool_call_type: 'manual_upload' },
408
- interoperability_contract_ids: [interopContractId],
409
- })
410
- dacId = dacResp.data.id!
411
- ctx.dacSlug = dacResp.data.slug!
412
- ```
413
-
414
- ## 009 — ingest two compliance-screening events
415
-
416
- Two rows, ingested as inline JSON through the manual-upload DAC.
417
- They differ in the field the workflow filter cares about:
418
- `screening_score`. The first row scores `0.88` — squarely in the
419
- gray zone `[0.80, 0.95)`, so the filter passes it to the agent.
420
- The second scores `0.20` — an auto-clear, so the filter rejects
421
- it before any agent call. Each row carries a distinct
422
- `account_holder_id` so MDM resolves each to its own legal entity.
423
- Each ingest call gets its own batch id; both are pinned for the
424
- run scope in §011.
425
-
426
- ```typescript
427
- const grayZoneRow = {
428
- compliance_screening_number: `CS-GRAY-${runSuffix}`,
429
- account_holder_id: `AH-SR-${runSuffix}-gray`,
430
- scope: 'account_holder',
431
- screening_type: 'sanctions',
432
- screening_status: 'pending',
433
- screened_entity_type: 'individual',
434
- sanctions_screening_status: 'match',
435
- screening_score: 0.88,
436
- match_count: 1,
437
- screened_entity_name: 'Jane Cookbook-Disambiguating-Doe',
438
- source_uri: 'api.atomic.fi',
439
- }
440
- const autoClearRow = {
441
- compliance_screening_number: `CS-CLEAR-${runSuffix}`,
442
- account_holder_id: `AH-SR-${runSuffix}-clear`,
443
- scope: 'account_holder',
444
- screening_type: 'sanctions',
445
- screening_status: 'pass',
446
- screened_entity_type: 'individual',
447
- sanctions_screening_status: 'cleared',
448
- screening_score: 0.2,
449
- match_count: 0,
450
- screened_entity_name: 'John Cookbook-Clear-Smith',
451
- source_uri: 'api.atomic.fi',
452
- }
453
-
454
- const [grayZoneIngest, autoClearIngest] = await Promise.all([
455
- api.dataActivationClients.ingest(tenantSlug, datalakeSlug, ctx.dacSlug, { data: grayZoneRow }),
456
- api.dataActivationClients.ingest(tenantSlug, datalakeSlug, ctx.dacSlug, { data: autoClearRow }),
457
- ])
458
- ctx.batchGrayZone = grayZoneIngest.data.batch_id!
459
- ctx.batchAutoClear = autoClearIngest.data.batch_id!
460
- ```
461
-
462
- ## 010 — wait for both ingest batches to reach steady-state
463
-
464
- Ingestion is async — the DAC enqueues per-row jobs that the
465
- `BatchMergeWorker` drains into the regulated compliance-screening
466
- table. Poll the DAC's activation logs until both batches show a
467
- row with `rows_ingested >= 1` and a non-empty `output_files`
468
- array (the merged Parquet landed in object storage). Only then is
469
- it safe to run the workflow against these rows.
470
-
471
- ```typescript
472
- const targetBatches = new Set([ctx.batchGrayZone, ctx.batchAutoClear])
473
- const deadline = Date.now() + 90_000
474
- let greenCount = 0
475
- while (Date.now() < deadline) {
476
- const { data } = await api.dataActivationClients.logs.list(tenantSlug, datalakeSlug, ctx.dacSlug)
477
- const green = new Set<string>()
478
- for (const row of (data.data ?? []) as Array<Record<string, unknown>>) {
479
- const b = row.batch_id
480
- if (typeof b !== 'string' || !targetBatches.has(b)) continue
481
- if (typeof row.rows_ingested !== 'number' || row.rows_ingested < 1) continue
482
- const files = row.output_files
483
- if (!Array.isArray(files) || files.length === 0) continue
484
- green.add(b)
485
- }
486
- greenCount = green.size
487
- if (greenCount === targetBatches.size) break
488
- await new Promise((r) => setTimeout(r, 1_000))
489
- }
490
- if (greenCount !== targetBatches.size) {
491
- throw new Error(`only ${greenCount}/2 compliance-screening batches reached steady-state within 90s`)
492
- }
493
- ```
494
-
495
- ## 011 — run the workflow against the two batches
496
-
497
- `workflows.run` with `manual_override: false` evaluates the
498
- filter, so the gray-zone filter genuinely routes each row. The
499
- SQL where-clause scopes the run to exactly the two batches §009
500
- ingested (`rcs` is the regulated-compliance-screening alias the
501
- run-query exposes).
502
-
503
- For the gray-zone row the run drives the full agent-driven
504
- pipeline: filter → agent enrichment (the Ollama call that returns
505
- the verdict) → decision (the verdict interpolated into the
506
- decision array) → action fan-out. The auto-clear row is rejected
507
- at the filter and never reaches the agent. Poll
508
- `batchLogs.refresh` until the run leaves `:pending`; the window
509
- is generous because the gray-zone row's enrichment is a live LLM
510
- inference call.
511
-
512
- ```typescript
513
- const runResp = await api.workflows.run(tenantSlug, datalakeSlug, ctx.workflowSlug, {
514
- sql_where_clause: `rcs.batch_id IN ('${ctx.batchGrayZone}', '${ctx.batchAutoClear}')`,
515
- mode: 'live',
516
- manual_override: false,
517
- })
518
- // run-workflow only SCHEDULES the run. The log id and batch id are
519
- // written when it fires, so read them back via workflowRuns.get.
520
- const fired = await ctx.waitForFiredRun(datalakeSlug, runResp.data.workflow_run_id)
521
- ctx.runLogId = fired.workflowRunLogId
522
- ctx.runBatchId = fired.batchId!
523
-
524
- const deadline = Date.now() + 240_000
525
- let status: string | null = null
526
- while (Date.now() < deadline) {
527
- const { data: log } = await api.workflows.batchLogs.refresh(tenantSlug, datalakeSlug, ctx.workflowSlug, ctx.runLogId)
528
- status = log.status ?? null
529
- if (status && status !== 'pending') break
530
- await new Promise((r) => setTimeout(r, 2_000))
531
- }
532
- if (status === 'failed') throw new Error('agent-driven workflow run reached :failed')
533
- if (!status || status === 'pending') {
534
- throw new Error('workflow run did not leave :pending within 240s')
535
- }
536
- ```
537
-
538
- ## 012 — verify the gray-zone filter routed and the agent steered the fan-out
539
-
540
- Each row produced a Workflow Execution Log. The gray-zone
541
- screening passes the filter, reaches the agent, and its WEL is
542
- `:executing` or `:completed`. The auto-clear screening fails the
543
- filter, so its WEL is `:filtered` — it never reached the agent. A
544
- run where both passed — or both were filtered — would mean the
545
- gray-zone band filter is not actually evaluating the score.
546
-
547
- The pass-branch WEL carries exactly **two** action execution logs
548
- — one per verdict — and exactly **one** is matched (`:pending` or
549
- `:completed`, the verdict the agent emitted) while the other is
550
- `:skipped`. That is the proof the agent's verdict actually steered
551
- the decision rather than firing every action.
552
-
553
- ```typescript
554
- const { data: wfLogs } = await api.workflows.workflowLogs.list(tenantSlug, datalakeSlug, ctx.workflowSlug)
555
- const ourWels = (wfLogs.data ?? []).filter(
556
- (w) => (w as { batch_id?: string }).batch_id === ctx.runBatchId,
557
- )
558
- if (ourWels.length !== 2) {
559
- throw new Error(`expected 2 WELs for the run, got ${ourWels.length}`)
560
- }
561
-
562
- const byStatus: Record<string, number> = {}
563
- for (const w of ourWels) {
564
- const st = (w as { status?: string }).status ?? 'unknown'
565
- byStatus[st] = (byStatus[st] ?? 0) + 1
566
- }
567
- if ((byStatus.filtered ?? 0) !== 1) {
568
- throw new Error(
569
- `expected 1 :filtered WEL (the auto-clear screening) — distribution ${JSON.stringify(byStatus)}`,
570
- )
571
- }
572
- const passWel = ourWels.find((w) => {
573
- const st = (w as { status?: string }).status
574
- return st === 'executing' || st === 'completed'
575
- })
576
- if (!passWel) {
577
- throw new Error(`no pass-branch WEL — distribution ${JSON.stringify(byStatus)}`)
578
- }
579
-
580
- const aels =
581
- (passWel as { action_execution_logs?: Array<{ status?: string; decision_key?: string }> })
582
- .action_execution_logs ?? []
583
- if (aels.length !== 2) {
584
- throw new Error(`expected 2 AELs (one per verdict) on the gray-zone WEL, got ${aels.length}`)
585
- }
586
- const aelByStatus: Record<string, number> = {}
587
- for (const ael of aels) {
588
- const st = ael.status ?? 'unknown'
589
- aelByStatus[st] = (aelByStatus[st] ?? 0) + 1
590
- }
591
- const matched = (aelByStatus.pending ?? 0) + (aelByStatus.completed ?? 0)
592
- if (matched !== 1) {
593
- throw new Error(`expected exactly 1 matched AEL, got ${matched} — ${JSON.stringify(aelByStatus)}`)
594
- }
595
- if ((aelByStatus.skipped ?? 0) !== 1) {
596
- throw new Error(`expected 1 :skipped AEL — ${JSON.stringify(aelByStatus)}`)
597
- }
598
- const routed = aels.find((a) => a.status === 'pending' || a.status === 'completed')
599
- if (!routed?.decision_key || !/^(verified|blocked)$/.test(routed.decision_key)) {
600
- throw new Error(`matched AEL has unexpected decision_key ${routed?.decision_key}`)
601
- }
602
- ```
603
-
604
- ## 013 — confirm the verdict-keyed SMS rendered
605
-
606
- The matched action fired immediately (`trigger_template: 'now'`)
607
- and persisted a row in the regulated `message` dataset, carrying
608
- the fully-rendered SMS body. The body is prefixed with the
609
- verdict decision_key — `[verified]` or `[blocked]` — followed by
610
- the agent's free-text `comments`. For this cookbook's fixture
611
- entity name the agent is prompted toward `verified`, so the
612
- persisted SMS is prefixed `[verified]`. Search the message
613
- dataset scoped to this workflow and assert the prefix.
614
-
615
- ```typescript
616
- const { data: userSearch } = await api.datasets.createUserSearch(tenantSlug, datalakeSlug, 'message', {
617
- search_query: `rm.workflow_id = '${workflowId}'`,
618
- })
619
- if (userSearch.status !== 'completed') {
620
- throw new Error(`message user-search status=${userSearch.status} error=${userSearch.error_message ?? '(none)'}`)
621
- }
622
-
623
- const deadline = Date.now() + 45_000
624
- let messages: Array<Record<string, unknown>> = []
625
- while (Date.now() < deadline && messages.length === 0) {
626
- const { data } = await api.datasets.search(tenantSlug, datalakeSlug, 'message', {
627
- userSearchId: userSearch.id!,
628
- dataAccessMode: 'regulated',
629
- })
630
- messages = (data.data ?? []) as Array<Record<string, unknown>>
631
- if (messages.length === 0) await new Promise((r) => setTimeout(r, 1_000))
632
- }
633
- if (messages.length === 0) {
634
- throw new Error('no verdict SMS message persisted for the workflow within 45s')
635
- }
636
-
637
- const verdictBody = messages
638
- .map((m) => String(m.body ?? ''))
639
- .find((body) => body.includes('[verified]') || body.includes('[blocked]'))
640
- if (!verdictBody) {
641
- throw new Error(`no verdict-prefixed SMS body — bodies: ${JSON.stringify(messages.map((m) => m.body))}`)
642
- }
643
- if (!verdictBody.includes('[verified]')) {
644
- throw new Error(`expected the agent verdict to render [verified] — got: ${verdictBody}`)
645
- }
646
- ```
647
-
648
- ## 014 — write the integration test
649
-
650
- End the build with a test you keep: re-read the workflow and prove the
651
- pipeline still executes — without a side effect. `mode: 'dry_run'` with a
652
- never-matching selection runs the FULL pipeline (selection → filter →
653
- decision) and intercepts only the final action call, so no message
654
- leaves, yet the acknowledgement proves the workflow is runnable. This
655
- block runs live under `make validate-cookbook`.
656
-
657
- ```typescript
658
- // Re-GET — the workflow must still be live, or nothing will run.
659
- const { data: wfRow } = await api.workflows.get(tenantSlug, datalakeSlug, workflowId)
660
- if (wfRow.status !== 'live') {
661
- throw new Error(`workflow regressed from live: ${wfRow.status}`)
662
- }
663
- // Behavioural probe — a dry run against a selection no row can match:
664
- // the pipeline executes end-to-end, the final action call is
665
- // intercepted, and the acknowledgement carries the scheduled run id. The
666
- // clause must speak this workflow's selection dialect — the dataset
667
- // alias is `rcs` here, the same alias the live run above uses.
668
- const { data: probeRun } = await api.workflows.run(tenantSlug, datalakeSlug, ctx.workflowSlug, {
669
- sql_where_clause: "rcs.batch_id = 'test-never-matching-batch'",
670
- mode: 'dry_run',
671
- manual_override: false,
672
- })
673
- // The run-log id does not exist until the run fires — wait, do not read a null.
674
- const probeFired = await ctx.waitForFiredRun(datalakeSlug, probeRun.workflow_run_id)
675
- if (probeFired.workflowRunLogId.length === 0) {
676
- throw new Error('dry-run probe never produced a workflow_run_log_id')
677
- }
678
- ```
679
-
680
- If the probe fails in production, escalate with the run response as
681
- evidence — don't flip the workflow's status or rewrite its configs to
682
- chase the error.
683
-
684
- # Branches
685
-
686
- - **The auto-clear screening is filtered, not failed** — §012
687
- asserts the `0.20`-scored screening's WEL is `:filtered`, a
688
- distinct terminal status from `:failed`. A filtered row is a
689
- *correct* outcome: the workflow looked at it, the gray-zone
690
- filter rendered empty because the score was below `0.80`, and
691
- the platform recorded the row as intentionally skipped. The
692
- agent is never called for a filtered row — which is the whole
693
- point of the band filter: do not spend an LLM inference on a
694
- screening a threshold already settles.
695
- - **The blocked verdict** — the agent's response schema admits
696
- `blocked` as well as `verified`. A gray-zone screening on a
697
- genuinely sanctioned entity would have the agent emit `blocked`,
698
- the decision interpolate `["blocked"]`, and the blocked-keyed
699
- SMS fire instead. This cookbook's fixture entity name is
700
- deliberately disambiguating so the run is deterministic; a
701
- production deployment lets the model's judgement pick the
702
- branch.
703
- - **Agent emits an out-of-enum verdict** — the LLM response
704
- schema's `enum` constraint guards the decision interpolation.
705
- If the model returned an unexpected string the workflow row's
706
- execution log would land `:failed`. Production deployments
707
- either tighten the prompt or add a fallback action.
708
-
709
- # Rollback
710
-
711
- The cookbook doctest harness does not currently tear down created
712
- resources. The `_setup/payments.md` setup file's
713
- runSuffix-scoped tenant / datalake / user names mean each run is
714
- naturally isolated; the seeded local DB is cheap to reset
715
- (`mix ecto.reset` on the platform repo).
716
-
717
- # Outcome
718
-
719
- After this cookbook's thirteen steps run green:
720
-
721
- - A payments tenant exists with a payments-domain
722
- datalake
723
- - An SMS tool, a chat-completion LLM tool, a Sanctions Review AI
724
- agent (qwen3-vl:8b-instruct, verified/blocked response schema), and a
725
- Sanctions Review agent-driven workflow are all registered
726
- - An Atomic FI data source, a Manual Upload tool, a Compliance
727
- Screening interop contract, and a manual-upload DAC form a
728
- working ingestion chain
729
- - Two compliance screenings have been ingested through that chain
730
- — one gray-zone-scored, one auto-clear
731
- - Running the workflow routed them correctly: the gray-zone
732
- screening passed the band filter, reached the agent, and fanned
733
- out to a verdict-keyed SMS; the auto-clear screening was
734
- `:filtered` before any agent call
735
- - The pass-branch WEL is verified row-by-row — two AELs, exactly
736
- one matched and one `:skipped`, proving the agent's verdict
737
- steered the decision
738
- - The rendered SMS in the `message` dataset carries the
739
- `[verified]` verdict prefix
740
-
741
- The business outcome — gray-zone sanctions screenings
742
- disambiguated by an LLM and routed to a verdict-specific SMS,
743
- with clear-cut screenings filtered out of the agent path
744
- entirely — is demonstrated end-to-end, not merely provisioned.
745
-
746
- # See also
747
-
748
- - `_setup/payments.md` — the inlined bootstrap that
749
- provisions the tenant + datalake this cookbook starts from
750
- - `kyc-notification-on-account-activation.md` — the
751
- static-decision payments cookbook; same SMS wiring, full
752
- data-activation chain, no agent
753
- - `score-leads-with-llm-categorization.md`,
754
- `contact-us-triage-with-llm.md` — the foundation and healthcare
755
- agent-driven cookbooks; same agent + decision-interpolation
756
- shape
757
- - `.agent/tools.md` — SMS and chat-completion tool body shapes;
758
- intent classification
759
- - `.agent/ai_agents.md` — AI agent registration shape; input +
760
- response schemas; `data_access` regulated vs unregulated
761
- - `.agent/workflows.md` — standard and agent-driven workflow
762
- primitives; `ai_agents`, `context_mapping_config`, and
763
- agent-output decision interpolation
764
- - `.agent/interoperability_contracts.md` — custom contract shape,
765
- `template_config` vs `mdm_input_config`
766
- - `.agent/cookbook/_fixtures/payments/` — the vendored Atomic
767
- FI compliance-screening / MDM Liquid templates §007 loads
768
- - `integration-tests/tests/payments/sanctions-review-workflow.test.ts` —
769
- the anchor green test these snippets are lifted from
770
- - `integration-tests/tests/payments/interoperability-contracts.test.ts`,
771
- `run-dac-single.test.ts`, `create-dac.test.ts`, `tools.test.ts`,
772
- `data-sources.test.ts` — the per-resource create + ingest
773
- snippets §005–§010 are lifted from