Nango Function Builder
Build deployable Nango functions (actions, syncs, on-event hooks) with repeatable patterns and validation steps.
When to use
- User wants to build or modify a Nango function
- User wants to build an action in Nango
- User wants to build a sync in Nango
- User wants to build an on-event hook (validate-connection, post-connection-creation, pre-connection-deletion)
Useful Nango docs (quick links)
Workflow (recommended)
- Verify this is a Zero YAML TypeScript project (no ) and you are in the Nango root ( exists).
- Compile as needed with (one-off).
- Create/update the function file under , , or
{integrationId}/on-events/
.
- Register the file in (side-effect import).
- Validate with
nango dryrun ... --validate
.
- Record mocks with and generate tests with .
- Run .
- Deploy with .
Preconditions (Do Before Writing Code)
Confirm TypeScript Project (No nango.yaml)
This skill only supports TypeScript projects using createAction()/createSync()/createOnEvent().
bash
ls nango.yaml 2>/dev/null && echo "YAML PROJECT DETECTED" || echo "OK - No nango.yaml"
If you see YAML PROJECT DETECTED:
- Stop immediately.
- Tell the user to upgrade to the TypeScript format first.
- Do not attempt to mix YAML and TypeScript.
Verify Nango Project Root
Do not create files until you confirm the Nango root:
bash
ls -la .nango/ 2>/dev/null && pwd && echo "IN NANGO PROJECT ROOT" || echo "NOT in Nango root"
If you see NOT in Nango root:
- cd into the directory that contains .nango/
- Re-run the check
- Do not use absolute paths as a workaround
All file paths must be relative to the Nango root. Creating files with extra prefixes while already in the Nango root will create nested directories that break the build.
Project Structure and Naming
./
|-- .nango/
|-- index.ts
|-- hubspot/
| |-- actions/
| | `-- create-contact.ts
| |-- on-events/
| | `-- validate-connection.ts
| `-- syncs/
| `-- fetch-contacts.ts
`-- slack/
`-- actions/
`-- post-message.ts
- Provider directories: lowercase (hubspot, slack)
- Action files: kebab-case (create-contact.ts)
- Event handler files: kebab-case in (validate-connection.ts)
- Sync files: kebab-case (many teams use a prefix, but it's optional)
- One function per file (action, sync, or on-event)
- All actions, syncs, and on-event hooks must be imported in index.ts
Register scripts in (required)
Use side-effect imports only (no default/named imports). Include the
extension.
typescript
// index.ts
import './github/actions/get-top-contributor.js';
import './github/syncs/fetch-issues.js';
import './github/on-events/validate-connection.js';
Symptom of incorrect registration: the file compiles but you see
No entry points found in index.ts...
or the function never appears.
Decide: Action vs Sync vs OnEvent
Action:
- One-time request, user-triggered
- CRUD operations and small lookups
- Thin API wrapper
Sync:
- Continuous data sync on a schedule
- Fetches all records or incremental changes
- Uses batchSave/batchDelete
OnEvent:
- Runs on connection lifecycle events (e.g., validate credentials)
- Good for verification and setup/cleanup hooks
If unclear, ask the user which behavior they want (one-time vs scheduled vs lifecycle hook).
Required Inputs (Ask User if Missing)
Always:
- Integration ID (provider name)
- Connection ID (for dryrun)
- Function name (kebab-case)
- API reference URL or sample response
Action-specific:
- Use case summary
- Input parameters
- Output fields
- Metadata JSON if required
- Test input JSON for dryrun/mocks
Sync-specific:
- Model name (singular, PascalCase)
- Sync type (full or incremental)
- Frequency (every hour, every 5 minutes, etc.)
- Metadata JSON if required (team_id, workspace_id)
OnEvent-specific:
- Event type (validate-connection, post-connection-creation, pre-connection-deletion)
- Expected behavior (what to validate/change)
If any of these are missing, ask the user for them before writing code. Use their values in dryrun commands and tests.
Prompt Templates (Use When Details Are Missing)
Action prompt:
Please provide:
Integration ID (required):
Connection ID (required):
Use Case Summary:
Action Inputs:
Action Outputs:
Metadata JSON (if required):
Action Name (kebab-case):
API Reference URL:
Test Input JSON:
Sync prompt:
Please provide:
Integration ID (required):
Connection ID (required):
Model Name (singular, PascalCase):
Endpoint Path (for Nango endpoint):
Frequency (every hour, every 5 minutes, etc.):
Sync Type (full or incremental):
Metadata JSON (if required):
API Reference URL:
Non-Negotiable Rules (Shared)
Platform constraints (docs-backed)
- Zero YAML TypeScript projects do not use . Define functions with , , or .
- Register every action/sync/on-event in via side-effect import () or it will not load.
- You cannot install/import arbitrary third-party packages in Functions. Relative imports inside the Nango project are supported. Pre-included dependencies include , /, and /.
- Sync records must include a stable string .
- Action outputs cannot exceed 2MB.
deleteRecordsFromPreviousExecutions()
is for full refresh syncs only. Call it only after you successfully fetched + saved the full dataset; do not swallow errors and still call it.
- HTTP request retries default to . Set intentionally (and be careful retrying non-idempotent writes).
Conventions (recommended)
- Prefer explicit parameter names (, , ).
- Add examples for IDs, timestamps, enums, and URLs.
- Avoid ; use inline types when mapping responses.
- Prefer static Nango endpoint paths (avoid / in the exposed endpoint); pass IDs in input/params.
- Add an API doc link comment above each provider API call.
- Standardize list actions on /.
- For optional outputs, return only when the output schema models .
- Use when you need custom input validation/logging; otherwise rely on schemas + .
Symptom of missing index.ts import: file compiles without errors but does not appear in the build output.
Parameter Naming Rules
- IDs: suffix with _id (user_id, channel_id)
- Names: suffix with _name (channel_name)
- Emails: suffix with _email (user_email)
- URLs: suffix with _url (callback_url)
- Timestamps: use *_at or *_time (created_at, scheduled_time)
Mapping example (API expects a different parameter name):
typescript
const InputSchema = z.object({
user_id: z.string()
});
const config: ProxyConfiguration = {
endpoint: 'users.info',
params: {
user: input.user_id
},
retries: 3
};
Action Template (createAction)
Notes:
- is required even for "no input" actions. Use .
- Do not import as a value from (it is a type-only export in recent versions). Throw
new nango.ActionError(payload)
using the exec parameter.
- typing is optional. Only import it if you explicitly annotate a variable.
typescript
import { z } from 'zod';
import { createAction } from 'nango';
const InputSchema = z.object({
user_id: z.string().describe('User ID. Example: "123"')
// For no-input actions use: z.object({})
});
const OutputSchema = z.object({
id: z.string(),
name: z.union([z.string(), z.null()])
});
const action = createAction({
description: 'Brief single sentence',
version: '1.0.0',
endpoint: {
method: 'GET',
path: '/user',
group: 'Users'
},
input: InputSchema,
output: OutputSchema,
scopes: ['required.scope'],
exec: async (nango, input): Promise<z.infer<typeof OutputSchema>> => {
const response = await nango.get({
// https://api-docs-url
endpoint: '/api/v1/users',
params: {
userId: input.user_id
},
retries: 3 // safe for idempotent GETs; be careful retrying non-idempotent writes
});
if (!response.data) {
throw new nango.ActionError({
type: 'not_found',
message: 'User not found',
user_id: input.user_id
});
}
return {
id: response.data.id,
name: response.data.name ?? null
};
}
});
export type NangoActionLocal = Parameters<(typeof action)['exec']>[0];
export default action;
Action Metadata (When Required)
Use metadata when the action depends on connection-specific values.
typescript
const MetadataSchema = z.object({
team_id: z.string()
});
const action = createAction({
metadata: MetadataSchema,
exec: async (nango, input) => {
const metadata = await nango.getMetadata<{ team_id?: string }>();
const teamId = metadata?.team_id;
if (!teamId) {
throw new nango.ActionError({
type: 'invalid_metadata',
message: 'team_id is required in metadata.'
});
}
}
});
Action CRUD Patterns
| Operation | Method | Config Pattern |
|---|
| Create | nango.post(config) | data: { properties: {...} } |
| Read | nango.get(config) | endpoint: , params: {...} |
| Update | nango.patch(config) | endpoint: , data: {...} |
| Delete | nango.delete(config) | endpoint: |
| List | nango.get(config) | params: {...} with pagination |
Note: These endpoint examples are for ProxyConfiguration (provider API). The createAction endpoint path must stay static.
Recommended in most configs:
- API doc link comment above endpoint
- retries: set intentionally (often for idempotent GET/LIST; avoid retries for non-idempotent POST unless the API supports idempotency)
Optional input fields pattern:
typescript
data: {
required_field: input.required_field,
...(input.optional_field && { optional_field: input.optional_field })
}
Action Error Handling (ActionError)
Use ActionError for expected failures (not found, validation, rate limit). Use standard Error for unexpected failures.
typescript
if (response.status === 429) {
throw new nango.ActionError({
type: 'rate_limited',
message: 'API rate limit exceeded',
retry_after: response.headers['retry-after']
});
}
Do not return null-filled objects to indicate "not found". Use ActionError instead.
ActionError response format:
json
{
"error_type": "action_script_failure",
"payload": {
"type": "not_found",
"message": "User not found",
"user_id": "123"
}
}
Action Pagination Standard (List Actions)
All list actions must use cursor/next_cursor regardless of provider naming.
Schema pattern:
typescript
const ListInput = z.object({
cursor: z.string().optional().describe('Pagination cursor from previous response. Omit for first page.')
});
const ListOutput = z.object({
items: z.array(ItemSchema),
next_cursor: z.union([z.string(), z.null()])
});
Provider mapping:
| Provider | Native Input | Native Output | Map To |
|---|
| Slack | cursor | response_metadata.next_cursor | cursor -> next_cursor |
| Notion | start_cursor | next_cursor | cursor -> next_cursor |
| HubSpot | after | paging.next.after | cursor -> next_cursor |
| GitHub | page | Link header | cursor -> next_cursor |
| Google | pageToken | nextPageToken | cursor -> next_cursor |
Example:
typescript
exec: async (nango, input): Promise<z.infer<typeof ListOutput>> => {
const config: ProxyConfiguration = {
endpoint: 'api/items',
params: {
...(input.cursor && { cursor: input.cursor })
},
retries: 3
};
const response = await nango.get(config);
return {
items: response.data.items.map((item: { id: string; name: string }) => ({
id: item.id,
name: item.name
})),
next_cursor: response.data.next_cursor || null
};
}
OnEvent Template (createOnEvent)
Use on-event functions for connection lifecycle hooks:
- : verify credentials/scopes on connection creation
- : run setup after a connection is created
- : cleanup before a connection is deleted
File location convention:
{integrationId}/on-events/<name>.ts
and import it from
.
typescript
import { createOnEvent } from 'nango';
import { z } from 'zod';
export default createOnEvent({
description: 'Validate connection credentials',
version: '1.0.0',
event: 'validate-connection',
metadata: z.void(),
exec: async (nango) => {
// https://api-docs-url
await nango.get({ endpoint: '/me', retries: 3 });
}
});
Sync Template (createSync)
typescript
import { createSync } from 'nango';
import { z } from 'zod';
const RecordSchema = z.object({
id: z.string(),
name: z.union([z.string(), z.null()])
});
const sync = createSync({
description: 'Brief single sentence',
version: '1.0.0',
endpoints: [{ method: 'GET', path: '/provider/records', group: 'Records' }],
frequency: 'every hour',
autoStart: true,
syncType: 'full',
models: {
Record: RecordSchema
},
exec: async (nango) => {
// Sync logic here
}
});
export type NangoSyncLocal = Parameters<(typeof sync)['exec']>[0];
export default sync;
Sync Deletion Detection
- Do not use trackDeletes. It is deprecated.
- Full syncs: call deleteRecordsFromPreviousExecutions at the end of exec after all batchSave calls.
- Incremental syncs: if the API supports it, detect deletions and call batchDelete.
Important: deletion detection is a soft delete. Records remain in the cache but are marked as deleted in metadata.
Safety: only call deleteRecordsFromPreviousExecutions when the run successfully fetched the full dataset. Do not catch and swallow errors and still call it (false deletions).
typescript
await nango.deleteRecordsFromPreviousExecutions('Record');
Full Sync (Recommended)
typescript
exec: async (nango) => {
const proxyConfig = {
// https://api-docs-url
endpoint: 'api/v1/records',
paginate: { limit: 100 },
retries: 3
};
for await (const batch of nango.paginate(proxyConfig)) {
const records = batch.map((r: { id: string; name: string }) => ({
id: r.id,
name: r.name ?? null
}));
if (records.length > 0) {
await nango.batchSave(records, 'Record');
}
}
await nango.deleteRecordsFromPreviousExecutions('Record');
}
Incremental Sync
typescript
const sync = createSync({
syncType: 'incremental',
frequency: 'every 5 minutes',
exec: async (nango) => {
const lastSync = nango.lastSyncDate;
const proxyConfig = {
// https://api-docs-url
endpoint: '/api/records',
params: {
sort: 'updated',
...(lastSync && { since: lastSync.toISOString() })
},
paginate: { limit: 100 },
retries: 3
};
for await (const batch of nango.paginate(proxyConfig)) {
const records = batch.map((record: { id: string; name?: string }) => ({
id: record.id,
name: record.name ?? null
}));
await nango.batchSave(records, 'Record');
}
if (lastSync) {
const deleted = await nango.get({
// https://api-docs-url
endpoint: '/api/records/deleted',
params: { since: lastSync.toISOString() },
retries: 3
});
if (deleted.data.length > 0) {
await nango.batchDelete(
deleted.data.map((d: { id: string }) => ({ id: d.id })),
'Record'
);
}
}
}
});
Sync Metadata (When Required)
typescript
const MetadataSchema = z.object({
team_id: z.string()
});
const sync = createSync({
metadata: MetadataSchema,
exec: async (nango) => {
const metadata = await nango.getMetadata();
const teamId = metadata?.team_id;
if (!teamId) {
throw new Error('team_id is required in metadata.');
}
const response = await nango.get({
// https://api-docs-url
endpoint: `/v1/teams/${teamId}/projects`,
retries: 3
});
}
});
Note: nango.getMetadata() is cached for up to 60 seconds during a sync execution. Metadata updates may not be visible until the next run.
Realtime Syncs (Webhooks)
Use webhookSubscriptions + onWebhook when the provider supports webhooks.
typescript
const sync = createSync({
webhookSubscriptions: ['contact.propertyChange'],
exec: async (nango) => {
// Optional periodic polling
},
onWebhook: async (nango, payload) => {
if (payload.subscriptionType === 'contact.propertyChange') {
const updated = {
id: payload.objectId,
[payload.propertyName]: payload.propertyValue
};
await nango.batchSave([updated], 'Contact');
}
}
});
Optional merge strategy:
typescript
await nango.setMergingStrategy({ strategy: 'ignore_if_modified_after' }, 'Contact');
Key SDK Methods (Sync)
| Method | Purpose |
|---|
| nango.paginate(config) | Iterate through paginated responses |
| nango.batchSave(records, model) | Save records to cache |
| nango.batchDelete(records, model) | Mark as deleted (incremental) |
| nango.deleteRecordsFromPreviousExecutions(model) | Auto-detect deletions (full) |
| nango.lastSyncDate | Last sync timestamp (incremental) |
Pagination Helper (Advanced Config)
Nango preconfigures pagination for some APIs. Override when needed.
Pagination types: cursor, link, offset.
typescript
const proxyConfig = {
endpoint: '/tickets',
paginate: {
type: 'cursor',
cursor_path_in_response: 'next',
cursor_name_in_request: 'cursor',
response_path: 'tickets',
limit_name_in_request: 'limit',
limit: 100
},
retries: 3
};
for await (const page of nango.paginate(proxyConfig)) {
await nango.batchSave(page, 'Ticket');
}
Link pagination uses link_rel_in_response_header or link_path_in_response_body. Offset pagination uses offset_name_in_request.
Manual Cursor-Based Pagination (If Needed)
typescript
let cursor: string | undefined;
while (true) {
const res = await nango.get({
endpoint: '/api',
params: { cursor },
retries: 3
});
const records = res.data.items.map((item: { id: string; name?: string }) => ({
id: item.id,
name: item.name ?? null
}));
await nango.batchSave(records, 'Record');
cursor = res.data.next_cursor;
if (!cursor) break;
}
Dryrun Command Reference
Basic syntax (action or sync):
nango dryrun <script-name> <connection-id>
Actions: pass input:
nango dryrun <action-name> <connection-id> --input '{"key":"value"}'
# For actions with input: z.object({})
nango dryrun <action-name> <connection-id> --input '{}'
Stub metadata (when your function calls nango.getMetadata()):
nango dryrun <script-name> <connection-id> --metadata '{"team_id":"123"}'
nango dryrun <script-name> <connection-id> --metadata @fixtures/metadata.json
Save mocks for tests (implies validation; only saves if validation passes):
nango dryrun <script-name> <connection-id> --save
Notes:
- Connection ID is the second positional argument (no flag).
- Use
--integration-id <integration-id>
when script names overlap across integrations.
- Common flags: ,
-e/--environment dev|prod
, , , --lastSyncDate "YYYY-MM-DD"
, .
- If you do not have on PATH, use .
- In CI/non-interactive runs always pass (otherwise the CLI prompts for environment selection).
- CLI upgrade prompts can block non-interactive runs. Workaround: set
NANGO_CLI_UPGRADE_MODE=ignore
.
Common mistakes:
- Using (does not exist)
- Using legacy flags like or (use and )
- Putting integration ID as the second argument (it will be interpreted as connection ID)
Testing and Validation Workflow
Recommended loop while coding:
- Implement the function file under or .
- Register it via side-effect import in .
- Dryrun with
nango dryrun ... --validate
until it passes.
Dryrun + validate:
- Action:
nango dryrun <action-name> <connection-id> --input '{...}' --validate
- Sync:
nango dryrun <sync-name> <connection-id> --validate
- Incremental sync testing: add
--lastSyncDate "YYYY-MM-DD"
Record mocks + generate tests:
nango dryrun <script-name> <connection-id> --save
(add for actions; add if the script reads metadata)
- (or narrow: , , )
- Run tests via (Vitest) or
Mocks and Test Files (Current Format)
{integrationId}/tests/
|-- <script-name>.test.ts
`-- <script-name>.test.json
The
file is generated by
and contains the recorded API mocks + expected input/output.
Deploy (Optional)
Deploy functions to an environment in your Nango account:
nango deploy dev
# Deploy only one function
nango deploy --action <action-name> dev
nango deploy --sync <sync-name> dev
When API Docs Do Not Render
If web fetching returns incomplete docs (JS-rendered):
- Ask the user for a sample response
- Use existing actions/syncs in the repo as a pattern
- Run dryrun with and build from the captured response
Common Mistakes
| Mistake | Impact | Fix |
|---|
| Missing/incorrect index.ts import | Function not loaded | Add side-effect import () |
| Using legacy dryrun flags (, ) | Dryrun/mocks fail | Use and |
| Calling deleteRecordsFromPreviousExecutions after partial fetch | False deletions | Let failures fail; only call after full successful save |
| trackDeletes: true | Deprecated | Use deleteRecordsFromPreviousExecutions (full) or batchDelete (incremental) |
| Retrying non-idempotent writes blindly | Duplicate side effects | Avoid retries or use provider idempotency keys |
| Using any in mapping | Loses type safety | Use inline types |
| Using --connection-id | Dryrun fails | Use positional connection id |
Final Checklists
Action:
Sync:
OnEvent: