@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.
Files changed (66) hide show
  1. package/.agent/AGENTS.md +499 -0
  2. package/.agent/account_management.md +456 -0
  3. package/.agent/action_status_updaters.md +264 -0
  4. package/.agent/ai_agents.md +462 -0
  5. package/.agent/ai_sandbox.md +265 -0
  6. package/.agent/async.md +112 -0
  7. package/.agent/connected_apps.md +408 -0
  8. package/.agent/cookbook/_fixtures/README.md +106 -0
  9. package/.agent/cookbook/_fixtures/accounts_receivable/_customers_accounts_receivable_customer.liquid +32 -0
  10. package/.agent/cookbook/_fixtures/accounts_receivable/_customers_accounts_receivable_mdm.liquid +20 -0
  11. package/.agent/cookbook/_fixtures/accounts_receivable/stripe_customers_batch1.csv +5 -0
  12. package/.agent/cookbook/_fixtures/foundation/_lead_submissions_foundation_generic_table.liquid +33 -0
  13. package/.agent/cookbook/_fixtures/foundation/_lead_submissions_foundation_legal_entity.liquid +88 -0
  14. package/.agent/cookbook/_fixtures/foundation/_lead_submissions_foundation_mdm.liquid +48 -0
  15. package/.agent/cookbook/_fixtures/healthcare/_cahps_appointments_healthcare_appointment.liquid +47 -0
  16. package/.agent/cookbook/_fixtures/healthcare/_cahps_appointments_healthcare_mdm.liquid +24 -0
  17. package/.agent/cookbook/_fixtures/healthcare/_cahps_appointments_healthcare_patient.liquid +38 -0
  18. package/.agent/cookbook/_fixtures/healthcare/memorandum-of-association-01.png +0 -0
  19. package/.agent/cookbook/_fixtures/healthcare/sample_two_page.pdf +43 -0
  20. package/.agent/cookbook/_fixtures/payment_risk/_compliance_screenings_payment_risk_compliance_screening.liquid +59 -0
  21. package/.agent/cookbook/_fixtures/payment_risk/_compliance_screenings_payment_risk_mdm.liquid +36 -0
  22. package/.agent/cookbook/_fixtures/payment_risk/_payment_accounts_payment_risk_mdm.liquid +30 -0
  23. package/.agent/cookbook/_fixtures/payment_risk/_payment_accounts_payment_risk_payment_account.liquid +55 -0
  24. package/.agent/cookbook/_setup/accounts_receivable.md +282 -0
  25. package/.agent/cookbook/_setup/foundation.md +277 -0
  26. package/.agent/cookbook/_setup/healthcare.md +279 -0
  27. package/.agent/cookbook/_setup/payment_risk.md +283 -0
  28. package/.agent/cookbook/action-status-updaters.md +212 -0
  29. package/.agent/cookbook/ai-agent-invoke.md +243 -0
  30. package/.agent/cookbook/appointment-review-sms-workflow.md +761 -0
  31. package/.agent/cookbook/birthday-greeting-sms-trigger.md +656 -0
  32. package/.agent/cookbook/bulk-ingest.md +254 -0
  33. package/.agent/cookbook/contact-us-triage-with-llm.md +622 -0
  34. package/.agent/cookbook/custom-tables.md +201 -0
  35. package/.agent/cookbook/dunning-sms-for-delinquent.md +619 -0
  36. package/.agent/cookbook/invite-team.md +194 -0
  37. package/.agent/cookbook/kyc-notification-on-account-activation.md +619 -0
  38. package/.agent/cookbook/rest-fetch.md +246 -0
  39. package/.agent/cookbook/sanctions-screening-with-agent-review.md +733 -0
  40. package/.agent/cookbook/score-leads-with-llm-categorization.md +624 -0
  41. package/.agent/cookbook/system-templates.md +129 -0
  42. package/.agent/cookbook/triage-prospects-by-priority.md +533 -0
  43. package/.agent/cookbook/welcome-sms-for-customers.md +607 -0
  44. package/.agent/data_activation_clients.md +559 -0
  45. package/.agent/data_sources.md +235 -0
  46. package/.agent/datalakes.md +714 -0
  47. package/.agent/debugging.md +137 -0
  48. package/.agent/errors.md +190 -0
  49. package/.agent/generic_tables.md +351 -0
  50. package/.agent/interoperability_contracts.md +417 -0
  51. package/.agent/mdm.md +293 -0
  52. package/.agent/mutations.md +126 -0
  53. package/.agent/templates.md +98 -0
  54. package/.agent/tool-call-configs.md +90 -0
  55. package/.agent/tools.md +547 -0
  56. package/.agent/type_naming.md +129 -0
  57. package/.agent/workflows.md +617 -0
  58. package/README.md +178 -0
  59. package/dist/bin/platform-sdk.d.mts +1 -0
  60. package/dist/bin/platform-sdk.mjs +106 -0
  61. package/dist/bin/platform-sdk.mjs.map +1 -0
  62. package/dist/index.d.mts +1310 -44116
  63. package/dist/index.d.mts.map +1 -1
  64. package/dist/index.mjs +1194 -7344
  65. package/dist/index.mjs.map +1 -1
  66. package/package.json +19 -10
