@xentom/integration-framework 0.0.17 → 0.0.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md ADDED
@@ -0,0 +1,438 @@
1
+ # @xentom/integration-framework — Agent Guide
2
+
3
+ Concise, directive reference for AI agents building integrations with this framework.
4
+ This is the only file you need to read. For type-level details, check the `.d.ts` files in `dist/`.
5
+
6
+ ## Import Convention
7
+
8
+ ```typescript
9
+ import * as i from '@xentom/integration-framework'
10
+ import * as v from 'valibot'
11
+ ```
12
+
13
+ Always use namespace imports for the framework (`i`) and validator (`v`).
14
+ Any [Standard Schema](https://github.com/standard-schema/standard-schema)-compatible validator works (e.g. `valibot`, `zod`). Most integrations use `valibot`.
15
+ Never access the validator through the framework (no `i.v.string()`).
16
+
17
+ ## Integration Structure
18
+
19
+ Every integration default-exports a call to `i.integration()`:
20
+
21
+ ```typescript
22
+ declare module '@xentom/integration-framework' {
23
+ interface IntegrationState {
24
+ client: SomeClient
25
+ }
26
+ }
27
+
28
+ export default i.integration({
29
+ nodes, // required — record of trigger/callable/pure nodes
30
+ auth, // optional — i.auth.token(), i.auth.oauth2(), or i.auth.basic()
31
+ env, // optional — record of i.env() definitions
32
+ start(opts) { // optional — initialize shared resources
33
+ opts.state.client = new SomeClient(opts.auth.token)
34
+ },
35
+ stop(opts) { // optional — tear down resources
36
+ opts.state.client.close()
37
+ },
38
+ })
39
+ ```
40
+
41
+ - Augment `IntegrationState` via `declare module` to type the shared `state` object.
42
+ - `start` receives `{ auth, env, state, webhook, kv }`.
43
+ - `stop` receives the same, but `state` values may be undefined (partial).
44
+ - `state` is in-memory only — not persisted across restarts.
45
+ - When using valibot, set global config for early abort at the top of your entry point:
46
+
47
+ ```typescript
48
+ v.setGlobalConfig({ abortEarly: true, abortPipeEarly: true })
49
+ ```
50
+
51
+ ## Authentication
52
+
53
+ Three auth strategies. Pick one per integration.
54
+
55
+ | Strategy | Builder | Credential access |
56
+ |----------|---------|-------------------|
57
+ | API token | `i.auth.token()` | `opts.auth.token` (string) |
58
+ | OAuth 2.0 | `i.auth.oauth2({ authUrl, tokenUrl, scopes })` | `opts.auth.accessToken` (string) |
59
+ | Basic | `i.auth.basic()` | `opts.auth.username`, `opts.auth.password` |
60
+
61
+ Token auth supports a `schema` for validation and a `control` for the UI:
62
+
63
+ ```typescript
64
+ auth: i.auth.token({
65
+ control: i.controls.text({
66
+ label: 'API Key',
67
+ placeholder: 'sk_...',
68
+ }),
69
+ schema: v.pipeAsync(
70
+ v.string(),
71
+ v.startsWith('sk_'),
72
+ v.checkAsync(async (token) => { /* test call */ return true }, 'Invalid key.'),
73
+ ),
74
+ })
75
+ ```
76
+
77
+ OAuth2 also supports `grantType`, `pkce`, `pkceMethod`, and `onAccessTokenUpdated`.
78
+ When using OAuth2, implement `onAccessTokenUpdated` to refresh your client when tokens rotate:
79
+
80
+ ```typescript
81
+ auth: i.auth.oauth2({
82
+ authUrl: 'https://example.com/oauth/authorize',
83
+ tokenUrl: 'https://example.com/oauth/token',
84
+ scopes: ['read', 'write'],
85
+ onAccessTokenUpdated(opts) {
86
+ opts.state.client.setToken(opts.auth.accessToken)
87
+ },
88
+ })
89
+ ```
90
+
91
+ ## Environment Variables
92
+
93
+ Use `env` for configuration values that are not authentication credentials.
94
+
95
+ ```typescript
96
+ env: {
97
+ REGION: i.env({
98
+ control: i.controls.select({
99
+ options: [
100
+ { value: 'us-east-1', label: 'US East' },
101
+ { value: 'eu-west-1', label: 'EU West' },
102
+ ],
103
+ }),
104
+ }),
105
+ DEBUG: i.env({
106
+ control: i.controls.switch({ label: 'Debug Mode', defaultValue: false }),
107
+ optional: true,
108
+ }),
109
+ }
110
+ ```
111
+
112
+ Rules:
113
+ - Env vars are available in `start`/`stop` via `opts.env`. They are **not** available inside nodes.
114
+ - To use env values in nodes, read them in `start` and store on `state`.
115
+ - Controls are limited to `text`, `switch`, and `select` (static options only — no async callback).
116
+ - Set `sensitive: true` on text controls for secrets.
117
+ - Add `optional: true` on the env definition itself (not the control) if the value is not required.
118
+
119
+ ## Node Types
120
+
121
+ ### Trigger — workflow entry point
122
+
123
+ Listens for external events. Implements `subscribe`, which should return a cleanup function.
124
+
125
+ ```typescript
126
+ export const onEvent = nodes.trigger({
127
+ outputs: {
128
+ payload: i.pins.data<EventPayload>(),
129
+ },
130
+ subscribe(opts) {
131
+ const unsubscribe = opts.state.client.on('event', (data) => {
132
+ void opts.next({ payload: data })
133
+ })
134
+ return () => unsubscribe()
135
+ },
136
+ })
137
+ ```
138
+
139
+ - `subscribe` receives `{ next, state, webhook, inputs, variables, kv, node, workflow }`.
140
+ - Call `next(outputs)` to fire the trigger. If exec pins are defined, call `next('pinName', outputs)`.
141
+ - Use `void opts.next(...)` for fire-and-forget, or `await opts.next(...)` when you need to wait for downstream execution.
142
+ - `next` accepts an optional trailing arg for context and dedup: `next(outputs, { ctx, deduplication: { id, ttl? } })` or `next('pinName', outputs, { ctx, deduplication: { id, ttl? } })`.
143
+ - Webhook triggers: call `opts.webhook.subscribe(handler)` — handler receives a `Request`, returns a `Response`.
144
+ - The `workflow.on('start', callback)` hook lets you react to workflow lifecycle events.
145
+
146
+ ### Callable — side-effect action
147
+
148
+ Invoked by other nodes during a workflow run. Implements `run` and calls `next()` to continue the workflow.
149
+
150
+ ```typescript
151
+ export const createItem = nodes.callable({
152
+ inputs: {
153
+ name: pins.item.name,
154
+ },
155
+ outputs: {
156
+ id: pins.item.id.with({ control: false }),
157
+ },
158
+ async run(opts) {
159
+ const result = await opts.state.client.items.create({ name: opts.inputs.name })
160
+ return opts.next({ id: result.id })
161
+ },
162
+ })
163
+ ```
164
+
165
+ - `run` receives `{ next, state, inputs, ctx, variables, webhook, kv, node }`.
166
+ - Always `return opts.next(...)` — this is the standard pattern across all integrations.
167
+ - If the node defines exec pins in outputs, call `return opts.next('pinName', outputs)` for the specific branch.
168
+ - Omitting `next()` is valid for terminal/void actions (e.g. delete operations) that have no downstream outputs. The workflow will not continue to connected nodes.
169
+
170
+ ### Pure — deterministic transform
171
+
172
+ Side-effect-free computation. Assign directly to `outputs` — no `next()` call.
173
+
174
+ ```typescript
175
+ export const formatName = nodes.pure({
176
+ inputs: {
177
+ first: i.pins.data({ schema: v.string() }),
178
+ last: i.pins.data({ schema: v.string() }),
179
+ },
180
+ outputs: {
181
+ full: i.pins.data<string>(),
182
+ },
183
+ run(opts) {
184
+ opts.outputs.full = `${opts.inputs.first} ${opts.inputs.last}`
185
+ },
186
+ })
187
+ ```
188
+
189
+ - `run` receives `{ inputs, outputs, state, ctx, variables, webhook, kv, node }`.
190
+ - `run` is optional. Data-only pure nodes (e.g. a string constant with just a control) can omit it entirely.
191
+ - Pure nodes cannot have exec pins. Outputs must be data pins only.
192
+ - Evaluated lazily when downstream nodes need their outputs.
193
+
194
+ ### When to pick which
195
+
196
+ | Need | Node type |
197
+ |------|-----------|
198
+ | React to external events (webhooks, timers, client events) | `trigger` |
199
+ | Call an API, mutate data, produce side effects | `callable` |
200
+ | Transform or compute a value from inputs, no side effects | `pure` |
201
+
202
+ ## Node Groups
203
+
204
+ Organize nodes into UI categories:
205
+
206
+ ```typescript
207
+ const nodes = i.nodes.group('Emails')
208
+
209
+ export const sendEmail = nodes.callable({ ... })
210
+ export const listEmails = nodes.callable({ ... })
211
+ ```
212
+
213
+ Use slash-separated names for nested categories:
214
+
215
+ ```typescript
216
+ const nodes = i.nodes.group('Repositories/Issues')
217
+ ```
218
+
219
+ `group()` returns a scoped builder with `trigger`, `callable`, and `pure` — but no nested `group`.
220
+
221
+ ## Pins
222
+
223
+ ### Data Pins
224
+
225
+ Carry values between nodes.
226
+
227
+ ```typescript
228
+ // Minimal
229
+ i.pins.data()
230
+
231
+ // Typed (compile-time only)
232
+ i.pins.data<User>()
233
+
234
+ // With schema (runtime validation)
235
+ i.pins.data({ schema: v.pipe(v.string(), v.email()) })
236
+
237
+ // With state-dependent schema (function form — receives IntegrationOptions)
238
+ i.pins.data({ schema: ({ state }) => v.pipe(v.string(), v.transform((id) => state.client.getById(id))) })
239
+
240
+ // With control
241
+ i.pins.data({
242
+ description: 'Recipient email.',
243
+ schema: v.pipe(v.string(), v.email()),
244
+ control: i.controls.text({ placeholder: 'user@example.com' }),
245
+ })
246
+ ```
247
+
248
+ #### Customizing with `.with()`
249
+
250
+ Always use `.with()` to extend a pin definition. Never destructure or access internal properties.
251
+
252
+ ```typescript
253
+ pins.email.address.with({ optional: true, description: 'CC recipient.' })
254
+ pins.item.id.with({ control: false }) // strip the control (e.g. for output-only pins)
255
+ ```
256
+
257
+ #### Pin definition rules
258
+
259
+ - **Reusable pins** (in `src/pins/`): never set `optional: true` directly. Apply it via `.with()` at the usage site so the pin stays reusable. Same for `control: false` — use `.with({ control: false })` to strip controls when using a pin as an output.
260
+ - **Inline pins** (created directly in node inputs/outputs): setting `optional: true` or `control: false` directly is fine.
261
+ - Naming: `item` for a single object, `items` for an array, descriptive names for properties (`id`, `name`, `email`).
262
+ - Provide `examples` on complex pins to help users and AI.
263
+
264
+ ### Exec Pins
265
+
266
+ Control execution branching in trigger and callable nodes.
267
+
268
+ ```typescript
269
+ outputs: {
270
+ forEach: i.pins.exec({
271
+ outputs: {
272
+ item: i.pins.data(),
273
+ index: i.pins.data(),
274
+ },
275
+ }),
276
+ completed: i.pins.exec(),
277
+ },
278
+ async run(opts) {
279
+ for (const [index, item] of opts.inputs.items.entries()) {
280
+ await opts.next('forEach', { item, index })
281
+ }
282
+ return opts.next('completed')
283
+ },
284
+ ```
285
+
286
+ **Only use exec pins for:**
287
+ 1. Branching logic (conditional paths)
288
+ 2. Iteration (forEach patterns)
289
+ 3. State machines
290
+
291
+ **Never use exec pins for error handling.** Throw instead.
292
+
293
+ ## Controls
294
+
295
+ | Control | Use for | Key options |
296
+ |---------|---------|-------------|
297
+ | `i.controls.text()` | Strings | `placeholder`, `sensitive`, `rows`, `language` (`'plain'`, `'html'`, `'markdown'`, `'javascript'`) |
298
+ | `i.controls.expression()` | Numbers, objects, JS expressions | `placeholder`, `rows`, `defaultValue` |
299
+ | `i.controls.select()` | Enum/choice values | `options` (static array or async callback), `placeholder`, `multiple` |
300
+ | `i.controls.switch()` | Booleans | `defaultValue` |
301
+
302
+ ### Dynamic select options (nodes only)
303
+
304
+ ```typescript
305
+ i.controls.select({
306
+ async options({ state, pagination, search }) {
307
+ const res = await state.client.items.list({ limit: pagination.limit, after: pagination.after })
308
+ return {
309
+ items: res.data.map((item) => ({ value: item.id, label: item.name, suffix: item.id })),
310
+ hasMore: res.hasMore,
311
+ }
312
+ },
313
+ })
314
+ ```
315
+
316
+ The callback receives `{ state, auth, env, webhook, kv, node, pagination, search }`.
317
+ `pagination` has `limit`, `after?`, `before?`, and `page`.
318
+ Dynamic options are **not** available on env var controls.
319
+
320
+ ## Generic Nodes
321
+
322
+ Use `i.generic()` when a node's output types depend on its input types at the type level:
323
+
324
+ ```typescript
325
+ export const passthrough = i.generic(<I extends i.GenericInputs<typeof inputs>>() => {
326
+ const inputs = {
327
+ value: i.pins.data(),
328
+ }
329
+
330
+ return i.nodes.pure({
331
+ inputs,
332
+ outputs: {
333
+ value: i.pins.data<I['value']>(),
334
+ },
335
+ run({ inputs, outputs }) {
336
+ outputs.value = inputs.value
337
+ },
338
+ })
339
+ })
340
+ ```
341
+
342
+ This is an advanced pattern. Use it only when static typing cannot express the relationship between inputs and outputs.
343
+
344
+ ## Key-Value Store
345
+
346
+ A persistent store available via `opts.kv` in all nodes, triggers, and lifecycle hooks.
347
+
348
+ ```typescript
349
+ await opts.kv.set('cursor', lastCursor)
350
+ const cursor = await opts.kv.get('cursor') // string | null
351
+ await opts.kv.delete('cursor', 'otherKey')
352
+ ```
353
+
354
+ Values are strings. Persists across restarts. Use for pagination cursors, sync tokens, or cached state.
355
+
356
+ ## Error Handling
357
+
358
+ **Always throw. Never route errors through exec pins or return values.**
359
+
360
+ ```typescript
361
+ // Correct — throw and let the framework handle it
362
+ async run(opts) {
363
+ const res = await opts.state.client.items.get(opts.inputs.id)
364
+ if (!res) throw new Error('Item not found')
365
+ return opts.next({ item: res })
366
+ }
367
+ ```
368
+
369
+ The framework catches thrown errors and surfaces them to the user.
370
+
371
+ The only exception is webhook handlers inside triggers, which may catch errors to return an appropriate HTTP response:
372
+
373
+ ```typescript
374
+ subscribe(opts) {
375
+ const unsub = opts.webhook.subscribe(async (req) => {
376
+ try {
377
+ const data = await req.json()
378
+ opts.next({ data })
379
+ return new Response('OK')
380
+ } catch {
381
+ return new Response('Bad Request', { status: 400 })
382
+ }
383
+ })
384
+ return () => unsub()
385
+ }
386
+ ```
387
+
388
+ ## Trigger Cleanup
389
+
390
+ Every `subscribe` function should return a cleanup function (sync or async). The framework calls it when the workflow stops or reconfigures.
391
+
392
+ Clean up:
393
+ - `setInterval` / `setTimeout` handles
394
+ - Event listener subscriptions
395
+ - Webhook handlers (the return value of `webhook.subscribe()`)
396
+ - Any open connections or resources
397
+
398
+ Omitting cleanup is technically allowed (the return type permits `void`), but causes resource leaks in most cases. Only omit it when cleanup is handled centrally (e.g. in `stop()`).
399
+
400
+ ## Webhook Routing in `start()`
401
+
402
+ For integrations that receive all events through a single webhook endpoint (e.g. GitHub, Stripe), set up central webhook routing in `start()` and distribute events to trigger nodes via shared state:
403
+
404
+ ```typescript
405
+ start(opts) {
406
+ opts.state.client = new Client(opts.auth.token)
407
+ opts.state.events = new EventEmitter()
408
+
409
+ opts.webhook.subscribe(async (request) => {
410
+ const signature = request.headers.get('X-Signature')
411
+ if (!signature) return new Response('Unauthorized', { status: 401 })
412
+
413
+ const payload = await request.text()
414
+ if (!verify(payload, signature)) return new Response('Forbidden', { status: 403 })
415
+
416
+ const event = JSON.parse(payload)
417
+ opts.state.events.emit(event.type, event)
418
+ return new Response('OK')
419
+ })
420
+ }
421
+ ```
422
+
423
+ Individual trigger nodes then subscribe to the shared event emitter in their `subscribe()` function.
424
+
425
+ ## Common Mistakes
426
+
427
+ - **Forgetting `return opts.next()` in callable nodes.** The workflow will not continue to downstream nodes. Always `return opts.next(...)` unless the node is a terminal/void action.
428
+ - **Calling `next()` in pure nodes.** Pure nodes assign to `outputs` directly. They have no `next`.
429
+ - **Using exec pins for error handling.** Throw errors instead. The framework handles them.
430
+ - **Not returning cleanup from `subscribe`.** Causes resource leaks on stop/reconfigure.
431
+ - **Accessing `env` inside nodes.** Env vars are only in `start`/`stop`. Store values on `state` instead.
432
+ - **Setting `optional: true` on reusable pin definitions (in `src/pins/`).** Apply it via `.with()` at the usage site to keep the pin reusable. Inline pins in node definitions may set it directly.
433
+ - **Using `as any`, `@ts-ignore`, or `biome-ignore`.** Fix the type error properly. If stuck after 3 attempts, redesign the approach. The only acceptable escape hatch is `@ts-expect-error` in rare generic type narrowing scenarios.
434
+ - **Using `import type { X } from 'pkg'`.** Use inline type keyword: `import { type X } from 'pkg'`.
435
+ - **Generic export names.** Use `sendEmail`, not `send`. Use `deleteUser`, not `delete`.
436
+ - **Unnecessary `displayName`.** Only set it when auto-generation from the export name is wrong (e.g. acronyms, JS keyword conflicts).
437
+ - **Wrapping everything in try-catch.** Let errors propagate. Only catch when you need to transform the error or handle webhook responses.
438
+ - **Intermediate variables for API calls.** Prefer `opts.state.client.items.create(...)` over `const client = opts.state.client; const items = client.items; ...`.
@@ -165,12 +165,23 @@ export interface TriggerNextOptions {
165
165
  */
166
166
  ctx?: TriggerRunContext;
167
167
  /**
168
- * Optional identifier to prevent duplicate executions of the same trigger.
169
- * If a trigger with the same deduplicationId is already queued or recently
170
- * executed, this run will be skipped. Useful for ensuring idempotency when
171
- * the same event may be received multiple times.
168
+ * Optional configuration for deduplicating trigger executions.
169
+ * Helps prevent the same trigger from running multiple times when
170
+ * identical events are received.
172
171
  */
173
- deduplicationId?: string;
172
+ deduplication?: {
173
+ /**
174
+ * Unique identifier used to detect duplicate executions.
175
+ * If a trigger with the same ID is already queued or was recently
176
+ * executed, this run will be skipped.
177
+ */
178
+ id: string;
179
+ /**
180
+ * Optional time-to-live (in seconds) for the deduplication record.
181
+ * After this period expires, a trigger with the same ID may run again.
182
+ */
183
+ ttl?: number;
184
+ };
174
185
  }
175
186
  export type TriggerRunContext = Record<string, any>;
176
187
  export type TriggerNodeBuilder = <I extends TriggerNodeInputs, O extends TriggerNodeOutputs>(definition: Omit<TriggerNode<I, O>, 'type'>) => TriggerNode<I, O>;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@xentom/integration-framework",
3
3
  "description": "A type-safe, declarative framework for building composable workflow integrations using nodes, pins, and rich controls.",
4
- "version": "0.0.17",
4
+ "version": "0.0.19",
5
5
  "license": "MIT",
6
6
  "homepage": "https://xentom.com",
7
7
  "author": {
@@ -42,7 +42,7 @@
42
42
  "sideEffects": false,
43
43
  "files": [
44
44
  "dist",
45
- "CLAUDE.md"
45
+ "AGENTS.md"
46
46
  ],
47
47
  "publishConfig": {
48
48
  "access": "public"
@@ -58,7 +58,7 @@
58
58
  },
59
59
  "devDependencies": {
60
60
  "@xentom/style-guide": "^0.0.0",
61
- "bun-types": "^1.3.6",
61
+ "@types/bun": "^1.3.10",
62
62
  "typescript": "^5.9.3"
63
63
  }
64
- }
64
+ }
package/CLAUDE.md DELETED
@@ -1,837 +0,0 @@
1
- # CLAUDE.md
2
-
3
- This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4
-
5
- ## Integration Development Commands
6
-
7
- ```bash
8
- # Build and test your integration
9
- npm run build
10
- npm run typecheck
11
- npm run lint
12
- ```
13
-
14
- ## Framework Overview
15
-
16
- This is the `@xentom/integration-framework` package for building workflow integrations. It provides a declarative,
17
- type-safe API for creating integrations that can process data through interconnected nodes.
18
-
19
- **Core Philosophy:**
20
-
21
- - **Type Safety**: Heavy use of TypeScript generics and inference
22
- - **Declarative**: Define what you want, not how to achieve it
23
- - **Composable**: Build complex workflows from simple, reusable components
24
- - **Standard Schema**: Compatible with any validation library using the Standard Schema spec
25
-
26
- Import the framework as:
27
-
28
- ```typescript
29
- import * as i from '@xentom/integration-framework';
30
- ```
31
-
32
- ## Integration Architecture
33
-
34
- ### Integration Structure
35
-
36
- Every integration must follow this structure:
37
-
38
- ```typescript
39
- export default i.integration({
40
- // Environment variables - secure configuration
41
- env: {
42
- API_KEY: i.env({
43
- control: i.controls.text({
44
- label: 'API Key',
45
- description: 'Your service API key for authentication',
46
- sensitive: true, // Hides value in UI
47
- }),
48
- }),
49
- },
50
-
51
- // Workflow nodes - the building blocks
52
- nodes: {
53
- // Your trigger, callable, and pure nodes
54
- },
55
-
56
- // Optional lifecycle hooks
57
- start({ state, webhook }) {
58
- // Initialize shared resources (API clients, connections, etc.)
59
- // This runs when the integration starts
60
- },
61
-
62
- stop({ state }) {
63
- // Clean up resources (close connections, clear timers, etc.)
64
- // This runs when the integration stops
65
- },
66
- });
67
- ```
68
-
69
- ### Integration State
70
-
71
- The integration provides an in-memory state object (`IntegrationState`) that is shared across all nodes:
72
-
73
- - **Purpose**: Store shared resources like API clients, caches, or connections
74
- - **Scope**: Available to all nodes and lifecycle hooks
75
- - **Lifecycle**: Exists only during integration runtime (not persisted)
76
- - **Usage**: Access via `state` parameter in node functions
77
-
78
- ## Node Types Deep Dive
79
-
80
- ### Trigger Nodes - Workflow Entry Points
81
-
82
- Trigger nodes are the **only** way to start a workflow. They listen for events and emit outputs when triggered.
83
-
84
- **Key Characteristics:**
85
-
86
- - Cannot be invoked by other nodes
87
- - Can invoke other nodes via their outputs
88
- - Must implement a `subscribe` function
89
- - Should return cleanup functions
90
-
91
- ```typescript
92
- webhookTrigger: i.nodes.trigger({
93
- // Optional: categorize your node in the UI
94
- category: { path: ['External', 'HTTP'] },
95
-
96
- // Optional: custom display name (defaults to key name in title case)
97
- displayName: 'Webhook Receiver',
98
-
99
- // Optional: description for UI and AI assistance
100
- description: 'Receives HTTP requests and processes the payload',
101
-
102
- // Inputs: configuration from user (not runtime data)
103
- inputs: {
104
- path: i.pins.data({
105
- control: i.controls.text({
106
- label: 'Webhook Path',
107
- placeholder: '/webhook',
108
- defaultValue: '/webhook',
109
- }),
110
- }),
111
- },
112
-
113
- // Outputs: data emitted when triggered
114
- outputs: {
115
- payload: i.pins.data({
116
- displayName: 'Request Payload',
117
- description: 'The parsed request body',
118
- }),
119
- headers: i.pins.data({
120
- displayName: 'HTTP Headers',
121
- description: 'Request headers as key-value pairs',
122
- }),
123
- },
124
-
125
- // Subscribe function: sets up event listeners
126
- subscribe({ next, webhook, inputs, state, variables }) {
127
- // Register webhook handler
128
- const unsubscribe = webhook.subscribe(async (req) => {
129
- try {
130
- const payload = await req.json();
131
-
132
- // Emit outputs and start workflow
133
- next({
134
- payload,
135
- headers: Object.fromEntries(req.headers),
136
- });
137
-
138
- // Return HTTP response
139
- return new Response('OK', { status: 200 });
140
- } catch (error) {
141
- return new Response('Bad Request', { status: 400 });
142
- }
143
- });
144
-
145
- // Always return cleanup function
146
- return () => unsubscribe();
147
- },
148
- }),
149
-
150
- // Timer trigger example
151
- timerTrigger: i.nodes.trigger({
152
- inputs: {
153
- interval: i.pins.data({
154
- control: i.controls.text({
155
- label: 'Interval (seconds)',
156
- defaultValue: '60',
157
- }),
158
- }),
159
- },
160
- outputs: {
161
- timestamp: i.pins.data(),
162
- },
163
- subscribe({ next, inputs }) {
164
- const intervalMs = parseInt(inputs.interval) * 1000;
165
-
166
- const timer = setInterval(() => {
167
- next({ timestamp: new Date().toISOString() });
168
- }, intervalMs);
169
-
170
- return () => clearInterval(timer);
171
- },
172
- }),
173
- ```
174
-
175
- ### Callable Nodes - Processing Units
176
-
177
- Callable nodes perform operations with side effects and explicitly control workflow execution.
178
-
179
- **Key Characteristics:**
180
-
181
- - Can be invoked by other nodes
182
- - Can invoke other nodes via exec pins
183
- - Must call `next()` to continue execution
184
- - Use `next()` to pass outputs
185
-
186
- ```typescript
187
- apiCall: i.nodes.callable({
188
- category: { path: ['API', 'HTTP'] },
189
- displayName: 'Make API Call',
190
- description: 'Performs HTTP requests to external APIs',
191
-
192
- inputs: {
193
- url: i.pins.data({
194
- control: i.controls.text({
195
- label: 'API Endpoint',
196
- placeholder: 'https://api.example.com/data',
197
- }),
198
- }),
199
- method: i.pins.data({
200
- control: i.controls.select({
201
- options: [
202
- { value: 'GET', label: 'GET' },
203
- { value: 'POST', label: 'POST' },
204
- { value: 'PUT', label: 'PUT' },
205
- { value: 'DELETE', label: 'DELETE' },
206
- ],
207
- placeholder: 'Select HTTP method',
208
- }),
209
- }),
210
- headers: i.pins.data({
211
- control: i.controls.expression({
212
- defaultValue: { 'Content-Type': 'application/json' },
213
- }),
214
- optional: true, // Pin won't show initially but can be added
215
- }),
216
- body: i.pins.data({
217
- control: i.controls.text({
218
- rows: 4, // Multi-line text area
219
- language: 'json', // Syntax highlighting
220
- }),
221
- optional: true,
222
- }),
223
- },
224
-
225
- outputs: {
226
- data: i.pins.data({
227
- displayName: 'Response Data',
228
- description: 'The parsed response body',
229
- }),
230
- status: i.pins.data({
231
- displayName: 'HTTP Status',
232
- description: 'The HTTP status code',
233
- }),
234
- headers: i.pins.data({
235
- displayName: 'Response Headers',
236
- description: 'Response headers as key-value pairs',
237
- }),
238
- },
239
-
240
- async run({ inputs, next, state, ctx, variables, webhook }) {
241
- // Access shared state (API client, etc.)
242
- const client = state.httpClient;
243
-
244
- // Perform the API call
245
- const response = await client.fetch(inputs.url, {
246
- method: inputs.method,
247
- headers: inputs.headers,
248
- body: inputs.body ? JSON.stringify(inputs.body) : undefined,
249
- });
250
-
251
- // Parse response
252
- const data = await response.json();
253
-
254
- // Pass outputs via next() - this continues the workflow
255
- next({
256
- data,
257
- status: response.status,
258
- headers: Object.fromEntries(response.headers),
259
- });
260
- },
261
- }),
262
- ```
263
-
264
- ### Pure Nodes - Computational Units
265
-
266
- Pure nodes are side-effect-free and compute outputs solely from inputs.
267
-
268
- **Key Characteristics:**
269
-
270
- - Cannot be invoked directly (only via data dependencies)
271
- - Cannot invoke other nodes
272
- - Automatically evaluated when their outputs are needed
273
- - Assign directly to `outputs` object
274
-
275
- ```typescript
276
- dataTransform: i.nodes.pure({
277
- category: { path: ['Data', 'Transform'] },
278
- displayName: 'Transform Data',
279
- description: 'Transforms input data using a specified mapping',
280
-
281
- inputs: {
282
- data: i.pins.data({
283
- control: i.controls.expression({
284
- placeholder: 'Enter data to transform',
285
- }),
286
- examples: [
287
- {
288
- title: 'Simple Object',
289
- value: { name: 'John', age: 30 },
290
- },
291
- {
292
- title: 'Array of Objects',
293
- value: [
294
- { id: 1, name: 'Alice' },
295
- { id: 2, name: 'Bob' },
296
- ],
297
- },
298
- ],
299
- }),
300
- mapping: i.pins.data({
301
- control: i.controls.expression({
302
- defaultValue: {
303
- newName: 'data.name',
304
- ageInMonths: 'data.age * 12',
305
- },
306
- }),
307
- }),
308
- },
309
-
310
- outputs: {
311
- result: i.pins.data({
312
- displayName: 'Transformed Data',
313
- description: 'The data after applying the mapping',
314
- }),
315
- },
316
-
317
- run({ inputs, outputs, state, ctx, variables, webhook }) {
318
- // Pure computation - no side effects
319
- const { data, mapping } = inputs;
320
-
321
- // Apply transformation
322
- const result = applyMapping(data, mapping);
323
-
324
- // Assign to outputs - no next() call needed
325
- outputs.result = result;
326
- },
327
- }),
328
- ```
329
-
330
- ## Pin System Deep Dive
331
-
332
- ### Data Pins - Information Flow
333
-
334
- Data pins handle the flow of information between nodes.
335
-
336
- ```typescript
337
- // Basic data pin
338
- i.pins.data()
339
-
340
- // Fully configured data pin
341
- i.pins.data({
342
- // UI Configuration
343
- displayName: 'User Input', // Custom label (default: key name)
344
- description: 'The user-provided input value',
345
-
346
- // Control for user input
347
- control: i.controls.text({
348
- label: 'Enter Value',
349
- placeholder: 'Type here...',
350
- defaultValue: 'Default text',
351
- }),
352
-
353
- // Schema validation (Standard Schema compatible)
354
- schema: v.pipe(v.string(), v.minLength(1), v.maxLength(100)),
355
-
356
- // Examples for users and AI
357
- examples: [
358
- { title: 'Simple Text', value: 'Hello World' },
359
- { title: 'Template', value: '{{variable}}' },
360
- ],
361
-
362
- // Optional pins don't show initially
363
- optional: true,
364
- }),
365
-
366
- // Method chaining with .with()
367
- i.pins.data().with({
368
- displayName: 'Custom Label',
369
- description: 'Additional configuration',
370
- }),
371
- ```
372
-
373
- ### Exec Pins - Execution Flow
374
-
375
- Exec pins control the execution flow in trigger and callable nodes.
376
-
377
- **Critical Rule: Only use exec pins for:**
378
-
379
- 1. **Branching Logic**: Conditional execution paths
380
- 2. **Iteration**: Processing arrays/collections
381
- 3. **State Machines**: Complex state transitions
382
-
383
- ```typescript
384
- // Branching example
385
- conditionalProcessor: i.nodes.callable({
386
- inputs: {
387
- condition: i.pins.data(),
388
- trueValue: i.pins.data(),
389
- falseValue: i.pins.data(),
390
- },
391
- outputs: {
392
- // Exec pins for different paths
393
- whenTrue: i.pins.exec({
394
- outputs: {
395
- value: i.pins.data(),
396
- },
397
- }),
398
- whenFalse: i.pins.exec({
399
- outputs: {
400
- value: i.pins.data(),
401
- },
402
- }),
403
- },
404
- run({ inputs, next }) {
405
- if (inputs.condition) {
406
- next('whenTrue', { value: inputs.trueValue });
407
- } else {
408
- next('whenFalse', { value: inputs.falseValue });
409
- }
410
- },
411
- }),
412
-
413
- // Iteration example
414
- arrayProcessor: i.nodes.callable({
415
- inputs: {
416
- items: i.pins.data(),
417
- },
418
- outputs: {
419
- // Exec pin for each iteration
420
- forEach: i.pins.exec({
421
- outputs: {
422
- item: i.pins.data(),
423
- index: i.pins.data(),
424
- },
425
- }),
426
- // Exec pin when all items processed
427
- completed: i.pins.exec({
428
- outputs: {
429
- count: i.pins.data(),
430
- },
431
- }),
432
- },
433
- run({ inputs, next }) {
434
- const items = inputs.items;
435
-
436
- // Process each item
437
- items.forEach((item, index) => {
438
- next('forEach', { item, index });
439
- });
440
-
441
- // Signal completion
442
- next('completed', { count: items.length });
443
- },
444
- }),
445
- ```
446
-
447
- ## Control System Deep Dive
448
-
449
- ### Text Controls - String Input
450
-
451
- ```typescript
452
- i.controls.text({
453
- // Base properties
454
- label: 'Input Label',
455
- description: 'Help text for the user',
456
- defaultValue: 'Default text',
457
-
458
- // Text-specific properties
459
- placeholder: 'Enter text here...',
460
- sensitive: true, // Hides input value (passwords, API keys)
461
- rows: 4, // Multi-line text area
462
- language: 'json', // Syntax highlighting: 'plain', 'html', 'markdown'
463
- });
464
- ```
465
-
466
- ### Expression Controls - JavaScript Code
467
-
468
- ```typescript
469
- i.controls.expression({
470
- // Base properties
471
- label: 'Expression',
472
- description: 'JavaScript expression to evaluate',
473
- defaultValue: { result: 'computed value' },
474
-
475
- // Expression-specific properties
476
- placeholder: 'Enter JavaScript expression...',
477
- rows: 6, // Multi-line code editor
478
- });
479
- ```
480
-
481
- ### Select Controls - Dropdown Selection
482
-
483
- ```typescript
484
- // Static options
485
- i.controls.select({
486
- label: 'Choose Option',
487
- placeholder: 'Select an option...',
488
- options: [
489
- {
490
- value: 'option1',
491
- label: 'Option 1',
492
- description: 'Description of option 1'
493
- },
494
- {
495
- value: 'option2',
496
- label: 'Option 2',
497
- description: 'Description of option 2'
498
- },
499
- ],
500
- }),
501
-
502
- // Dynamic options (only for node pins, not env variables)
503
- i.controls.select({
504
- label: 'API Endpoint',
505
- placeholder: 'Select endpoint...',
506
- options: async ({ state }) => {
507
- // Access shared state to fetch options
508
- const endpoints = await state.apiClient.getEndpoints();
509
- return endpoints.map(ep => ({
510
- value: ep.url,
511
- label: ep.name,
512
- description: ep.description,
513
- }));
514
- },
515
- }),
516
- ```
517
-
518
- ### Switch Controls - Boolean Toggle
519
-
520
- ```typescript
521
- i.controls.switch({
522
- label: 'Enable Feature',
523
- description: 'Toggle this feature on or off',
524
- defaultValue: false,
525
- });
526
- ```
527
-
528
- ## Environment Variables
529
-
530
- Environment variables are secure configuration values that are set once and used across all nodes.
531
-
532
- ```typescript
533
- export default i.integration({
534
- env: {
535
- API_KEY: i.env({
536
- control: i.controls.text({
537
- label: 'API Key',
538
- description: 'Your service API key for authentication',
539
- placeholder: 'sk-...',
540
- sensitive: true, // Important: hides the value
541
- }),
542
- // Optional: validation schema
543
- schema: v.pipe(v.string(), v.startsWith('sk-')),
544
- }),
545
-
546
- DEBUG_MODE: i.env({
547
- control: i.controls.switch({
548
- label: 'Debug Mode',
549
- description: 'Enable debug logging',
550
- defaultValue: false,
551
- }),
552
- }),
553
-
554
- REGION: i.env({
555
- control: i.controls.select({
556
- options: [
557
- { value: 'us-east-1', label: 'US East 1' },
558
- { value: 'us-west-2', label: 'US West 2' },
559
- { value: 'eu-west-1', label: 'EU West 1' },
560
- ],
561
- }),
562
- }),
563
- },
564
-
565
- // Environment variables are available in start/stop hooks
566
- async start({ state, env }) {
567
- // Use environment variables to initialize shared resources
568
- state.apiClient = new ApiClient({
569
- apiKey: env.API_KEY,
570
- region: env.REGION,
571
- debug: env.DEBUG_MODE,
572
- });
573
- },
574
-
575
- nodes: {
576
- // Environment variables are NOT directly available in nodes
577
- // Access them through shared state or pass as inputs
578
- },
579
- });
580
- ```
581
-
582
- ## Error Handling
583
-
584
- **Golden Rule: Always throw errors, never try to handle them with exec pins or return values.**
585
-
586
- ```typescript
587
- // Correct error handling in callable nodes
588
- async run({ inputs, next, state }) {
589
- try {
590
- const response = await state.apiClient.get(inputs.url);
591
-
592
- // Check for API errors
593
- if (!response.ok) {
594
- throw new Error(`API request failed: ${response.status} ${response.statusText}`);
595
- }
596
-
597
- const data = await response.json();
598
- next({ data });
599
- } catch (error) {
600
- // Let the error bubble up - the framework will handle it
601
- throw error;
602
- }
603
- }
604
-
605
- // Correct error handling in pure nodes
606
- run({ inputs, outputs }) {
607
- if (!inputs.value) {
608
- throw new Error('Value is required');
609
- }
610
-
611
- if (typeof inputs.value !== 'string') {
612
- throw new Error('Value must be a string');
613
- }
614
-
615
- outputs.result = inputs.value.toUpperCase();
616
- }
617
-
618
- // Correct error handling in triggers
619
- subscribe({ next, webhook, state }) {
620
- const unsubscribe = webhook.subscribe(async (req) => {
621
- try {
622
- const payload = await req.json();
623
- next({ payload });
624
- return new Response('OK');
625
- } catch (error) {
626
- // Handle webhook-specific errors
627
- console.error('Webhook error:', error);
628
- return new Response('Bad Request', { status: 400 });
629
- }
630
- });
631
-
632
- return () => unsubscribe();
633
- }
634
- ```
635
-
636
- ## Advanced Patterns
637
-
638
- ### State Management with Lifecycle Hooks
639
-
640
- ```typescript
641
- export default i.integration({
642
- env: {
643
- DATABASE_URL: i.env({
644
- control: i.controls.text({ sensitive: true }),
645
- }),
646
- },
647
-
648
- async start({ state, env }) {
649
- // Initialize shared resources
650
- state.db = new Database(env.DATABASE_URL);
651
- state.cache = new Map();
652
-
653
- // Setup connections
654
- await state.db.connect();
655
-
656
- // Initialize other services
657
- state.emailService = new EmailService();
658
- },
659
-
660
- async stop({ state }) {
661
- // Clean up resources
662
- if (state.db) {
663
- await state.db.disconnect();
664
- }
665
-
666
- if (state.cache) {
667
- state.cache.clear();
668
- }
669
- },
670
-
671
- nodes: {
672
- // Nodes can access shared state
673
- dbQuery: i.nodes.callable({
674
- inputs: {
675
- query: i.pins.data(),
676
- },
677
- outputs: {
678
- result: i.pins.data(),
679
- },
680
- async run({ inputs, next, state }) {
681
- const result = await state.db.query(inputs.query);
682
- next({ result });
683
- },
684
- }),
685
- },
686
- });
687
- ```
688
-
689
- ### Complex Webhook Handling
690
-
691
- ```typescript
692
- webhookProcessor: i.nodes.trigger({
693
- inputs: {
694
- secretKey: i.pins.data({
695
- control: i.controls.text({
696
- label: 'Webhook Secret',
697
- sensitive: true,
698
- }),
699
- }),
700
- },
701
- outputs: {
702
- verified: i.pins.exec({
703
- outputs: {
704
- payload: i.pins.data(),
705
- signature: i.pins.data(),
706
- },
707
- }),
708
- invalid: i.pins.exec(),
709
- },
710
-
711
- subscribe({ next, webhook, inputs }) {
712
- const unsubscribe = webhook.subscribe(async (req) => {
713
- try {
714
- // Verify webhook signature
715
- const signature = req.headers.get('X-Signature');
716
- const payload = await req.text();
717
-
718
- if (!verifySignature(payload, signature, inputs.secretKey)) {
719
- next('invalid');
720
- return new Response('Unauthorized', { status: 401 });
721
- }
722
-
723
- // Process verified webhook
724
- const data = JSON.parse(payload);
725
- next('verified', { payload: data, signature });
726
-
727
- return new Response('OK');
728
- } catch (error) {
729
- next('invalid');
730
- return new Response('Bad Request', { status: 400 });
731
- }
732
- });
733
-
734
- return () => unsubscribe();
735
- },
736
- }),
737
- ```
738
-
739
- ### Dynamic Options with Caching
740
-
741
- ```typescript
742
- apiEndpointSelector: i.pins.data({
743
- control: i.controls.select({
744
- options: async ({ state }) => {
745
- // Check cache first
746
- if (state.endpointCache) {
747
- return state.endpointCache;
748
- }
749
-
750
- // Fetch from API
751
- const endpoints = await state.apiClient.getEndpoints();
752
- const options = endpoints.map(ep => ({
753
- value: ep.id,
754
- label: ep.name,
755
- description: `${ep.method} ${ep.path}`,
756
- }));
757
-
758
- // Cache results
759
- state.endpointCache = options;
760
-
761
- return options;
762
- },
763
- }),
764
- }),
765
- ```
766
-
767
- ## Type Safety and Inference
768
-
769
- The framework provides comprehensive TypeScript support:
770
-
771
- ```typescript
772
- // Type inference from integration definition
773
- const myIntegration = i.integration({
774
- nodes: {
775
- processor: i.nodes.callable({
776
- inputs: {
777
- data: i.pins.data(),
778
- },
779
- outputs: {
780
- result: i.pins.data(),
781
- },
782
- run({ inputs, next }) {
783
- // inputs.data is properly typed
784
- // next is properly typed
785
- next({ result: inputs.data });
786
- },
787
- }),
788
- },
789
- });
790
-
791
- // Extract types from integration
792
- type IntegrationOutput = typeof myIntegration.$infer;
793
- // IntegrationOutput.nodes.processor.inputs.data is typed
794
- // IntegrationOutput.nodes.processor.outputs.result is typed
795
- ```
796
-
797
- ## Development Best Practices
798
-
799
- 1. **Use TypeScript**: The framework is built for TypeScript - use it
800
- 2. **Descriptive Names**: Use clear, descriptive names for nodes, pins, and variables
801
- 3. **Categories**: Organize nodes with categories for better UX
802
- 4. **Documentation**: Add descriptions to nodes and pins for AI assistance
803
- 5. **Examples**: Provide examples for complex data pins
804
- 6. **State Management**: Use integration state for shared resources
805
- 7. **Error Handling**: Always throw errors, never handle them with exec pins
806
- 8. **Cleanup**: Always return cleanup functions from trigger subscriptions
807
- 9. **Optional Pins**: Use optional pins to reduce UI clutter
808
- 10. **Validation**: Use schema validation for robust data handling
809
-
810
- ## Testing Guidelines
811
-
812
- ```typescript
813
- // Test pure nodes easily
814
- import { describe, expect, it } from 'vitest';
815
-
816
- import { integration } from './my-integration';
817
-
818
- describe('Data Transform Node', () => {
819
- it('should transform data correctly', () => {
820
- const node = integration.nodes.dataTransform;
821
- const outputs = {};
822
-
823
- node.run({
824
- inputs: { data: { name: 'John' }, mapping: { title: 'data.name' } },
825
- outputs,
826
- state: {},
827
- ctx: {},
828
- variables: {},
829
- webhook: { url: 'http://test' },
830
- });
831
-
832
- expect(outputs.result).toEqual({ title: 'John' });
833
- });
834
- });
835
- ```
836
-
837
- This framework enables you to build powerful, type-safe integrations with clear separation of concerns and excellent developer experience.