Overview
Creating a custom node involves defining Zod schemas for inputs and outputs, implementing an executor function, and registering the node with the registry.import { z } from 'zod';
const MyInputSchema = z.object({
query: z.string(),
limit: z.number().min(1).max(100).default(10),
includeMetadata: z.boolean().optional(),
});
const MyOutputSchema = z.object({
results: z.array(z.object({
id: z.string(),
title: z.string(),
score: z.number(),
})),
totalCount: z.number(),
});
Schema design tips:
.optional()for non-required fields.default(value)for defaults (rendered as optional in generated forms)z.enum([...])for dropdowns in generated forms.describe('...')for field descriptions (used by schema introspector)- Nested
z.object()creates collapsible sections in the config panel
import { defineNode } from '@jam-nodes/core';
export const mySearchNode = defineNode({
type: 'my_search',
name: 'My Search',
description: 'Search for items matching a query',
category: 'integration',
inputSchema: MyInputSchema,
outputSchema: MyOutputSchema,
estimatedDuration: 10,
capabilities: {
supportsRerun: true,
supportsCancel: true,
},
executor: async (input, context) => {
try {
const results = await doSearch(input.query, input.limit);
return {
success: true,
output: { results, totalCount: results.length },
};
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Search failed',
};
}
},
});
actionlogicintegrationtransformNodes access API credentials via
context.credentials. Always check availability and make direct HTTP calls:executor: async (input, context) => {
const apiKey = context.credentials?.apollo?.apiKey;
if (!apiKey) {
return { success: false, error: 'Apollo API key not configured.' };
}
const response = await fetch('https://api.apollo.io/api/v1/mixed_people/search', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Api-Key': apiKey,
},
body: JSON.stringify({ person_titles: input.titles }),
});
const data = await response.json();
return { success: true, output: { contacts: data.people } };
}
apolloapiKeytwitterbearerToken, twitterApiIoKeyforumScoutapiKeydataForSeoapiToken (Base64 encoded)openaiapiKeyanthropicapiKeyThe host application provides credentials when executing nodes. For the playground CLI, credentials are loaded from environment variables or the encrypted credential store.
executor: async (input, context) => {
// User and workflow info
context.userId;
context.workflowExecutionId;
context.campaignId;
// Access upstream node outputs
const prevData = context.variables['previousNodeId'];
const nested = context.resolveNestedPath('contacts[0].email');
return {
success: true,
output: { /* ... */ },
// Optional: conditional branching
nextNodeId: someCondition ? 'nodeA' : 'nodeB',
// Optional: request approval
needsApproval: {
resourceIds: ['id1'],
resourceType: 'email_draft',
message: 'Please review',
},
// Optional: notification
notification: {
title: 'Complete',
message: 'Found 42 results',
},
};
}
import { NodeRegistry } from '@jam-nodes/core';
import { builtInNodes } from '@jam-nodes/nodes';
import { mySearchNode } from './my-search-node';
const registry = new NodeRegistry();
registry.registerAll(builtInNodes);
registry.register(mySearchNode);
// Test with the CLI playground
// jam run my_search --mock
import { createExecutionContext } from '@jam-nodes/core';
import { mySearchNode } from './my-search-node';
const ctx = createExecutionContext({ previousData: 'hello' });
const nodeContext = ctx.toNodeContext('test-user', 'test-run');
nodeContext.services = { /* mock services */ } as any;
const result = await mySearchNode.executor(
{ query: 'test', limit: 5 },
nodeContext
);
expect(result.success).toBe(true);
expect(result.output?.totalCount).toBeGreaterThan(0);
// Schema validation
const validated = mySearchNode.inputSchema.parse({ query: 'test' });
expect(() => mySearchNode.inputSchema.parse({})).toThrow();