@@ -0,0 +1,733 @@
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: payment_risk
5
+ slug: sanctions-screening-with-agent-review
6
+ vitest_source:
7
+ - integration-tests/tests/payment_risk/sanctions-review-workflow.test.ts
8
+ - integration-tests/tests/payment_risk/interoperability-contracts.test.ts
9
+ - integration-tests/tests/payment_risk/run-dac-single.test.ts
10
+ - integration-tests/tests/payment_risk/create-dac.test.ts
11
+ - integration-tests/tests/payment_risk/tools.test.ts
12
+ - integration-tests/tests/payment_risk/data-sources.test.ts
13
+ - integration-tests/tests/payment_risk/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/payment_risk/sanctions-review-workflow.test.ts`
53
+ — a green end-to-end test. The setup file `_setup/payment_risk.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/payment_risk.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
+ filter_config: {
274
+ type: 'custom',
275
+ body: GRAY_ZONE_FILTER,
276
+ output_schema: { type: 'boolean' },
277
+ },
278
+ decision_config: {
279
+ type: 'custom',
280
+ body: DECISION_CONFIG_BODY,
281
+ output_schema: DECISION_OUTPUT_SCHEMA,
282
+ },
283
+ actions: VERDICTS.map((verdict) => ({
284
+ decision_key: verdict,
285
+ action_type: 'sms',
286
+ tool_id: toolId,
287
+ position: 0,
288
+ trigger_template: 'now',
289
+ idempotency_template: `{{ compliance_screening.id }}-${verdict}-{{ "" | uuid }}`,
290
+ tool_call: {
291
+ tool_call_type: 'sms_request',
292
+ to: { type: 'custom', body: '+15550000000' },
293
+ body: {
294
+ type: 'custom',
295
+ body: `[${verdict}] {{ additional_context["${ctx.agentSlug}"].comments }}`,
296
+ },
297
+ sms_type: 'transactional',
298
+ },
299
+ })),
300
+ // Nest the sanctions-review agent inline. Workflow joins omit
301
+ // output_schema — the server pins it from the agent's input_schema.
302
+ workflow_ai_agents: [
303
+ {
304
+ ai_agent_id: aiAgentId,
305
+ position: 0,
306
+ context_mapping_config: { type: 'custom', body: CONTEXT_MAPPING_BODY },
307
+ },
308
+ ],
309
+ })
310
+ workflowId = workflowResp.data.id!
311
+ ctx.workflowSlug = workflowResp.data.slug!
312
+ ```
313
+
314
+ ## 005 — create the Atomic FI data source
315
+
316
+ A workflow runs on rows; rows arrive through the data-activation
317
+ chain. The chain's first link is a `DataSource` — a registration
318
+ of where the rows originate. The `uri` is the system-of-record
319
+ address; it flows into each ingested row's `source_uri`.
320
+
321
+ ```typescript
322
+ const dataSourceResp = await api.dataSources.create(tenantSlug, datalakeSlug, {
323
+ name: `Cookbook Atomic FI Source ${runSuffix}`,
324
+ uri: 'api.atomic.fi',
325
+ description: 'Atomic FI compliance API — origin of the compliance-screening rows the workflow runs on.',
326
+ status: 'active',
327
+ is_default: false,
328
+ })
329
+ dataSourceId = dataSourceResp.data.id!
330
+ ```
331
+
332
+ ## 006 — create the Manual Upload tool
333
+
334
+ The Data Activation Client needs a tool. For inline-JSON ingest a
335
+ `manual_upload` tool is the minimal choice — `intent:
336
+ 'data_exchange'` distinguishes it from the SMS tool, and
337
+ `tool_body_type: 'manual_upload'` needs no endpoint or credential
338
+ wiring (the rows arrive in the ingest call body, not by the tool
339
+ fetching them).
340
+
341
+ ```typescript
342
+ const manualUploadToolResp = await api.tools.create(tenantSlug, datalakeSlug, {
343
+ name: `Cookbook Manual Upload Tool ${runSuffix}`,
344
+ description: 'Manual-upload data-exchange tool — backs the DAC that ingests compliance-screening rows.',
345
+ intent: 'data_exchange',
346
+ status: 'active',
347
+ datalake_id: ctx.datalakeId,
348
+ data_source_id: dataSourceId,
349
+ body: { tool_body_type: 'manual_upload' },
350
+ })
351
+ ctx.manualUploadToolId = manualUploadToolResp.data.id!
352
+ ```
353
+
354
+ ## 007 — create the Compliance Screening interoperability contract
355
+
356
+ The interoperability contract is the row-shaping rule: a Liquid
357
+ template that maps an inbound Atomic FI compliance-screening row
358
+ into a payment-risk `ComplianceScreening` upsert. This cookbook
359
+ loads the production Atomic FI compliance-screening + MDM
360
+ templates from the vendored fixtures directory.
361
+
362
+ `template_config` shapes the ComplianceScreening resource —
363
+ including the `screened_entity_name` field the changeset
364
+ tokenizes automatically. `mdm_input_config` shapes the MDM input
365
+ — the `account_holder_id` plus the entity name the platform's
366
+ master-data resolution keys on to find-or-create the LegalEntity
367
+ + AccountHolder pair the screening belongs to.
368
+
369
+ ```typescript
370
+ const { readFileSync } = await import('node:fs')
371
+ const { join } = await import('node:path')
372
+ const csTemplate = readFileSync(
373
+ join(process.env.COOKBOOK_FIXTURES_DIR!, 'payment_risk/_compliance_screenings_payment_risk_compliance_screening.liquid'),
374
+ 'utf8',
375
+ )
376
+ const mdmTemplate = readFileSync(
377
+ join(process.env.COOKBOOK_FIXTURES_DIR!, 'payment_risk/_compliance_screenings_payment_risk_mdm.liquid'),
378
+ 'utf8',
379
+ )
380
+
381
+ const contractResp = await api.interoperabilityContracts.create(tenantSlug, datalakeSlug, {
382
+ name: `Cookbook Atomic FI Compliance Screening Contract ${runSuffix}`,
383
+ description: 'Atomic FI compliance-screenings → PaymentRisk ComplianceScreening (custom Liquid + MDM input).',
384
+ resource_type: 'compliance_screening',
385
+ template_config: { type: 'custom', body: csTemplate },
386
+ mdm_input_config: { type: 'custom', body: mdmTemplate },
387
+ generic_table_id: null,
388
+ })
389
+ interopContractId = contractResp.data.id!
390
+ ```
391
+
392
+ ## 008 — create the manual-upload DAC
393
+
394
+ The Data Activation Client binds the three preceding pieces — the
395
+ manual-upload tool, the data source, and the interop contract —
396
+ into one ingestion endpoint. `tool_call.tool_call_type:
397
+ 'manual_upload'` selects the inline-JSON ingest path. The
398
+ server-derived `slug` is the handle §009 ingests rows against.
399
+
400
+ ```typescript
401
+ const dacResp = await api.dataActivationClients.create(tenantSlug, datalakeSlug, {
402
+ name: `Cookbook Compliance Screening DAC ${runSuffix}`,
403
+ description: 'Manual-upload DAC — ingests compliance-screening events into ComplianceScreening via the interop contract.',
404
+ tool_id: ctx.manualUploadToolId,
405
+ data_source_id: dataSourceId,
406
+ tool_call: { tool_call_type: 'manual_upload' },
407
+ interoperability_contract_ids: [interopContractId],
408
+ })
409
+ dacId = dacResp.data.id!
410
+ ctx.dacSlug = dacResp.data.slug!
411
+ ```
412
+
413
+ ## 009 — ingest two compliance-screening events
414
+
415
+ Two rows, ingested as inline JSON through the manual-upload DAC.
416
+ They differ in the field the workflow filter cares about:
417
+ `screening_score`. The first row scores `0.88` — squarely in the
418
+ gray zone `[0.80, 0.95)`, so the filter passes it to the agent.
419
+ The second scores `0.20` — an auto-clear, so the filter rejects
420
+ it before any agent call. Each row carries a distinct
421
+ `account_holder_id` so MDM resolves each to its own legal entity.
422
+ Each ingest call gets its own batch id; both are pinned for the
423
+ run scope in §011.
424
+
425
+ ```typescript
426
+ const grayZoneRow = {
427
+ compliance_screening_number: `CS-GRAY-${runSuffix}`,
428
+ account_holder_id: `AH-SR-${runSuffix}-gray`,
429
+ scope: 'account_holder',
430
+ screening_type: 'sanctions',
431
+ screening_status: 'pending',
432
+ screened_entity_type: 'individual',
433
+ sanctions_screening_status: 'match',
434
+ screening_score: 0.88,
435
+ match_count: 1,
436
+ screened_entity_name: 'Jane Cookbook-Disambiguating-Doe',
437
+ source_uri: 'api.atomic.fi',
438
+ }
439
+ const autoClearRow = {
440
+ compliance_screening_number: `CS-CLEAR-${runSuffix}`,
441
+ account_holder_id: `AH-SR-${runSuffix}-clear`,
442
+ scope: 'account_holder',
443
+ screening_type: 'sanctions',
444
+ screening_status: 'pass',
445
+ screened_entity_type: 'individual',
446
+ sanctions_screening_status: 'cleared',
447
+ screening_score: 0.2,
448
+ match_count: 0,
449
+ screened_entity_name: 'John Cookbook-Clear-Smith',
450
+ source_uri: 'api.atomic.fi',
451
+ }
452
+
453
+ const [grayZoneIngest, autoClearIngest] = await Promise.all([
454
+ api.dataActivationClients.ingest(tenantSlug, datalakeSlug, ctx.dacSlug, { data: grayZoneRow }),
455
+ api.dataActivationClients.ingest(tenantSlug, datalakeSlug, ctx.dacSlug, { data: autoClearRow }),
456
+ ])
457
+ ctx.batchGrayZone = grayZoneIngest.data.batch_id!
458
+ ctx.batchAutoClear = autoClearIngest.data.batch_id!
459
+ ```
460
+
461
+ ## 010 — wait for both ingest batches to reach steady-state
462
+
463
+ Ingestion is async — the DAC enqueues per-row jobs that the
464
+ `BatchMergeWorker` drains into the regulated compliance-screening
465
+ table. Poll the DAC's activation logs until both batches show a
466
+ row with `rows_ingested >= 1` and a non-empty `output_files`
467
+ array (the merged Parquet landed in object storage). Only then is
468
+ it safe to run the workflow against these rows.
469
+
470
+ ```typescript
471
+ const targetBatches = new Set([ctx.batchGrayZone, ctx.batchAutoClear])
472
+ const deadline = Date.now() + 90_000
473
+ let greenCount = 0
474
+ while (Date.now() < deadline) {
475
+ const { data } = await api.dataActivationClients.logs.list(tenantSlug, datalakeSlug, ctx.dacSlug)
476
+ const green = new Set<string>()
477
+ for (const row of (data.data ?? []) as Array<Record<string, unknown>>) {
478
+ const b = row.batch_id
479
+ if (typeof b !== 'string' || !targetBatches.has(b)) continue
480
+ if (typeof row.rows_ingested !== 'number' || row.rows_ingested < 1) continue
481
+ const files = row.output_files
482
+ if (!Array.isArray(files) || files.length === 0) continue
483
+ green.add(b)
484
+ }
485
+ greenCount = green.size
486
+ if (greenCount === targetBatches.size) break
487
+ await new Promise((r) => setTimeout(r, 1_000))
488
+ }
489
+ if (greenCount !== targetBatches.size) {
490
+ throw new Error(`only ${greenCount}/2 compliance-screening batches reached steady-state within 90s`)
491
+ }
492
+ ```
493
+
494
+ ## 011 — run the workflow against the two batches
495
+
496
+ `workflows.run` with `manual_override: false` evaluates the
497
+ filter, so the gray-zone filter genuinely routes each row. The
498
+ SQL where-clause scopes the run to exactly the two batches §009
499
+ ingested (`rcs` is the regulated-compliance-screening alias the
500
+ run-query exposes).
501
+
502
+ For the gray-zone row the run drives the full agent-driven
503
+ pipeline: filter → agent enrichment (the Ollama call that returns
504
+ the verdict) → decision (the verdict interpolated into the
505
+ decision array) → action fan-out. The auto-clear row is rejected
506
+ at the filter and never reaches the agent. Poll
507
+ `batchLogs.refresh` until the run leaves `:pending`; the window
508
+ is generous because the gray-zone row's enrichment is a live LLM
509
+ inference call.
510
+
511
+ ```typescript
512
+ const runResp = await api.workflows.run(tenantSlug, datalakeSlug, ctx.workflowSlug, {
513
+ sql_where_clause: `rcs.batch_id IN ('${ctx.batchGrayZone}', '${ctx.batchAutoClear}')`,
514
+ mode: 'live',
515
+ manual_override: false,
516
+ })
517
+ ctx.runLogId = runResp.data.workflow_run_log_id!
518
+ ctx.runBatchId = runResp.data.batch_id!
519
+
520
+ const deadline = Date.now() + 240_000
521
+ let status: string | null = null
522
+ while (Date.now() < deadline) {
523
+ const { data: log } = await api.workflows.batchLogs.refresh(tenantSlug, datalakeSlug, ctx.workflowSlug, ctx.runLogId)
524
+ status = log.status ?? null
525
+ if (status && status !== 'pending') break
526
+ await new Promise((r) => setTimeout(r, 2_000))
527
+ }
528
+ if (status === 'failed') throw new Error('agent-driven workflow run reached :failed')
529
+ if (!status || status === 'pending') {
530
+ throw new Error('workflow run did not leave :pending within 240s')
531
+ }
532
+ ```
533
+
534
+ ## 012 — verify the gray-zone filter routed and the agent steered the fan-out
535
+
536
+ Each row produced a Workflow Execution Log. The gray-zone
537
+ screening passes the filter, reaches the agent, and its WEL is
538
+ `:executing` or `:completed`. The auto-clear screening fails the
539
+ filter, so its WEL is `:filtered` — it never reached the agent. A
540
+ run where both passed — or both were filtered — would mean the
541
+ gray-zone band filter is not actually evaluating the score.
542
+
543
+ The pass-branch WEL carries exactly **two** action execution logs
544
+ — one per verdict — and exactly **one** is matched (`:pending` or
545
+ `:completed`, the verdict the agent emitted) while the other is
546
+ `:skipped`. That is the proof the agent's verdict actually steered
547
+ the decision rather than firing every action.
548
+
549
+ ```typescript
550
+ const { data: wfLogs } = await api.workflows.workflowLogs.list(tenantSlug, datalakeSlug, ctx.workflowSlug)
551
+ const ourWels = (wfLogs.data ?? []).filter(
552
+ (w) => (w as { batch_id?: string }).batch_id === ctx.runBatchId,
553
+ )
554
+ if (ourWels.length !== 2) {
555
+ throw new Error(`expected 2 WELs for the run, got ${ourWels.length}`)
556
+ }
557
+
558
+ const byStatus: Record<string, number> = {}
559
+ for (const w of ourWels) {
560
+ const st = (w as { status?: string }).status ?? 'unknown'
561
+ byStatus[st] = (byStatus[st] ?? 0) + 1
562
+ }
563
+ if ((byStatus.filtered ?? 0) !== 1) {
564
+ throw new Error(
565
+ `expected 1 :filtered WEL (the auto-clear screening) — distribution ${JSON.stringify(byStatus)}`,
566
+ )
567
+ }
568
+ const passWel = ourWels.find((w) => {
569
+ const st = (w as { status?: string }).status
570
+ return st === 'executing' || st === 'completed'
571
+ })
572
+ if (!passWel) {
573
+ throw new Error(`no pass-branch WEL — distribution ${JSON.stringify(byStatus)}`)
574
+ }
575
+
576
+ const aels =
577
+ (passWel as { action_execution_logs?: Array<{ status?: string; decision_key?: string }> })
578
+ .action_execution_logs ?? []
579
+ if (aels.length !== 2) {
580
+ throw new Error(`expected 2 AELs (one per verdict) on the gray-zone WEL, got ${aels.length}`)
581
+ }
582
+ const aelByStatus: Record<string, number> = {}
583
+ for (const ael of aels) {
584
+ const st = ael.status ?? 'unknown'
585
+ aelByStatus[st] = (aelByStatus[st] ?? 0) + 1
586
+ }
587
+ const matched = (aelByStatus.pending ?? 0) + (aelByStatus.completed ?? 0)
588
+ if (matched !== 1) {
589
+ throw new Error(`expected exactly 1 matched AEL, got ${matched} — ${JSON.stringify(aelByStatus)}`)
590
+ }
591
+ if ((aelByStatus.skipped ?? 0) !== 1) {
592
+ throw new Error(`expected 1 :skipped AEL — ${JSON.stringify(aelByStatus)}`)
593
+ }
594
+ const routed = aels.find((a) => a.status === 'pending' || a.status === 'completed')
595
+ if (!routed?.decision_key || !/^(verified|blocked)$/.test(routed.decision_key)) {
596
+ throw new Error(`matched AEL has unexpected decision_key ${routed?.decision_key}`)
597
+ }
598
+ ```
599
+
600
+ ## 013 — confirm the verdict-keyed SMS rendered
601
+
602
+ The matched action fired immediately (`trigger_template: 'now'`)
603
+ and persisted a row in the regulated `message` dataset, carrying
604
+ the fully-rendered SMS body. The body is prefixed with the
605
+ verdict decision_key — `[verified]` or `[blocked]` — followed by
606
+ the agent's free-text `comments`. For this cookbook's fixture
607
+ entity name the agent is prompted toward `verified`, so the
608
+ persisted SMS is prefixed `[verified]`. Search the message
609
+ dataset scoped to this workflow and assert the prefix.
610
+
611
+ ```typescript
612
+ const { data: userSearch } = await api.datasets.createUserSearch(tenantSlug, datalakeSlug, 'message', {
613
+ search_query: `rm.workflow_id = '${workflowId}'`,
614
+ })
615
+ if (userSearch.status !== 'completed') {
616
+ throw new Error(`message user-search status=${userSearch.status} error=${userSearch.error_message ?? '(none)'}`)
617
+ }
618
+
619
+ const deadline = Date.now() + 45_000
620
+ let messages: Array<Record<string, unknown>> = []
621
+ while (Date.now() < deadline && messages.length === 0) {
622
+ const { data } = await api.datasets.search(tenantSlug, datalakeSlug, 'message', {
623
+ userSearchId: userSearch.id!,
624
+ dataAccessMode: 'regulated',
625
+ })
626
+ messages = (data.data ?? []) as Array<Record<string, unknown>>
627
+ if (messages.length === 0) await new Promise((r) => setTimeout(r, 1_000))
628
+ }
629
+ if (messages.length === 0) {
630
+ throw new Error('no verdict SMS message persisted for the workflow within 45s')
631
+ }
632
+
633
+ const verdictBody = messages
634
+ .map((m) => String(m.body ?? ''))
635
+ .find((body) => body.includes('[verified]') || body.includes('[blocked]'))
636
+ if (!verdictBody) {
637
+ throw new Error(`no verdict-prefixed SMS body — bodies: ${JSON.stringify(messages.map((m) => m.body))}`)
638
+ }
639
+ if (!verdictBody.includes('[verified]')) {
640
+ throw new Error(`expected the agent verdict to render [verified] — got: ${verdictBody}`)
641
+ }
642
+ ```
643
+
644
+ # Branches
645
+
646
+ - **The auto-clear screening is filtered, not failed** — §012
647
+ asserts the `0.20`-scored screening's WEL is `:filtered`, a
648
+ distinct terminal status from `:failed`. A filtered row is a
649
+ *correct* outcome: the workflow looked at it, the gray-zone
650
+ filter rendered empty because the score was below `0.80`, and
651
+ the platform recorded the row as intentionally skipped. The
652
+ agent is never called for a filtered row — which is the whole
653
+ point of the band filter: do not spend an LLM inference on a
654
+ screening a threshold already settles.
655
+ - **The blocked verdict** — the agent's response schema admits
656
+ `blocked` as well as `verified`. A gray-zone screening on a
657
+ genuinely sanctioned entity would have the agent emit `blocked`,
658
+ the decision interpolate `["blocked"]`, and the blocked-keyed
659
+ SMS fire instead. This cookbook's fixture entity name is
660
+ deliberately disambiguating so the run is deterministic; a
661
+ production deployment lets the model's judgement pick the
662
+ branch.
663
+ - **Agent emits an out-of-enum verdict** — the LLM response
664
+ schema's `enum` constraint guards the decision interpolation.
665
+ If the model returned an unexpected string the workflow row's
666
+ execution log would land `:failed`. Production deployments
667
+ either tighten the prompt or add a fallback action.
668
+
669
+ # Rollback
670
+
671
+ The cookbook doctest harness does not currently tear down created
672
+ resources. The `_setup/payment_risk.md` setup file's
673
+ runSuffix-scoped tenant / datalake / user names mean each run is
674
+ naturally isolated; the seeded local DB is cheap to reset
675
+ (`mix ecto.reset` on the platform repo).
676
+
677
+ # Outcome
678
+
679
+ After this cookbook's thirteen steps run green:
680
+
681
+ - A payment-risk tenant exists with a payment-risk-domain
682
+ datalake
683
+ - An SMS tool, a chat-completion LLM tool, a Sanctions Review AI
684
+ agent (qwen3-vl:8b-instruct, verified/blocked response schema), and a
685
+ Sanctions Review agent-driven workflow are all registered
686
+ - An Atomic FI data source, a Manual Upload tool, a Compliance
687
+ Screening interop contract, and a manual-upload DAC form a
688
+ working ingestion chain
689
+ - Two compliance screenings have been ingested through that chain
690
+ — one gray-zone-scored, one auto-clear
691
+ - Running the workflow routed them correctly: the gray-zone
692
+ screening passed the band filter, reached the agent, and fanned
693
+ out to a verdict-keyed SMS; the auto-clear screening was
694
+ `:filtered` before any agent call
695
+ - The pass-branch WEL is verified row-by-row — two AELs, exactly
696
+ one matched and one `:skipped`, proving the agent's verdict
697
+ steered the decision
698
+ - The rendered SMS in the `message` dataset carries the
699
+ `[verified]` verdict prefix
700
+
701
+ The business outcome — gray-zone sanctions screenings
702
+ disambiguated by an LLM and routed to a verdict-specific SMS,
703
+ with clear-cut screenings filtered out of the agent path
704
+ entirely — is demonstrated end-to-end, not merely provisioned.
705
+
706
+ # See also
707
+
708
+ - `_setup/payment_risk.md` — the inlined bootstrap that
709
+ provisions the tenant + datalake this cookbook starts from
710
+ - `kyc-notification-on-account-activation.md` — the
711
+ static-decision payment-risk cookbook; same SMS wiring, full
712
+ data-activation chain, no agent
713
+ - `score-leads-with-llm-categorization.md`,
714
+ `contact-us-triage-with-llm.md` — the foundation and healthcare
715
+ agent-driven cookbooks; same agent + decision-interpolation
716
+ shape
717
+ - `.agent/tools.md` — SMS and chat-completion tool body shapes;
718
+ intent classification
719
+ - `.agent/ai_agents.md` — AI agent registration shape; input +
720
+ response schemas; `data_access` regulated vs unregulated
721
+ - `.agent/workflows.md` — standard and agent-driven workflow
722
+ primitives; `ai_agents`, `context_mapping_config`, and
723
+ agent-output decision interpolation
724
+ - `.agent/interoperability_contracts.md` — custom contract shape,
725
+ `template_config` vs `mdm_input_config`
726
+ - `.agent/cookbook/_fixtures/payment_risk/` — the vendored Atomic
727
+ FI compliance-screening / MDM Liquid templates §007 loads
728
+ - `integration-tests/tests/payment_risk/sanctions-review-workflow.test.ts` —
729
+ the anchor green test these snippets are lifted from
730
+ - `integration-tests/tests/payment_risk/interoperability-contracts.test.ts`,
731
+ `run-dac-single.test.ts`, `create-dac.test.ts`, `tools.test.ts`,
732
+ `data-sources.test.ts` — the per-resource create + ingest
733
+ snippets §005–§010 are lifted from