@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,383 +0,0 @@
1
- ---
2
- title: "Capability: poll a paginated provider API for delivery status (Mailgun-style)"
3
- summary: A capability walk for restapi action status updaters. Register a REST poller tool against a provider's paginated events API (Mailgun-shaped here) and an action status updater whose request templates FOLLOW the pagination cursor — page 1 renders the collection path, later pages ride `msg.pagination_context.next`. The platform refuses a poller whose request can never advance (`api.actionStatusUpdaters.create` 422), so the guard is walked first, then the accepted shape.
4
- industry: foundation
5
- slug: paginated-restapi-poller
6
- vitest_source:
7
- - integration-tests/tests/foundation/action-status-updaters.test.ts
8
- - integration-tests/tests/foundation/bootstrap.test.ts
9
- status: green
10
- ---
11
-
12
- # Capability
13
-
14
- **What you get:** delivery outcomes pulled from a provider's REST events API —
15
- paginated, cursor-driven — written back onto the messages you sent, on a cron,
16
- with the platform enforcing that your poll can actually terminate.
17
-
18
- A **restapi action status updater** polls an HTTP events endpoint instead of a
19
- log group:
20
-
21
- - a **REST poller tool** (`intent: 'status_poller'`, `tool_body_type:
22
- 'rest_api'`) supplies the base URL + auth,
23
- - the ASU's `updater_body` renders the request per page: `path`, `params`,
24
- an `events_template` that extracts the page's events as a JSON array, and a
25
- `pagination_context_template` that captures `has_next` + the provider's
26
- cursor,
27
- - on every page after the first, the driver binds what your pagination
28
- template captured as `msg.pagination_context` (and the page number as
29
- `msg.page`) into your `path`/`params` — **your templates must read one of
30
- them, or the request is byte-identical for every page and the poll can
31
- never advance. The platform rejects that config at create.**
32
-
33
- Mailgun's events API is the shape walked here (`GET /v3/<domain>/events` with
34
- a `paging.next` cursor link), but any cursor- or page-numbered API fits. See
35
- `action_status_updaters.md` §7 for the wire reference.
36
-
37
- # Walkthrough
38
-
39
- The `_setup/foundation.md` bootstrap left `api`, `tenantSlug`, `datalakeSlug`,
40
- and `ctx.datalakeId` populated.
41
-
42
- ## 001 — register a data source for the tools
43
-
44
- Both the sender and the poller attach to a data source — the origin
45
- registration for the messaging provider.
46
-
47
- ```typescript
48
- const { data: ds } = await api.dataSources.create(tenantSlug, datalakeSlug, {
49
- name: `Poller Source ${runSuffix}`,
50
- uri: 'mailgun.local',
51
- description: 'Messaging-provider origin for the paginated delivery poller.',
52
- status: 'active',
53
- is_default: false,
54
- })
55
- dataSourceId = ds.id!
56
- ```
57
-
58
- ## 002 — create the sender tool whose messages get reconciled
59
-
60
- The ASU updates messages a sender produced; `sender_tool_ids` will point here.
61
- An SNS-backed SMS sender (LocalStack locally) plays that role.
62
-
63
- ```typescript
64
- const { data: smsTool } = await api.tools.create(tenantSlug, datalakeSlug, {
65
- name: `Poller SMS Sender ${runSuffix}`,
66
- description: 'Sender whose delivery status the paginated poller reconciles.',
67
- intent: 'sms',
68
- status: 'active',
69
- datalake_id: ctx.datalakeId,
70
- data_source_id: dataSourceId,
71
- body: {
72
- tool_body_type: 'sns',
73
- auth_method: 'access_key',
74
- region: 'us-east-1',
75
- phone_number: '+15551234567',
76
- endpoint_url: 'http://localhost:4566',
77
- access_key_id: 'test',
78
- secret_access_key: 'test',
79
- },
80
- })
81
- ctx.senderToolId = smsTool.id!
82
- ```
83
-
84
- ## 003 — create the REST poller tool
85
-
86
- The poller supplies base URL + auth for the provider's events API —
87
- Mailgun-shaped here (basic auth, `api` / API key). `intent:
88
- 'status_poller'` tags it as a reconciler source, `tool_body_type: 'rest_api'`
89
- makes the ASU's requests ride this tool's HTTP client.
90
-
91
- ```typescript
92
- const { data: restTool } = await api.tools.create(tenantSlug, datalakeSlug, {
93
- name: `Mailgun Events Poller ${runSuffix}`,
94
- description: 'REST poller — supplies auth for restapi ActionStatusUpdater delivery polling',
95
- intent: 'status_poller',
96
- status: 'active',
97
- datalake_id: ctx.datalakeId,
98
- data_source_id: dataSourceId,
99
- body: {
100
- tool_body_type: 'rest_api',
101
- base_url: 'http://localhost:8080/mailgun/v3',
102
- auth_method: 'basic',
103
- username: 'api',
104
- password: 'key-test',
105
- request_type: 'json',
106
- response_type: 'json',
107
- timeout_ms: 30000,
108
- },
109
- })
110
- ctx.restToolId = restTool.id!
111
- ```
112
-
113
- ## 004 — the pagination guard: a static request is refused at create
114
-
115
- First, the shape that does NOT work — and why the platform refuses it. This
116
- `path`/`params` pair reads neither `msg.pagination_context` nor `msg.page`,
117
- so page 500's request would be byte-identical to page 1's: the window never
118
- moves, `has_next` never goes false, and the run would re-apply the same
119
- events forever. The create is a 422; the walk catches it to prove the gate.
120
-
121
- ```typescript
122
- let rejected = false
123
- try {
124
- await api.actionStatusUpdaters.create(tenantSlug, datalakeSlug, {
125
- name: `Mailgun Delivery Poller ${runSuffix} non-advancing`,
126
- cron_expression: '*/30 * * * *',
127
- updater_type: 'restapi',
128
- updater_tool_id: ctx.restToolId,
129
- sender_tool_ids: [ctx.senderToolId],
130
- datalake_id: ctx.datalakeId,
131
- // FLOOR the platform enforces: an array whose items are objects listing
132
- // `external_id` in `required`. A bare `{ type: 'array' }` is a 422 at
133
- // create — every event has to name the message it reconciles.
134
- events_output_schema: {
135
- type: 'array',
136
- items: {
137
- type: 'object',
138
- required: ['external_id'],
139
- properties: { external_id: { type: 'string' } },
140
- },
141
- },
142
- pagination_context_output_schema: {
143
- type: 'object',
144
- required: ['has_next'],
145
- properties: { has_next: { type: 'boolean' } },
146
- },
147
- updater_body: {
148
- updater_body_type: 'restapi_request',
149
- method: 'get',
150
- // Static on both — the cursor is captured below and never read back.
151
- path: { type: 'custom', body: '/wiremock.domain/events' },
152
- params: { type: 'custom', body: '{"event": "delivered"}' },
153
- // Providers name their own id — `message-id` here, `messageId` / `sid`
154
- // elsewhere. The events_template is where that becomes `external_id`:
155
- // the apply path POPS that key off every rendered event to find the row
156
- // it updates, so the render PROJECTS each event rather than passing the
157
- // provider body through untouched.
158
- events_template: {
159
- type: 'custom',
160
- body:
161
- '[{% for item in response.items %}' +
162
- '{"external_id": "{{ item.message.headers[\'message-id\'] }}", "event": "{{ item.event }}"}' +
163
- '{% unless forloop.last %},{% endunless %}{% endfor %}]',
164
- },
165
- pagination_context_template: {
166
- type: 'custom',
167
- body:
168
- '{"has_next": {% if response.items.size > 0 %}true{% else %}false{% endif %}, ' +
169
- '"next": "{{ response.paging.next }}"}',
170
- },
171
- },
172
- message_config: {
173
- type: 'custom',
174
- body: '{"external_id": "{{ external_id }}", "status": "delivered"}',
175
- },
176
- action_log_config: {
177
- type: 'custom',
178
- body: '{"external_id": "{{ external_id }}", "status": "delivered"}',
179
- },
180
- })
181
- } catch (err) {
182
- const status = (err as { _httpStatus?: number })._httpStatus
183
- if (status !== 422) throw err
184
- rejected = true
185
- }
186
- if (!rejected) {
187
- throw new Error('expected a 422 for a poller whose request never consumes the cursor')
188
- }
189
- ```
190
-
191
- ## 005 — the accepted shape: the path follows the cursor
192
-
193
- Page 1 (`msg.pagination_context` is falsy) renders the plain collection path
194
- with bounded query params; every later page rides the cursor link the
195
- pagination template captured — and drops the params, because Mailgun's
196
- `paging.next` is a complete URL that already carries them.
197
-
198
- ```typescript
199
- const { data: asu } = await api.actionStatusUpdaters.create(tenantSlug, datalakeSlug, {
200
- name: `Mailgun Delivery Poller ${runSuffix} advancing`,
201
- cron_expression: '*/30 * * * *',
202
- updater_type: 'restapi',
203
- updater_tool_id: ctx.restToolId,
204
- sender_tool_ids: [ctx.senderToolId],
205
- datalake_id: ctx.datalakeId,
206
- events_output_schema: {
207
- type: 'array',
208
- items: {
209
- type: 'object',
210
- required: ['external_id'],
211
- properties: { external_id: { type: 'string' } },
212
- },
213
- },
214
- pagination_context_output_schema: {
215
- type: 'object',
216
- required: ['has_next'],
217
- properties: { has_next: { type: 'boolean' } },
218
- },
219
- updater_body: {
220
- updater_body_type: 'restapi_request',
221
- method: 'get',
222
- path: {
223
- type: 'custom',
224
- body:
225
- '{% if msg.pagination_context %}{{ msg.pagination_context.next }}' +
226
- '{% else %}/wiremock.domain/events{% endif %}',
227
- },
228
- params: {
229
- type: 'custom',
230
- body: '{% unless msg.pagination_context %}{"event": "delivered"}{% endunless %}',
231
- },
232
- // The provider's own id (`message-id`) becomes `external_id` HERE — the
233
- // apply path pops that key to find the row it reconciles, so project each
234
- // event instead of passing the provider body through untouched.
235
- events_template: {
236
- type: 'custom',
237
- body:
238
- '[{% for item in response.items %}' +
239
- '{"external_id": "{{ item.message.headers[\'message-id\'] }}", "event": "{{ item.event }}"}' +
240
- '{% unless forloop.last %},{% endunless %}{% endfor %}]',
241
- },
242
- pagination_context_template: {
243
- type: 'custom',
244
- body:
245
- '{"has_next": {% if response.items.size > 0 %}true{% else %}false{% endif %}, ' +
246
- '"next": "{{ response.paging.next }}"}',
247
- },
248
- },
249
- message_config: {
250
- type: 'custom',
251
- body: '{"external_id": "{{ external_id }}", "status": "delivered"}',
252
- },
253
- action_log_config: {
254
- type: 'custom',
255
- body: '{"external_id": "{{ external_id }}", "status": "delivered"}',
256
- },
257
- })
258
- actionStatusUpdaterId = asu.id!
259
- if (asu.status !== 'active') {
260
- throw new Error(`a newly created poller must be free to poll — got status ${asu.status}`)
261
- }
262
- ```
263
-
264
- ## 006 — discover it (list + metadata)
265
-
266
- The poller shows up in the paginated ASU list, and `metadataDetails` renders
267
- the markdown an agent reads to understand the reconciler.
268
-
269
- ```typescript
270
- const { data: list } = await api.actionStatusUpdaters.list(tenantSlug, datalakeSlug)
271
- if (!(list.data ?? []).some((u) => u.id === actionStatusUpdaterId)) {
272
- throw new Error('created restapi ASU not found in the list')
273
- }
274
-
275
- const { data: detail } = await api.actionStatusUpdaters.metadataDetails(tenantSlug, datalakeSlug, actionStatusUpdaterId)
276
- if (typeof detail !== 'string' || detail.length === 0) {
277
- throw new Error('expected non-empty ASU metadata details')
278
- }
279
- ```
280
-
281
- ## 007 — write the integration test
282
-
283
- End the build with a test you keep: re-read the poller and assert the
284
- facts the scenario depends on — the create was accepted (so the
285
- pagination guard passed), the row is free to poll, and the run surface is
286
- on the wire. This block runs live under `make validate-cookbook`.
287
-
288
- ```typescript
289
- // Re-GET — the stored row echoes the authored cron.
290
- const { data: poller } = await api.actionStatusUpdaters.get(tenantSlug, datalakeSlug, actionStatusUpdaterId)
291
- if (poller.cron_expression !== '*/30 * * * *') {
292
- throw new Error(`cron mismatch on read-back: ${poller.cron_expression}`)
293
- }
294
- // cycle_detected is only ever set at runtime by the poll driver —
295
- // a fresh create MUST read active.
296
- if (poller.status !== 'active') {
297
- throw new Error(`a fresh poller must be free to poll — got status ${poller.status}`)
298
- }
299
- // last_run_* is the ONLY run surface (no per-run log); fresh create ⇒
300
- // legitimately null. Assert the field EXISTS, never a value.
301
- if (!('last_run_status' in poller)) {
302
- throw new Error('poller response carries no last_run_status field')
303
- }
304
- // Behavioural probe — the metadata surface an agent reads must render.
305
- const { data: pollerDetail } = await api.actionStatusUpdaters.metadataDetails(tenantSlug, datalakeSlug, actionStatusUpdaterId)
306
- if (typeof pollerDetail !== 'string' || pollerDetail.length === 0) {
307
- throw new Error('poller metadataDetails came back empty')
308
- }
309
- ```
310
-
311
- If the poller stops reconciling in production, refresh once, re-read
312
- `last_run_error`, and escalate with that evidence — don't rewrite the
313
- config and wait for another tick.
314
-
315
- # Gotchas
316
-
317
- - **The pagination guard is create-time and non-negotiable.** If neither
318
- `path` nor `params` reads `msg.pagination_context` / `msg.page`, the create
319
- is a 422 — the request could never advance past page 1. The guard reads
320
- your template SOURCE, so a dead `{% if false %}{{ msg.page }}{% endif %}`
321
- won't fool a reviewer even where it fools a regex; write the real cursor
322
- read.
323
- - **Page 1 is the falsy-context branch.** `msg.pagination_context` is unset
324
- on the first page — `{% if msg.pagination_context %}…{% else %}<collection
325
- path>{% endif %}` is the canonical shape. Bound page 1's `params`
326
- with `{% unless msg.pagination_context %}` when the cursor link already
327
- carries the query (Mailgun's `paging.next` does).
328
- - **`has_next` decides termination — prefer the full-page heuristic in
329
- production.** The walked shape (`response.items.size > 0`) terminates on
330
- the first empty page, costing one extra request. Where the provider
331
- documents a page size, `has_next: {% if response.items.size == 300 %}` (a
332
- full page implies more) saves that call; a `paging.next` link that is
333
- absent on the last page is an even stronger signal.
334
- - **`events_template` must render a JSON ARRAY**, validated against
335
- `events_output_schema` on every cycle; the pagination render is validated
336
- against `pagination_context_output_schema` (which must require `has_next`).
337
- Both schemas are REQUIRED for `restapi` updaters — blank is a 422.
338
- - **Each schema has a FLOOR the platform enforces, above which the contract is
339
- yours.** `events_output_schema` must describe an **array whose `items` are
340
- objects listing `external_id` in `required`**; a bare `{ type: 'array' }` is a
341
- 422 at create. `pagination_context_output_schema` must be an **object listing
342
- `has_next` in `required`** — that key is what ends the page loop. Demand more
343
- of your provider on top if you like; only the floor is checked.
344
- - **The floor exists because `external_id` is how reconciliation finds the row.**
345
- `apply_status_update` pops that key off every rendered event, so mapping the
346
- provider's own id (`message-id` / `messageId` / `sid`) into `external_id` is
347
- the **`events_template`'s job** — project each event, never pass the provider
348
- body through untouched. A schema that satisfies the floor while the template
349
- emits raw provider rows creates cleanly and then reconciles nothing on every
350
- cycle. `message_config` / `action_log_config` then read the **mapped**
351
- `{{ external_id }}`, not the provider's original key.
352
- - **`action_log_config` is required alongside `message_config`** for every
353
- updater type, and both are cast against the *same* pinned reconciliation
354
- schema — so one template body satisfies both.
355
- - **A newly created poller is `status: 'active'`.** `cycle_detected` is only
356
- ever set at runtime by the poll driver — never by a caller; you cannot
357
- create your way into it.
358
- - **`action_log_config` is REQUIRED alongside `message_config`** on create
359
- AND update (PUT) — same per-event assigns, rendered into the action-log
360
- write shape (`external_id` + at least one of `status` / `sent_at` /
361
- `metadata`).
362
- - **One poll cycle can be fired on demand.**
363
- `api.actionStatusUpdaters.refresh(tenantSlug, datalakeSlug, id)` runs the
364
- cycle the cron would — `202` with the updater row AS-IS (the poll is
365
- async; re-read `last_run_status` / `last_run_events_found` /
366
- `last_run_error` for the outcome) — a day-two operate surface, not a
367
- build step. That outcome has three values: `'ok'` (the whole window was
368
- read), `'partial'` (the fetch was truncated — the newest events may be
369
- missing, with `last_run_error` explaining), and `'error'` (the run
370
- failed). Treat only `last_run_status === 'ok'` as a complete
371
- reconciliation; a `!== 'error'` check silently accepts a truncated
372
- `partial`.
373
-
374
- # See also
375
-
376
- - `action_status_updaters.md` §7 — restapi wire shape, the two output
377
- schemas, `msg.*` assigns
378
- - `tools.md` — `rest_api` poller body, `status_poller` intent
379
- - `cookbook/action-status-updaters.md` — the CloudWatch-flavoured sibling
380
- (log-group polling instead of HTTP pagination)
381
- - `_setup/foundation.md` — the bootstrap this walk starts from
382
- - `integration-tests/tests/foundation/action-status-updaters.test.ts` —
383
- the green test these calls are lifted from (§3d–§3f)
@@ -1,273 +0,0 @@
1
- ---
2
- title: "Capability: pull records from a third-party REST API on demand"
3
- summary: A capability walk for pull-based ingestion. Register a REST-API tool with its auth (Bearer here; an OAuth2 refresh-token variant is shown too), bind it to a contract and a Data Activation Client whose `tool_call` names the path to fetch, trigger a fetch (`api.dataActivationClients.runManually`), and verify the rows landed. The platform calls the API for you — no file to upload.
4
- industry: subscription
5
- slug: rest-fetch
6
- vitest_source:
7
- - integration-tests/tests/subscription/run-dac-fetch.test.ts
8
- - integration-tests/tests/foundation/oauth2-dac-fetch.test.ts
9
- - integration-tests/tests/subscription/bootstrap.test.ts
10
- status: green
11
- ---
12
-
13
- # Capability
14
-
15
- **What you get:** records pulled straight from a third-party REST API into your
16
- dataset on demand, with the platform handling auth and the HTTP call — no file
17
- to export and upload.
18
-
19
- A **pull-based** Data Activation Client fetches instead of receiving:
20
-
21
- - the **tool** holds the API's base URL + auth (`bearer`, or `oauth2` with a
22
- refresh-token grant the platform exchanges server-side before each call),
23
- - the **DAC's `tool_call`** names the HTTP `method` + `path` to hit and how to
24
- paginate; a `response_extractor` unwraps the API's envelope into a row array,
25
- - `dataActivationClients.runManually(...)` triggers a fetch and returns a
26
- `batch_id`.
27
-
28
- This is **global** — only the API and its auth differ. See
29
- `data_activation_clients.md` §6.3 (`.runManually`) and `tools.md` (`rest_api`
30
- body) for the reference.
31
-
32
- # Walkthrough
33
-
34
- The `_setup/subscription.md` bootstrap left `api`, `tenantSlug`,
35
- `datalakeSlug`, and `ctx.datalakeId` populated. The fetch targets a mock Stripe
36
- API; locally that is the integration stack's WireMock on `localhost:8080`.
37
-
38
- ## 001 — create the data source
39
-
40
- ```typescript
41
- const { data: ds } = await api.dataSources.create(tenantSlug, datalakeSlug, {
42
- name: `REST Stripe Source ${runSuffix}`,
43
- uri: 'api.stripe.com',
44
- description: 'Stripe REST API — origin of the pulled customer rows.',
45
- status: 'active',
46
- is_default: false,
47
- })
48
- dataSourceId = ds.id!
49
- ```
50
-
51
- ## 002 — create the REST-API tool (Bearer auth)
52
-
53
- The tool holds the API's `base_url` and credentials. `auth_method: 'bearer'`
54
- with a `bearer_token` is the simplest case; `status: 'active'` is required (a
55
- draft tool is skipped at fetch time — see Gotchas).
56
-
57
- ```typescript
58
- const { data: tool } = await api.tools.create(tenantSlug, datalakeSlug, {
59
- name: `Stripe REST Tool ${runSuffix}`,
60
- description: 'WireMock-mocked Stripe REST API (AR customer fetch).',
61
- intent: 'data_exchange',
62
- status: 'active',
63
- datalake_id: ctx.datalakeId,
64
- data_source_id: dataSourceId,
65
- body: {
66
- tool_body_type: 'rest_api',
67
- auth_method: 'bearer',
68
- base_url: 'http://localhost:8080/stripe',
69
- bearer_token: 'sk_test_vitest_stripe_token',
70
- request_type: 'json',
71
- response_type: 'json',
72
- timeout_ms: 30_000,
73
- },
74
- })
75
- ctx.restToolId = tool.id!
76
- ```
77
-
78
- ## 003 — create the customer interoperability contract
79
-
80
- The contract shapes each fetched customer into a `Customer` upsert. Reuse the
81
- vendored production Stripe customer + MDM templates.
82
-
83
- ```typescript
84
- const { readFileSync } = await import('node:fs')
85
- const { join } = await import('node:path')
86
- const customerTemplate = readFileSync(
87
- join(process.env.COOKBOOK_FIXTURES_DIR!, 'subscription/_customers_subscription_customer.liquid'),
88
- 'utf8',
89
- )
90
- const mdmTemplate = readFileSync(
91
- join(process.env.COOKBOOK_FIXTURES_DIR!, 'subscription/_customers_subscription_mdm.liquid'),
92
- 'utf8',
93
- )
94
-
95
- const { data: contract } = await api.interoperabilityContracts.create(tenantSlug, datalakeSlug, {
96
- name: `Stripe REST Customer Contract ${runSuffix}`,
97
- description: 'Stripe customer fetch → Subscription Customer.',
98
- resource_type: 'customer',
99
- template_config: { type: 'custom', body: customerTemplate },
100
- mdm_input_config: { type: 'custom', body: mdmTemplate },
101
- generic_table_id: null,
102
- })
103
- interopContractId = contract.id!
104
- ```
105
-
106
- ## 004 — create the fetch DAC
107
-
108
- The Data Activation Client's `tool_call` selects the REST path: `restapi_request`
109
- with the HTTP `method` and the `path` to hit (`/v1/customers`, appended to the
110
- tool's `base_url`). `pagination_context_template` declares no further pages here.
111
- A top-level `response_extractor` unwraps the API's `{ data: [...] }` envelope so
112
- each element becomes one row.
113
-
114
- ```typescript
115
- const { data: dac } = await api.dataActivationClients.create(tenantSlug, datalakeSlug, {
116
- name: `Stripe REST Fetch DAC ${runSuffix}`,
117
- description: 'Pull-based DAC — fetches Stripe customers over REST.',
118
- tool_id: ctx.restToolId,
119
- data_source_id: dataSourceId,
120
- tool_call: {
121
- tool_call_type: 'restapi_request',
122
- method: 'get',
123
- path: { type: 'custom', body: '/v1/customers' },
124
- pagination_context_template: { type: 'custom', body: '{"has_next": false}' },
125
- },
126
- response_extractor: { type: 'custom', body: '{{ msg.data | to_json }}' },
127
- interoperability_contract_ids: [interopContractId],
128
- })
129
- dacId = dac.id!
130
- ctx.dacSlug = dac.slug!
131
- ```
132
-
133
- ## 005 — trigger a fetch
134
-
135
- `runManually` enqueues a fetch and returns the `batch_id` immediately; the actual
136
- HTTP call + ingestion run in a background worker.
137
-
138
- ```typescript
139
- const { data: run } = await api.dataActivationClients.runManually(tenantSlug, datalakeSlug, ctx.dacSlug)
140
- ctx.batchId = run.batch_id!
141
- if (!ctx.batchId) {
142
- throw new Error('runManually did not return a batch_id')
143
- }
144
- ```
145
-
146
- ## 006 — verify the fetched rows landed
147
-
148
- Poll the DAC logs until the batch shows a fully-merged ingest (the mock returns 3
149
- customers), then search the `customer` dataset scoped to the batch.
150
-
151
- ```typescript
152
- const deadline = Date.now() + 90_000
153
- let merged = false
154
- while (Date.now() < deadline && !merged) {
155
- const { data } = await api.dataActivationClients.logs.list(tenantSlug, datalakeSlug, ctx.dacSlug)
156
- merged = (data.data ?? []).some((r) => {
157
- const row = r as Record<string, unknown>
158
- return row.batch_id === ctx.batchId &&
159
- typeof row.rows_ingested === 'number' && row.rows_ingested >= 3 &&
160
- Array.isArray(row.output_files) && row.output_files.length > 0
161
- })
162
- if (!merged) await new Promise((r) => setTimeout(r, 1_000))
163
- }
164
- if (!merged) {
165
- throw new Error('REST fetch did not produce a merged batch within 90s')
166
- }
167
-
168
- const { data: userSearch } = await api.datasets.createUserSearch(tenantSlug, datalakeSlug, 'customer', {
169
- search_query: `ra.batch_id = '${ctx.batchId}'`,
170
- })
171
- const searchDeadline = Date.now() + 60_000
172
- let rows: Array<Record<string, unknown>> = []
173
- while (Date.now() < searchDeadline && rows.length < 3) {
174
- const { data: page } = await api.datasets.search(tenantSlug, datalakeSlug, 'customer', {
175
- userSearchId: userSearch.id!,
176
- dataAccessMode: 'unregulated',
177
- })
178
- rows = (page.data ?? []) as Array<Record<string, unknown>>
179
- if (rows.length < 3) await new Promise((r) => setTimeout(r, 1_000))
180
- }
181
- if (rows.length < 3) {
182
- throw new Error(`expected 3 fetched customers, found ${rows.length}`)
183
- }
184
- ```
185
-
186
- ## 007 — variant: an OAuth2 refresh-token tool
187
-
188
- When the API uses OAuth2, the tool carries the full `oauth2_*` grant config
189
- instead of a static token. The platform exchanges the `oauth2_refresh_token` for
190
- an access token **server-side before each fetch** — your code never handles the
191
- token. This step creates such a tool to show the body shape (the fetch itself
192
- runs the same `runManually` flow once bound to a DAC + contract for that API).
193
-
194
- ```typescript
195
- const { data: oauthTool } = await api.tools.create(tenantSlug, datalakeSlug, {
196
- name: `OAuth2 REST Tool ${runSuffix}`,
197
- description: 'REST tool authenticating via an OAuth2 refresh-token grant.',
198
- intent: 'data_exchange',
199
- status: 'active',
200
- datalake_id: ctx.datalakeId,
201
- data_source_id: dataSourceId,
202
- body: {
203
- tool_body_type: 'rest_api',
204
- auth_method: 'oauth2',
205
- base_url: 'http://localhost:8080/quickbooks',
206
- oauth2_grant_type: 'authorization_code',
207
- oauth2_client_id: 'test_client_id',
208
- oauth2_client_secret: 'test_client_secret',
209
- oauth2_token_url: 'http://localhost:8080/quickbooks/oauth2/v1/tokens/bearer',
210
- oauth2_refresh_token: 'test_initial_refresh_token',
211
- oauth2_scope: 'com.intuit.quickbooks.accounting',
212
- oauth2_token_ttl: 3300,
213
- request_type: 'json',
214
- response_type: 'json',
215
- timeout_ms: 30_000,
216
- },
217
- })
218
- if (!oauthTool.id) {
219
- throw new Error('OAuth2 tool create did not return an id')
220
- }
221
- ```
222
-
223
- ## 008 — write the integration test
224
-
225
- End the build with a test you keep: trigger one pull and assert the
226
- enqueue acknowledgement. `runManually` answers with the allocated
227
- `batch_id` BEFORE the fetch runs — the HTTP call and the ingestion drain
228
- on a background worker — so the durable test asserts on the call's own
229
- response, never on polling the merge (that diagnosis walk lives in §006).
230
- This block runs live under `make validate-cookbook`.
231
-
232
- ```typescript
233
- // Re-GET — the client must still be bound to the REST tool.
234
- const { data: fetchDac } = await api.dataActivationClients.get(tenantSlug, datalakeSlug, dacId)
235
- if (fetchDac.tool_id !== ctx.restToolId) {
236
- throw new Error('DAC is no longer bound to the REST fetch tool')
237
- }
238
- // Behavioural probe — one manual pull; the ack carries the batch_id
239
- // the worker will report under.
240
- const { data: probePull } = await api.dataActivationClients.runManually(tenantSlug, datalakeSlug, ctx.dacSlug)
241
- if (typeof probePull.batch_id !== 'string' || probePull.batch_id.length === 0) {
242
- throw new Error('runManually probe returned no batch_id')
243
- }
244
- ```
245
-
246
- If the probe fails in production, escalate with the response — and if
247
- the enqueue succeeds but rows never land, the evidence to escalate with
248
- is the batch's log row (§006's read), not a re-run storm.
249
-
250
- # Gotchas
251
-
252
- - **A draft tool is silently skipped at fetch time.** The worker only fetches
253
- through `status: 'active'` tools — a `draft` tool produces zero log rows (no
254
- error, no data). If a fetch ingests nothing, check the tool status first.
255
- - **The `response_extractor` unwraps the API envelope.** REST APIs wrap rows in
256
- an envelope (`{ data: [...] }`); the extractor (`{{ msg.data | to_json }}`)
257
- turns that into the row array the contract maps. Without it, the whole envelope
258
- is treated as one row.
259
- - **OAuth2 token exchange is server-side.** With `auth_method: 'oauth2'`, the
260
- platform swaps the refresh token for an access token before each call — your
261
- code never sees a token. Bad credentials fail the exchange and produce zero log
262
- rows (same silent-skip shape as a draft tool).
263
- - **`runManually` returns immediately.** The `batch_id` comes back before the
264
- fetch runs; poll the logs to know when rows actually landed.
265
-
266
- # See also
267
-
268
- - `data_activation_clients.md` §6.3 — `.runManually` + the pull-based reference
269
- - `tools.md` — the `rest_api` tool body, `bearer` vs `oauth2` auth
270
- - `_setup/subscription.md` — the bootstrap this walk starts from
271
- - `integration-tests/tests/subscription/run-dac-fetch.test.ts`,
272
- `integration-tests/tests/foundation/oauth2-dac-fetch.test.ts` — the green tests
273
- these calls are lifted from