Написание собственных нод для n8n
n8n — это мощная платформа для автоматизации рабочих процессов с открытым исходным кодом, которая позволяет пользователям создавать сложные интеграции без необходимости писать код. Однако иногда возникает необходимость в создании собственных нод — для специфических задач или интеграций с сервисами, которые не поддерживаются из коробки.
В этом руководстве мы разберём путь от «ничего нет» до «своя нода работает локально и опубликована в npm».
Что вы получите
- Быстрый старт проекта на базе
n8n-nodes-starter - Локальный цикл разработки:
build → npm link → n8n UI - Пример Trigger-ноды (Yandex Disk recent changes)
- Паттерны: Transform node (
execute()), Dynamic options (loadOptions), Tool node (supplyData()) - Публикация пакета в npm (Community Packages)
Оглавление
Когда стоит писать свою ноду
Вот представьте: вы искали ноды для решения своей задачи, но не нашли подходящих. Например, хотите удобно интегрировать n8n с API какого-то сервиса.
Да, можно использовать HTTP Request ноду, но это не всегда удобно — особенно если нужно много настроек или специфическая логика обработки данных. Писать каждый раз одно и то же вручную — утомительно и подвержено ошибкам.
Создавая ноду для n8n, вы не обязаны повторять API сервиса слово в слово. Ваша цель — сделать его удобнее: добавить валидацию, поддержку пагинации, кэширование, нормальные ошибки, динамические списки параметров и т.д.
Чтобы понять, какие углы API стоит скруглить, вам обязательно нужно попробовать свою ноду в реальном workflow. Это поможет выявить неудобства и улучшить UX. Например, написав ноду для Speech-to-Text я понял, что использовать enum который ожидает API на вход неудобно. Гораздо удобнее использовать mime-type файла, а внутри ноды уже маппить его на нужный enum. Или работая с Object Storage я понял, что мне не хочется каждый раз вручную собирать URL файла из bucket + path — гораздо удобнее сделать это внутри ноды и вернуть готовый URL.
К сожалению продумать все такие нюансы заранее сложно, но надеюсь вас это не пугает и вы готовы написать свою собственную ноду.
Подготовка проекта
В официальной документации n8n есть руководство по созданию нод. Я рекомендую следовать ему для настройки окружения: Node.js + TypeScript.
n8n рекомендует начинать с шаблона n8n-nodes-starter:
Я при создании своих нод для Yandex Cloud использовал именно этот подход.
Пакеты с нодами для n8n рекомендуется называть с префиксом n8n-nodes-, например n8n-nodes-yc.
Также в package.json укажите keyword:
{
"keywords": ["n8n-community-node-package"]
}
Локальное тестирование и отладка
- Быстрый цикл разработки
- Отладка (VSCode + nodemon)
- Установите n8n локально (если еще не установлен):
npm install n8n -g
- Соберите пакет и “опубликуйте” его локально:
npm run build
npm link
- Подключите пакет в директории пользовательских пакетов n8n (обычно
~/.n8n/custom):
cd ~/.n8n/custom
npm link your-node-package-name
- Запустите n8n:
n8n
- Откройте UI: http://localhost:5678 Проверьте, что ваша нода доступна в интерфейсе.
До версии n8n v2 у меня работала переменная окружения N8N_DEV_MODE=true, но в новых версиях она почему-то не
работает. В итоге я пришёл к nodemon-решению.
Создайте в корне проекта nodemon.json:
{
"watch": ["dist"],
"ext": "js,json",
"delay": 500
}
В VSCode выберите терминал JavaScript Debug Terminal и запустите n8n через nodemon:
nodemon --exec n8n
Теперь вы можете ставить брейкпоинты в TypeScript-коде нод и отлаживать их прямо из VSCode.
Пример: Trigger-нода для Яндекс Диска
Давайте начнем с триггер-ноды, которая будет запускать workflow по получению новых файлов из Яндекс Диска.
Готовый пакет:
Исходный код:
Шаг 1. Скелет ноды
Создадим файл Yandex360DiskTrigger.node.ts в директории nodes/Yandex360Disk/.
export class Yandex360DiskTrigger implements INodeType {
description: INodeTypeDescription = {
displayName: 'Yandex 360 Disk Trigger',
name: 'yandex360DiskTrigger',
icon: 'file:disk.svg',
group: ['trigger'],
version: 1,
subtitle: '={{$parameter["event"]}}',
description: 'Starts the workflow when files or folders change in Yandex Disk',
defaults: {
name: 'Yandex 360 Disk Trigger',
},
polling: true,
inputs: [],
outputs: ['main'],
credentials: [
{
name: 'yandex360OAuth2Api',
required: true,
},
],
...
};
}
Класс ноды нужно называть точно так же, как файл, в котором он расположен.
Разберем description подробно:
displayName— отображаемое имя ноды в UI.name— уникальный идентификатор ноды внутри n8n.icon— иконка ноды. Можно встроенную или файл. Также можно указать объект с 2 иконками для светлой и тёмной темы.group— группа ноды (trigger,transformи т.д.).version— версия ноды (целое число). Так она хранится в БД n8n.subtitle— подзаголовок под именем ноды в UI.description— краткое описание.defaults— настройки по умолчанию.polling— true → нода использует опрос.inputs— для триггер-ноды пусто.outputs— массив выходов (main).credentials— массив учетных данных, необходимых для работы ноды. Здесь мы указываем, что нода требует OAuth2 учетные данные для доступа к Яндекс 360. Нода может использовать несколько типов учетных данных, если это необходимо. Но похоже это редко используется на практике, потому что когда я использовал такой подход в ноде для YDB, то нашел баг.
Шаг 2. Добавляем UI-параметры (properties)
{
...
properties: [
{
displayName: 'Watch For',
name: PARAMS.EVENT,
type: 'options',
required: true,
default: 'updated',
options: [
{
name: 'All Changes',
value: EVENTS.ALL,
description: 'Trigger on any file or folder change (creation, modification, deletion)',
},
{
name: 'File or Folder Created',
value: EVENTS.CREATED,
description: 'Trigger when a new file or folder is created',
},
{
name: 'File or Folder Updated',
value: EVENTS.UPDATED,
description: 'Trigger when a file or folder is modified',
},
],
description: 'The event to watch for',
},
...
]
}
Здесь мы определяем параметр Watch For, который позволяет пользователю выбрать, на какие события он хочет реагировать.
displayName— Человекочитаемое имя параметра.name— Уникальное имя параметра, используемое внутри ноды.type— Тип параметра. В данном случае этоoptions, что означает, что пользователь сможет выбрать одно из нескольких значений. Полный список типов параметров можно найти в документации n8n.required— Булево значение, указывающее на то, что параметр является обязательным.default— Значение по умолчанию для параметра.options— Массив доступных опций для выбора. Каждая опция имеет name,valueиdescription.description— Краткое описание параметра.
Кроме этого, в ноде можно определить дополнительные параметры, такие как Watch Location (где именно, на диске или в
конкретной папке, отслеживать изменения), Path (указать путь к папке, если выбрана конкретная папка) и
Options (дополнительные настройки, такие как фильтрация по типу файла и лимит на количество обрабатываемых элементов
за один опрос).
Теперь, когда мы определили описание ноды и её параметры, нам нужно реализовать логику опроса Яндекс Диска на наличие изменений.
Шаг 3. Реализуем poll()
Идея в том, что мы храним lastTimeChecked в static data, на каждом запуске вызываем API recent(), фильтруем события
и возвращаем найденные изменения как JSON items. После этого обновляем lastTimeChecked.
async poll(this: IPollFunctions): Promise<INodeExecutionData[][] | null> {
// Получаем состояние ноды
const webhookData = this.getWorkflowStaticData('node');
// И значения переданные пользователем в описанные нами инпуты
const event = this.getNodeParameter(PARAMS.EVENT, 0) as string;
const location = this.getNodeParameter(PARAMS.LOCATION, 0) as string;
const options = this.getNodeParameter(PARAMS.OPTIONS, 0, {}) as IYandexDiskTriggerOptions;
try {
// Получаем учетные данные и инициализируем API
const credentials = await this.getCredentials('yandex360OAuth2Api');
const api = initializeYandexDiskApi(credentials);
// Вычисляем временные рамки для поиска изменений
const now = moment().utc().format();
const hourAgo = moment().utc().subtract(1, 'hour').format();
let defaultStartTime = now;
if (isManual(this)) {
defaultStartTime = hourAgo;
}
// Определяем временные рамки для поиска изменений
// Используем lastTimeChecked из статических данных ноды или дефолтное значение
const startDate = (webhookData.lastTimeChecked as string) || defaultStartTime;
const endDate = now;
let items: IYandexDiskResource[] = [];
// Получаем недавние изменения с помощью API Яндекс Диска
try {
// Смотрим на тип файла и сопоставляем с media_type API
const fileType = options.fileType || FILE_TYPES.ALL;
const mediaType = FILE_TYPE_TO_MEDIA_TYPE[fileType];
// Определяем лимит на количество элементов
const userLimit = options.limit || 50;
const apiLimit = Math.min(userLimit, 1000);
// Параметры для вызова метода recent API
const recentOptions: any = { limit: apiLimit };
if (mediaType) {
recentOptions.media_type = mediaType;
}
const response = await api.recent(recentOptions);
const responseBody = response.body;
// Извлекаем элементы из ответа
if (responseBody && typeof responseBody === 'object' && 'items' in responseBody) {
items = (responseBody.items as IYandexDiskResource[]) || [];
}
// Фильтруем по времени изменения
items = filterByModifiedTime(items, startDate, endDate);
// Фильтруем по типу события
items = filterByEventType(items, event);
// Фильтруем по пути, если выбрана конкретная папка
if (location === LOCATIONS.SPECIFIC_PATH) {
const path = this.getNodeParameter(PARAMS.PATH, 0) as string;
items = filterByPath(items, path);
}
// Фильтруем по типу файла
items = filterByFileType(items, fileType);
// Применяем пользовательский лимит
items = applyLimit(items, userLimit);
} catch (error) {
throw new NodeApiError(this.getNode(), error as any, {
message: 'Failed to fetch recent files from Yandex Disk',
description: 'Check your OAuth credentials and configuration',
});
}
// Обновляем lastTimeChecked в состоянии ноды
webhookData.lastTimeChecked = endDate;
// Если есть новые элементы, возвращаем их
if (items.length > 0) {
return [this.helpers.returnJsonArray(items)];
}
return null;
} catch (error) {
// Если это уже NodeApiError или NodeOperationError, просто пробрасываем его дальше
if (error instanceof NodeApiError || error instanceof NodeOperationError) {
throw error;
}
// Иначе оборачиваем в NodeApiError для лучшей интеграции с n8n
throw new NodeApiError(this.getNode(), error as any, {
message: 'An error occurred in Yandex 360 Disk Trigger',
});
}
}
К счастью в API Яндекс Диска есть метод, который позволяет получить недавние изменения — его мы и используем в ноде (recent()).
Регистрация нод и credentials в package.json
Чтобы n8n увидел ноды, их нужно объявить в package.json:
{
"n8n": {
"n8nNodesApiVersion": 1,
"credentials": [
"dist/credentials/Yandex360OAuth2Api.credentials.js"
],
"nodes": [
"dist/nodes/Yandex360Disk/Yandex360Disk.node.js",
"dist/nodes/Yandex360Disk/Yandex360DiskTrigger.node.js"
]
}
}
Чтобы не забывать про шаги, я добавил скрипт, который проверяет, что все dist-ноды перечислены в package.json и
наоборот — все из package.json реально существуют
Скрипт и пример его вывода:
> @nikolaymatrosov/n8n-nodes-yandex360@0.1.1 validate:nodes
> node scripts/validate-nodes.js
🔍 Validating 2 nodes in package.json...
🔍 Validating 1 credentials in package.json...
✅ Found 2 nodes in dist
🔍 Checking for unlisted nodes in dist...
📦 Found 2 .node.js files in dist
✅ All nodes in dist are listed in package.json
🔍 Validating credentials in package.json...
✅ Found 1 credentials in dist
🔍 Checking for unlisted credentials in dist...
📦 Found 1 .credentials.js files in dist
✅ All credentials in dist are listed in package.json
✅ All validations passed!
Публикация пакета
Когда вы будете довольны нодой, можно публиковать в npm:
- Убедитесь, что есть аккаунт на https://www.npmjs.com/
- Логин:
npm login
- Публикация:
npm publish
После этого ноду можно установить через n8n Community Packages или напрямую через npm.
Другие типы нод и полезные паттерны
Помимо Trigger-ноды, в n8n обычно встречаются:
- Transform nodes: выполняют действие (
execute()) - Tool nodes: используются в AI-цепочках/агентах (
supplyData())
Transform node: пример вызова Yandex Cloud Functions
Для transform-ноды нужно реализовать execute():
async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
// Инициализируем массив для хранения результатов
const items = this.getInputData();
const returnData: INodeExecutionData[] = [];
const resource = this.getNodeParameter('resource', 0) as string;
const operation = this.getNodeParameter('operation', 0) as string;
// Получаем учетные данные
const credentials = await this.getCredentials('yandexCloudAuthorizedApi');
// Парсим Service Account JSON
const serviceAccountJson: IIAmCredentials = parseServiceAccountJson(credentials.serviceAccountJson as string);
// Создаем сервис для получения IAM токенов
const iamTokenService = new IamTokenService(serviceAccountJson);
// Так как нода может обрабатывать несколько входных элементов, проходим по каждому из них
for (let i = 0; i < items.length; i++) {
try {
if (resource === 'function' && operation === 'invoke') {
// Получаем параметры ноды
const functionId = this.getNodeParameter('functionId', i) as string;
const httpMethod = this.getNodeParameter('httpMethod', i) as string;
const folderIdOverride = this.getNodeParameter('folderId', i) as string;
const additionalOptions = this.getNodeParameter('additionalOptions', i, {}) as {
queryParameters?: { parameter: Array<{ name: string; value: string }> };
headers?: { header: Array<{ name: string; value: string }> };
};
const folderId = folderIdOverride || (credentials.folderId as string);
if (!folderId) {
throw new NodeOperationError(
this.getNode(),
'Folder ID is required either in credentials or as node parameter',
);
}
// Получаем IAM токен, чтобы иметь возможность вызывать непубличные функции
const token = await withSdkErrorHandling(
this.getNode(),
() => iamTokenService.getToken(),
'get IAM token',
i,
);
// Строим URL для вызова функции
const invokeUrl = `https://functions.yandexcloud.net/${functionId}`;
// Собираем query параметры
const queryParams = new URLSearchParams();
if (additionalOptions.queryParameters?.parameter) {
for (const param of additionalOptions.queryParameters.parameter) {
if (param.name) {
queryParams.append(param.name, param.value);
}
}
}
const url = queryParams.toString()
? `${invokeUrl}?${queryParams.toString()}`
: invokeUrl;
// Собираем заголовки
const headers: Record<string, string> = {
'Authorization': `Bearer ${token}`,
};
// Добавляем дополнительные заголовки, которые указал пользователь
if (additionalOptions.headers?.header) {
for (const header of additionalOptions.headers.header) {
if (header.name) {
headers[header.name] = header.value;
}
}
}
// Настраиваем опции запроса
const requestOptions: RequestInit = {
method: httpMethod,
headers,
};
// Добавляем тело запроса, если это POST
if (httpMethod === 'POST') {
const body = this.getNodeParameter('body', i) as string;
try {
// Validate JSON
JSON.parse(body);
requestOptions.body = body;
headers['Content-Type'] = 'application/json';
} catch (error) {
throw new NodeOperationError(
this.getNode(),
`Invalid JSON in request body: ${error.message}`,
);
}
}
// Выполняем HTTP запрос к функции
const response = await fetch(url, requestOptions);
const responseText = await response.text();
// Парсим ответ
let responseData: any;
try {
responseData = JSON.parse(responseText);
} catch {
// Если не JSON, возвращаем как текст
responseData = responseText;
}
// Собираем заголовки ответа
const responseHeaders: Record<string, string> = {};
response.headers.forEach((value, key) => {
responseHeaders[key] = value;
});
// Добавляем результат в returnData
returnData.push({
json: {
statusCode: response.status,
headers: responseHeaders,
body: responseData,
},
// Сохраняем связь с исходным элементом
pairedItem: { item: i },
});
}
} catch (error) {
// В реальной ноде тут обработка ошибок, которую я тут опустил для краткости
...
}
}
return [returnData];
}
Логика похожа: подготовить параметры → вызвать API → обработать ответ.
Dynamic options: loadOptionsMethod
Полезный UX-паттерн — предзагружать параметры динамически из API.
Например, в ноде для Yandex Cloud Functions можно выбирать Function из выпадающего списка, а не вводить ID вручную:
export class YandexCloudFunctions implements INodeType {
description: INodeTypeDescription = {
properties: [
...other_properties,
{
displayName: 'Function Name or ID',
name: 'functionId',
type: 'options',
typeOptions: {
loadOptionsMethod: 'loadFunctions',
loadOptionsDependsOn: ['folderId'],
},
required: true,
displayOptions: {
show: {
resource: ['function'],
operation: ['invoke'],
},
},
default: '',
description: 'The function to invoke. Choose from the list, or specify an ID using an <a href="https://docs.n8n.io/code/expressions/">expression</a>.',
}
]
}
}
Обратите внимание:
typeOptions.loadOptionsMethod— имя методаloadOptionsDependsOn— зависимости для перезагрузки списка
Пример реализации функции loadFunctions, на которую ссылается loadOptionsMethod. Она должна быть определена в
проперти methods класса ноды:
methods = {
loadOptions: {
async loadFunctions(this: ILoadOptionsFunctions): Promise<INodePropertyOptions[]> {
const credentials = await this.getCredentials('yandexCloudAuthorizedApi');
// Парсим Service Account JSON
let serviceAccountJson: IIAmCredentials = parseServiceAccountJson(credentials.serviceAccountJson as string);
// Получаем folderId из параметров ноды или из учетных данных
const folderIdOverride = this.getNodeParameter('folderId', '') as string;
const folderId = folderIdOverride || (credentials.folderId as string);
if (!folderId || typeof folderId !== 'string') {
throw new NodeOperationError(
this.getNode(),
'Folder ID is required either in credentials or as node parameter to list functions',
);
}
try {
// Инициализируем сессию и клиент Function Service
const session = new Session({ serviceAccountJson });
const client = session.client(functionService.FunctionServiceClient);
// Вызываем метод list для получения функций
let response;
try {
response = await client.list(
functionService.ListFunctionsRequest.fromPartial({ folderId }),
);
} catch (error) {
throw new YandexCloudSdkError(this.getNode(), error as Error, {
operation: 'list functions',
});
}
// Формируем и возвращаем опции для выпадающего списка
return response.functions.map((func: functionType.Function) => ({
name: `${func.name} (${func.id})`,
value: func.id,
description: func.httpInvokeUrl || func.id,
}));
} catch (error) {
// В реальной ноде тут обработка ошибок, которую я тут опустил для краткости
throw error;
}
},
},
};
Tool node: Yandex AI Response API
Еще один интересный тип нод — суб-ноды для AI-цепочек и агентов. В этом примере мы рассмотрим tool-ноду, которая позволяет искать по векторному хранилищу через Yandex AI Responses API.
В отличие от transform-ноды, tool-нода в нашем случае не реализует execute(). Вместо этого она возвращает настройки через supplyData():
export class YandexFileSearchTool implements INodeType {
description: INodeTypeDescription = {
displayName: 'Yandex File Search Tool',
name: 'yandexFileSearchTool',
icon: 'file:yandexFileSearchTool.svg',
group: ['transform'],
version: 1,
description: 'File search tool for Yandex AI Responses API using vector stores',
defaults: {
name: 'Yandex File Search',
},
codex: {
categories: ['AI'],
subcategories: {
AI: ['Tools'],
},
},
inputs: [],
outputs: [NodeConnectionTypes.AiTool],
outputNames: ['Tool'],
credentials: [
{
name: 'yandexOpenAIApi',
required: true,
},
],
properties: [
{
displayName: 'Vector Store',
name: 'vectorStoreId',
type: 'resourceLocator',
required: true,
default: { mode: 'list', value: '' },
modes: [
{
displayName: 'From List',
name: 'list',
type: 'list',
typeOptions: {
searchListMethod: 'loadVectorStores',
searchable: true,
},
},
{
displayName: 'By ID',
name: 'id',
type: 'string',
placeholder: 'vs_abc123',
},
],
description: 'Vector store to search in',
},
],
};
methods = {
listSearch: {
loadVectorStores,
},
};
async supplyData(this: ISupplyDataFunctions, itemIndex: number): Promise<SupplyData> {
const vectorStoreId = this.getNodeParameter(
'vectorStoreId',
itemIndex,
{ mode: 'id', value: '' },
{ extractValue: true },
) as string;
const toolConfig: FileSearchToolConfig = {
type: 'file_search',
vector_store_ids: [vectorStoreId],
};
const wrapper: YandexBuiltInToolWrapper = {
__yandexBuiltInTool: true,
config: toolConfig,
};
return {
response: wrapper,
};
}
}
Код этих нод вы можете найти на гитхабе
Чек-лист качества
Этот список — про то, чтобы ваша нода была не просто «работает у меня», а нормально переживала установку, обновления и реальное использование в workflow. Самая частая боль на старте — когда код уже собран, но ноды нет в интерфейсе n8n. Поэтому сначала проверяем базовую интеграцию: пакет должен корректно линковаться локально и появляться в UI (это сразу отсекает проблемы со сборкой, путями в dist, именами файлов/классов и регистрацией в package.json). Следующий слой — соответствие манифеста реальному содержимому: все dist/**/*.node.js и dist/**/*.credentials.js должны быть перечислены в секции n8n вашего package.json, иначе n8n просто не «увидит» часть функциональности (и вы получите странные симптомы вроде «нода есть, но креды не доступны», или наоборот).
Дальше начинается то, что отличает хорошую community-ноду от «сырого прототипа»: ошибки и UX. Любая ошибка внешнего API, невалидный ввод, отсутствие обязательного параметра или проблемы с авторизацией должны превращаться в понятные NodeApiError/NodeOperationError с нормальными message и description — это то, что пользователь реально увидит в UI и логах. И обязательно закладывайте защиту от «случайных DoS» самим себе: лимиты, пагинация и валидация параметров (например, максимальный limit, проверка JSON тела, проверка обязательных полей, sane defaults). В конце — документация: README с установкой, объяснением credentials (что где брать), минимальным примером workflow и парой скриншотов. Это резко снижает количество вопросов и делает пакет реально пригодным для использования не только вами, но и другими.
Итак, чек-лист:
- Нода появляется в UI локально (
npm link+n8n) - Все
dist/**/*.node.jsперечислены вpackage.json - Все
credentialsперечислены вpackage.json - Ошибки обёрнуты в
NodeApiError/NodeOperationErrorсmessage/description - Есть лимиты/пагинация/валидация параметров
- README: установка, настройки credentials, примеры workflow, скриншоты