homeresume
 
   
🔍

Conversation memory for LangChain agents

June 18, 2026

This post extends the support triage agent from Building AI agents with LangChain into a multi-turn flow: turn 1 looks up the customer and invoice; turn 2 creates the ticket without the user repeating IDs. It is post #5 in the LangChain series, following the overview, loaders/chunking, RAG, and agents posts.

Prerequisites

  • OpenAI account
  • Generated API key
  • Enabled billing
  • Node.js version 26
  • Packages from the agents post, plus the checkpoint package:
npm i langchain @langchain/openai @langchain/core @langchain/langgraph-checkpoint zod
  • OPENAI_API_KEY set in the environment

Mental model

Three related concepts:

  • Checkpointer - short-term session memory. Saves messages and graph state after each step so the next invoke on the same thread can resume.
  • thread_id - conversation key passed in configurable. Same ID = same history; different ID = isolated session.
  • Store - long-term memory across threads (user preferences, facts learned over time). LangGraph stores are separate from checkpointers; this post focuses on checkpointers only.

Typical support flow with memory:

  1. Turn 1 - rep asks to look up cus_1042 and inv_8891; agent calls lookup tools and summarizes findings.
  2. Turn 2 - rep says "create the ticket we discussed"; agent recalls prior tool results and calls create_support_ticket.

MemorySaver

For demos and tests, use MemorySaver - an in-memory checkpointer that persists state for the lifetime of the process:

import { MemorySaver } from '@langchain/langgraph-checkpoint';
const checkpointer = new MemorySaver();

State is lost when the Node process exits. That is fine for local scripts; production apps need a durable backend (see below).

Attach a checkpointer to createAgent

Pass the checkpointer when creating the agent. Reuse the same triage tools and instructions from the agents post:

import { createAgent } from 'langchain';
import { MemorySaver } from '@langchain/langgraph-checkpoint';
const agent = createAgent({
model: 'gpt-5.5',
tools: supportTools,
systemPrompt: TRIAGE_INSTRUCTIONS,
checkpointer: new MemorySaver(),
});

The agent loop is unchanged - the checkpointer hooks into LangGraph beneath createAgent.

First turn - lookup

Pass a stable thread_id in the invoke config:

const threadConfig = { configurable: { thread_id: 'support-cus-1042' } };
const turn1 = await agent.invoke(
{
messages: [
{
role: 'user',
content:
'Look up customer cus_1042 and invoice inv_8891 for a possible duplicate charge. Summarize what you find. Do not create a ticket yet.',
},
],
},
threadConfig,
);
console.log(turn1.messages.at(-1)?.content);

The agent calls get_customer, get_invoice, and search_knowledge_base. LangGraph saves the full message history (including tool results) to the checkpointer.

Second turn - follow-up without IDs

Send only the new user message on the same thread_id. Prior context is restored automatically:

const turn2 = await agent.invoke(
{
messages: [
{
role: 'user',
content: 'Create the support ticket we discussed.',
},
],
},
threadConfig,
);
console.log(turn2.messages.at(-1)?.content);

The agent should call create_support_ticket using customer and invoice details from turn 1 - the user does not repeat cus_1042 or inv_8891.

Read the final answer from result.messages as in the agents post:

const lastAi = [...turn2.messages]
.reverse()
.find((message) => message.type === 'ai');
console.log(lastAi?.content);

Thread isolation

Different thread_id values do not share history. Two support reps working different cases should use separate thread IDs:

await agent.invoke(
{ messages: [{ role: 'user', content: 'Look up cus_1042.' }] },
{ configurable: { thread_id: 'rep-alice-case-1' } },
);
await agent.invoke(
{ messages: [{ role: 'user', content: 'Create the ticket we discussed.' }] },
{ configurable: { thread_id: 'rep-bob-case-2' } },
);

The second invoke on rep-bob-case-2 has no knowledge of Alice's lookup - Bob's thread starts empty.

Production checkpointers

MemorySaver is process-local and not suitable for production. LangGraph supports durable checkpointers backed by Postgres, SQLite, and other stores via @langchain/langgraph-checkpoint integrations. Swap the checkpointer implementation; the thread_id API stays the same.

Pick a backend that matches your deployment: Postgres for multi-instance apps, SQLite for single-node services.

Demo

See the langchain-agent-memory-nodejs-demo folder for multi-turn triage and thread-isolation scripts.

Building AI agents with LangChain

June 17, 2026

LangChain agents are built on LangGraph: the model calls tools in a loop until it returns a final answer. The high-level entry point is createAgent - pass a model, tools defined with tool(), and an optional systemPrompt.

This post builds the same support triage agent as the Vercel AI SDK agents and OpenAI Agents SDK posts so you can compare SDKs on one scenario. It follows the LangChain overview for Node.js and fits as post #4 in the LangChain series (after loaders/chunking and the RAG with pgvector pipeline).

Prerequisites

  • OpenAI account
  • Generated API key
  • Enabled billing
  • Node.js version 26
  • langchain, @langchain/openai, @langchain/core, and zod installed:
npm i langchain @langchain/openai @langchain/core zod
  • OPENAI_API_KEY set in the environment

Mental model - turns and the agent loop

A turn is one model generation. In that turn the model either:

  • returns final text (the run ends), or
  • returns tool calls (LangChain executes them and starts another turn with the results)

Typical flow for the support triage agent: user question → model calls lookup tools (get_customer, get_invoice, search_knowledge_base) → model creates a ticket or escalates → final answer.

A single turn can include multiple parallel tool calls. Set recursionLimit on invoke or stream to cap how many graph steps run (each model generation and tool batch counts toward the limit).

Defining tools

Use tool() from langchain with a Zod schema, plus name and description so the model knows when to call each tool:

import { tool } from 'langchain';
import { z } from 'zod';
const getInvoice = tool(
async ({ invoiceId }) => {
const invoice = invoices.find((item) => item.id === invoiceId);
if (!invoice) {
return { found: false, invoiceId, error: 'Invoice not found' };
}
return { found: true, invoice };
},
{
name: 'get_invoice',
description: 'Look up an invoice by ID, including payment IDs and status',
schema: z.object({
invoiceId: z.string().describe('Invoice ID, e.g. inv_8891'),
}),
},
);

LangChain uses schema (not Vercel's inputSchema or OpenAI Agents' parameters). The handler receives validated input as the first argument.

createAgent

Wire the model, tools, and triage instructions:

import { createAgent } from 'langchain';
const agent = createAgent({
model: 'gpt-5.5',
tools: [getInvoice],
systemPrompt: `You are a billing support triage agent.
Look up records before recommending refunds or creating tickets.`,
});

model can be a provider string ('gpt-5.5', 'openai:gpt-5.5') or a chat model instance from @langchain/openai.

Invoke

Pass a messages array and read the final answer from result.messages:

const result = await agent.invoke({
messages: [
{
role: 'user',
content: 'What is the status of invoice inv_8891? Reply in one sentence.',
},
],
});
const lastAi = [...result.messages]
.reverse()
.find((message) => message.type === 'ai');
console.log(lastAi?.content);

The last AI message is the agent's final reply after any tool calls complete.

Support triage scenario

Example prompt:

Customer cus_1042 says they were charged twice for invoice inv_8891. What should we do?

A realistic chain:

  1. get_customer - plan tier, open ticket count
  2. get_invoice - amount, status, payment IDs
  3. search_knowledge_base - duplicate-charge and refund policy
  4. create_support_ticket or escalate_to_human - write action or escalation

The demo uses in-memory fixtures (customers, invoices, knowledge-base articles) so scripts run without a database.

Multi-tool agent

Register all triage tools on one agent:

import { createAgent } from 'langchain';
import {
getCustomer,
getInvoice,
searchKnowledgeBase,
createSupportTicket,
escalateToHuman,
TRIAGE_INSTRUCTIONS,
} from './tools/index.js';
const agent = createAgent({
model: 'gpt-5.5',
tools: [
getCustomer,
getInvoice,
searchKnowledgeBase,
createSupportTicket,
escalateToHuman,
],
systemPrompt: TRIAGE_INSTRUCTIONS,
});
const result = await agent.invoke({
messages: [
{
role: 'user',
content:
'Customer cus_1042 says they were charged twice for invoice inv_8891. What should we do?',
},
],
recursionLimit: 15,
});
const answer = [...result.messages]
.reverse()
.find((message) => message.type === 'ai');
console.log(answer?.content);

Inspect result.messages for the full trace: human input, AI tool-call messages, tool results, and the final AI reply.

Streaming

agent.stream() yields state updates as the graph runs. Use streamMode: 'values' to receive the full message list after each step:

const stream = await agent.stream(
{
messages: [
{
role: 'user',
content:
'Customer cus_1042 says they were charged twice for invoice inv_8891. What should we do?',
},
],
},
{ streamMode: 'values', recursionLimit: 15 },
);
let finalMessages = [];
for await (const state of stream) {
if (state.messages) {
finalMessages = state.messages;
}
}
const answer = [...finalMessages]
.reverse()
.find((message) => message.type === 'ai');
console.log(answer?.content);

For token-level streaming, use streamMode: 'messages' or streamEvents (see LangGraph streaming).

When to pick LangChain

LangChain createAgentVercel AI SDKOpenAI Agents SDK
Best forRAG + LCEL + agents in one stackTypeScript apps already on AI SDKOpenAI-first agent primitives
Tool definitiontool() + Zod schematool() + inputSchematool() + Zod parameters
Run APIagent.invoke / agent.streamgenerateText + stopWhenrun() + maxTurns
Handoffs / guardrailsMiddleware (advanced)LimitedBuilt-in
MemoryLangGraph checkpointersBring your ownSession helpers

Pick LangChain when document loaders, retrievers, and agents should share one ecosystem. Pick Vercel AI SDK or OpenAI Agents SDK when you want a focused agent layer without the broader LangChain surface.

Demo

See the langchain-agents-nodejs-demo folder for runnable scripts: single-tool lookup, full triage, and streaming.

Document loaders and chunking with LangChain

June 16, 2026

This post covers local file ingestion and chunking in Node.js. For LangChain basics (LCEL, packages, agents), see the LangChain overview post. For the full RAG chain with pgvector, see the RAG with pgvector post.

Prerequisites

  • Node.js version 26
  • langchain, @langchain/core, @langchain/classic, and @langchain/textsplitters installed
npm i langchain @langchain/core @langchain/classic @langchain/textsplitters

More loader types (web, cloud, audio) live in standalone integration packages - see the document loader integrations page.

The Document type

Every loader returns Document instances from @langchain/core:

  • pageContent - the text of the chunk or file
  • metadata - optional key/value pairs (source path, section, page) used for citations
import { Document } from '@langchain/core/documents';
const doc = new Document({
pageContent: 'pgvector adds vector search to PostgreSQL.',
metadata: { source: 'notes/pgvector.txt', section: 'basics' }
});

Load a single file

Use TextLoader for plain text or markdown files:

import { TextLoader } from '@langchain/classic/document_loaders/fs/text';
const loader = new TextLoader('./notes/pgvector.txt');
const docs = await loader.load();
console.log(docs[0].pageContent);
console.log(docs[0].metadata.source);

The loader sets metadata.source to the file path - keep it for citations in RAG answers.

Load a directory

Use DirectoryLoader when you have many files. Map extensions to loader factories:

import { DirectoryLoader } from '@langchain/classic/document_loaders/fs/directory';
import { TextLoader } from '@langchain/classic/document_loaders/fs/text';
const loader = new DirectoryLoader('./notes', {
'.txt': (path) => new TextLoader(path),
'.md': (path) => new TextLoader(path)
});
const docs = await loader.load();
console.log(`Loaded ${docs.length} documents`);

PDF, CSV, and JSON loaders are available via other integration packages. This post uses .txt and .md files.

Split documents

Chunking makes retrieval more precise. Instead of embedding one large file, split it into smaller overlapping parts. Pass the docs array from TextLoader or DirectoryLoader to a splitter:

Two parameters matter most:

  • chunkSize - target maximum size per chunk (characters or tokens, depending on splitter)
  • chunkOverlap - shared text between adjacent chunks so context is not lost at boundaries

Start with chunkSize: 800 and chunkOverlap: 120, then tune based on document style and answer quality.

import { RecursiveCharacterTextSplitter } from '@langchain/textsplitters';
const splitter = new RecursiveCharacterTextSplitter({
chunkSize: 800,
chunkOverlap: 120
});
const chunks = await splitter.splitDocuments(docs);
console.log(chunks.length);

Splitter comparison

The example above uses RecursiveCharacterTextSplitter, the default for most RAG setups. Alternatives:

SplitterBest for
RecursiveCharacterTextSplitterDefault choice; tries paragraphs, then sentences, then words
CharacterTextSplitterFixed character windows when structure does not matter
TokenTextSplitterWhen chunk limits must match model token budgets

Character-based:

import { CharacterTextSplitter } from '@langchain/textsplitters';
const splitter = new CharacterTextSplitter({
chunkSize: 800,
chunkOverlap: 120
});
const chunks = await splitter.splitDocuments(docs);

Token-based:

import { TokenTextSplitter } from '@langchain/textsplitters';
const splitter = new TokenTextSplitter({
encodingName: 'cl100k_base',
chunkSize: 200,
chunkOverlap: 20
});
const chunks = await splitter.splitDocuments(docs);

Use token-based splitting when chunks must fit within a model's context window. Character-based recursive splitting is the usual starting point for RAG over prose.

Metadata through the pipeline

Pass metadata when creating documents manually, or rely on loader metadata - splitters preserve it on each chunk:

const splitter = new RecursiveCharacterTextSplitter({
chunkSize: 400,
chunkOverlap: 60
});
const chunks = await splitter.createDocuments(
['First paragraph.\n\nSecond paragraph.'],
[{ source: 'manual', section: 'intro' }]
);
console.log(chunks[0].metadata);

After splitDocuments(docs), each chunk keeps fields like source from the parent document. Use those fields when storing chunks in a vector database or displaying citations.

Choosing parameters

  • Short FAQs or API docs - smaller chunkSize (300–500) for precise retrieval
  • Long guides or blog posts - larger chunkSize (800–1200) to keep sections together
  • More overlap - helps when answers span chunk boundaries; increases storage and embedding cost
  • Less overlap - fewer redundant chunks; risk losing context at splits

Tune with real questions from your domain.

Demo

Runnable loader and splitter scripts for this post live in the langchain-loaders-chunking-demo folder. Get access via code demos.

LangChain overview for Node.js

June 15, 2026

LangChain.js is a framework for LLM applications in TypeScript and Node.js. It standardizes how you wire prompts, models, tools, document loaders, embeddings, and retrievers into reusable pipelines and agents.

LangChain, Deep Agents, LangGraph, and LangSmith

ProjectRole
LangChainHigh-level APIs: LCEL chains, createAgent, loaders, retrievers
Deep AgentsBatteries-included agent harness: planning, subagents, filesystem, context management
LangGraphLow-level orchestration; LangChain agents run on LangGraph under the hood
LangSmithTracing, debugging, and evaluation for LangChain and LangGraph apps

Use Deep Agents for complex multi-step tasks out of the box. Use LangChain's createAgent when you want a minimal harness you compose with middleware. Reach for LangGraph when you need custom stateful workflows, branching, or fine-grained control over the agent loop.

Packages

Install the core packages first (install guide):

npm i langchain @langchain/core @langchain/openai zod

Provider-specific integrations live in separate packages:

  • langchain - createAgent, tool, and high-level chain helpers
  • zod - tool input schemas when defining tools with tool()
  • @langchain/core - prompts, output parsers, Runnable interface, LCEL
  • @langchain/openai - ChatOpenAI, OpenAIEmbeddings
  • @langchain/textsplitters - document chunking (used in the RAG post)
  • Standalone integration packages for other providers and tools (see the integrations page)

For raw API access, see the Chat Completions and OpenAI Responses API posts. For provider-agnostic text and agents, see the Vercel AI SDK and OpenAI Agents SDK posts.

When to use LangChain

ToolBest for
Raw openai packageMinimal calls, full control, least abstraction
Vercel AI SDKProvider-agnostic generateText, streaming, embeddings, tool loops
OpenAI Agents SDKOfficial agent loop, handoffs, guardrails
LangChainDocument ingestion, retrievers, LCEL chains, createAgent, swappable vector stores

Reach for LangChain when RAG or multi-step LLM pipelines grow beyond a few manual API calls.

Prerequisites

  • OpenAI account
  • Generated API key
  • Enabled billing
  • Node.js version 26
  • langchain, @langchain/core, @langchain/openai, and zod installed
  • OPENAI_API_KEY set in the environment

Core concepts

Document - a chunk of text with optional metadata. Loaders produce Document instances; splitters break long sources into retrieval-friendly pieces.

import { Document } from '@langchain/core/documents';
const doc = new Document({
pageContent: 'LangChain helps compose LLM pipelines.',
metadata: { source: 'intro' }
});

Runnable - any component with .invoke(), .stream(), or .batch(). Prompts, models, parsers, and composed chains are all Runnables.

LCEL (LangChain Expression Language) - chain Runnables with .pipe(). Data flows left to right: prompt → model → parser. The same .invoke(), .stream(), and .batch() interface applies to every Runnable in the chain.

import { ChatPromptTemplate } from '@langchain/core/prompts';
import { StringOutputParser } from '@langchain/core/output_parsers';
import { ChatOpenAI } from '@langchain/openai';
const prompt = ChatPromptTemplate.fromMessages([
['system', 'Answer in one sentence.'],
['human', '{question}']
]);
const model = new ChatOpenAI({ model: 'gpt-5.5' });
const chain = prompt.pipe(model).pipe(new StringOutputParser());
const answer = await chain.invoke({ question: 'What is LangChain?' });
console.log(answer);

Agents - LangChain's current high-level agent API is createAgent. Pass a model string or chat model, optional tools (with zod schemas), and an optional checkpointer for conversation memory (@langchain/langgraph). For tools and the support triage scenario, see the agents post.

import { createAgent } from 'langchain';
const agent = createAgent({
model: 'gpt-5.5',
tools: []
});
const result = await agent.invoke({
messages: [{ role: 'user', content: 'What is LangChain?' }]
});

Structured output - return typed JSON instead of free text. In LCEL chains, call .withStructuredOutput() on a chat model with a Zod schema:

import { z } from 'zod';
import { ChatPromptTemplate } from '@langchain/core/prompts';
import { ChatOpenAI } from '@langchain/openai';
const schema = z.object({
answer: z.string(),
confidence: z.number(),
});
const prompt = ChatPromptTemplate.fromMessages([
['system', 'Answer briefly and rate your confidence from 0 to 1.'],
['human', '{question}'],
]);
const model = new ChatOpenAI({ model: 'gpt-5.5' }).withStructuredOutput(schema);
const result = await prompt.pipe(model).invoke({ question: 'What is LangChain?' });
console.log(result);

On agents, pass the same schema as responseFormat and read result.structuredResponse:

import { createAgent } from 'langchain';
import { z } from 'zod';
const schema = z.object({ answer: z.string(), confidence: z.number() });
const agent = createAgent({
model: 'gpt-5.5',
tools: [],
responseFormat: schema,
});
const result = await agent.invoke({
messages: [{ role: 'user', content: 'What is LangChain?' }],
});
console.log(result.structuredResponse);

What LangChain can do

  • Load and split documents - file and directory loaders, text splitters (see the loaders and chunking post); PDF, HTML, CSV via integration packages
  • Embeddings and vector stores - OpenAI embeddings with pgvector, Pinecone, Chroma, and others
  • Retrievers and RAG chains - fetch relevant context, then call a model (see the RAG with pgvector post)
  • Conversation memory - short-term memory via @langchain/langgraph checkpointers and thread_id (see the agent memory post); long-term memory via stores
  • Tools and agents - createAgent with tools and middleware; for production agents you may also prefer the Vercel AI SDK agents post or OpenAI Agents SDK post
  • Structured output - Zod schemas via .withStructuredOutput() on a chat model or responseFormat on createAgent; read parsed objects from the chain result or result.structuredResponse
  • Observability - trace runs with LangSmith (LANGSMITH_TRACING=true); optional LangSmith Engine monitors traces and flags issues

Streaming and batch

The same LCEL chain supports streaming and batch invocation:

for await (const chunk of await chain.stream({ question: 'What is LCEL?' })) {
process.stdout.write(chunk);
}
const answers = await chain.batch([
{ question: 'What is a Runnable?' },
{ question: 'What is a retriever?' }
]);

Demo

Runnable LCEL scripts for this post live in the langchain-overview-nodejs-demo folder. Get access via code demos.

Node Version Manager (nvm) for Windows

June 14, 2026

On Windows, nvm-windows manages multiple Node.js versions. It is a separate project from nvm-sh used on macOS and Linux - see the nvm overview post for that setup.

As of mid-2026, Node.js 24 is Active LTS, 22 is Maintenance LTS, and 26 is the Current release. nvm-windows 1.2.2 is the latest release.

Uninstall any existing Node.js installation before installing nvm-windows to avoid PATH conflicts.

Installation

Download and run nvm-setup.exe from the nvm-windows releases page.

Alternatively, install with a package manager:

winget install CoreyButler.NVMforWindows
choco install nvm

Open a new terminal and verify the installation:

nvm version

Version management

  • Install a specific version

    nvm install 24.16.0
  • Install the latest Current release

    nvm install latest
  • Install the latest LTS release

    nvm install lts
  • Install the latest patch for a major version

    nvm install 24
  • Switch to an installed version

    nvm use 24.16.0
  • Add a .nvmrc file inside the project directory and run nvm use to activate the version it specifies

    24.16.0
  • List locally installed versions

    nvm list
  • List versions available for installation

    nvm list available
  • Show the active Node version

    nvm current

Differences from nvm-sh

Featurenvm-windowsnvm-sh (macOS/Linux)
Version format1.2.20.40.5
Install latestnvm install latestnvm install node
Install LTSnvm install ltsnvm install --lts
List remote versionsnvm list availablenvm ls-remote
List local versionsnvm list or nvm lsnvm ls

Building AI agents with OpenAI Agents SDK

June 12, 2026

The OpenAI Agents SDK (@openai/agents) is OpenAI's official framework for agentic apps in TypeScript. It provides a small set of primitives: Agent, tools, handoffs, guardrails, and a run loop managed by run().

This post builds the same support triage agent as the Building AI agents with Vercel AI SDK post - lookup customers and invoices, search a knowledge base, then create a ticket or escalate - but uses the OpenAI SDK instead of the Vercel tool loop.

For lower-level API access, see the OpenAI Responses API post. For the Vercel AI SDK alternative (generateText, stopWhen, stepCountIs), see the Vercel AI SDK agents post. For the same scenario with LangChain (createAgent, tool(), agent.invoke), see Building AI agents with LangChain.

Prerequisites

  • OpenAI account
  • Generated API key
  • Enabled billing
  • Node.js version 26
  • @openai/agents and zod installed (npm i @openai/agents zod)
  • OPENAI_API_KEY set in the environment

Mental model - turns and the agent loop

A turn is one model generation. In that turn the model either:

  • returns final output (the run ends), or
  • returns tool calls (the SDK executes them and starts another turn with the results), or
  • requests a handoff to another agent (control switches, history is preserved, loop continues)

Typical flow for the support triage agent: user question → model calls lookup tools (get_customer, get_invoice, search_knowledge_base) → model creates a ticket or escalates → final answer.

maxTurns: 8 means “stop after 8 turns” (eight model generations), not eight individual tool calls. A single turn can include multiple parallel tool calls.

When you omit maxTurns, the SDK defaults to 10 as a safety cap.

Support triage scenario

Example prompt:

Customer cus_1042 says they were charged twice for invoice inv_8891. What should we do?

A realistic chain:

  1. get_customer - plan tier, open ticket count
  2. get_invoice - amount, status, payment IDs
  3. search_knowledge_base - duplicate-charge and refund policy
  4. create_support_ticket or escalate_to_human - write action or escalation

The demo uses in-memory fixtures (customers, invoices, knowledge-base articles) so scripts run without a database.

Defining multiple tools

Register tools with tool() and Zod parameters. Clear description values help the model pick the right tool.

import { tool } from '@openai/agents';
import { z } from 'zod';
const getCustomer = tool({
name: 'get_customer',
description: 'Look up a customer account by ID',
parameters: z.object({
customerId: z.string().describe('Customer ID, e.g. cus_1042'),
}),
execute: async ({ customerId }) => {
const customer = customers.find((item) => item.id === customerId);
if (!customer) {
return { found: false, customerId, error: 'Customer not found' };
}
return { found: true, customer };
},
});
const getInvoice = tool({
name: 'get_invoice',
description: 'Look up an invoice by ID, including payment IDs and status',
parameters: z.object({
invoiceId: z.string().describe('Invoice ID, e.g. inv_8891'),
}),
execute: async ({ invoiceId }) => {
const invoice = invoices.find((item) => item.id === invoiceId);
if (!invoice) {
return { found: false, invoiceId, error: 'Invoice not found' };
}
return { found: true, invoice };
},
});
const searchKnowledgeBase = tool({
name: 'search_knowledge_base',
description: 'Search internal support articles by keyword',
parameters: z.object({
query: z.string().describe('Search terms, e.g. duplicate charge refund'),
}),
execute: async ({ query }) => {
// keyword match against mocked articles
return { query, articles: matches };
},
});

Add write tools for outcomes:

const createSupportTicket = tool({
name: 'create_support_ticket',
description: 'Create a support ticket after gathering customer and policy context',
parameters: z.object({
customerId: z.string(),
subject: z.string().min(3),
priority: z.enum(['low', 'medium', 'high']),
summary: z.string().min(10),
}),
execute: async (input) => {
const ticket = createTicket(input);
return { created: true, ticket };
},
});
const escalateToHuman = tool({
name: 'escalate_to_human',
description: 'Escalate when policy requires manual review',
parameters: z.object({
customerId: z.string(),
reason: z.string().min(10),
urgency: z.enum(['normal', 'high']),
}),
execute: async (input) => ({
escalated: true,
queue: input.urgency === 'high' ? 'billing-urgent' : 'billing-standard',
...input,
}),
});

Return structured objects from execute. The SDK serializes them as tool results for the next turn. Return explicit errors (for example { found: false, error: '...' }) so the model can recover instead of throwing.

Running an agent

Define an Agent with instructions and tools, then call run():

import { Agent, run } from '@openai/agents';
const agent = new Agent({
name: 'Support Triage',
model: 'gpt-5.5',
instructions: `You are a billing support triage agent.
- Look up customer and invoice before recommending refunds.
- Search the knowledge base for policy guidance.
- Create a ticket when you can resolve within policy.
- Call escalate_to_human when manual review is required.`,
tools: [
getCustomer,
getInvoice,
searchKnowledgeBase,
createSupportTicket,
escalateToHuman,
],
});
const result = await run(
agent,
'Customer cus_1042 says they were charged twice for invoice inv_8891. What should we do?',
{ maxTurns: 8 },
);
console.log(result.finalOutput);

Use a model that supports tool calling.

maxTurns - cap the number of turns

maxTurns(n) stops once the run reaches n turns. Use it on every production agent to prevent runaway loops and unbounded API cost. When the cap is exceeded, the SDK throws MaxTurnsExceededError.

Use caseSuggested cap
Single tool, then answer2
Chat with occasional tool use3–5
Task agents (triage, research)8–15
Long autonomous workflows15–20 (with monitoring)

Tight vs relaxed cap on the same prompt:

import { Agent, run } from '@openai/agents';
// Stops after 3 turns even if the model still wants more context
const tight = await run(agent, prompt, { maxTurns: 3 });
// Allows a fuller investigation chain
const relaxed = await run(agent, prompt, { maxTurns: 8 });

Inspecting runs

The newItems array on the result contains tool calls, tool outputs, and messages from the run. Use it for debugging:

const result = await run(agent, prompt, { maxTurns: 8 });
for (const item of result.newItems) {
if (item.type === 'tool_call_item') {
console.log('tool:', item.rawItem.name, item.rawItem.arguments);
}
if (item.type === 'tool_call_output_item') {
console.log('output:', item.output);
}
}
console.log('lastAgent:', result.lastAgent.name);
console.log('answer:', result.finalOutput);

The SDK emits traces automatically. Set workflowName on a custom Runner to group related runs in the OpenAI Traces dashboard.

Handoffs

For multi-agent workflows, define specialist agents and wire them with Agent.create() and handoffs. The triage agent in this post stays single-agent, but handoffs are the SDK's way to delegate between agents (similar to routing a case to a billing specialist):

import { Agent } from '@openai/agents';
const billingAgent = new Agent({
name: 'Billing Specialist',
instructions: 'Handle refund and duplicate-charge cases.',
tools: [getInvoice, searchKnowledgeBase, createSupportTicket],
});
const triageAgent = Agent.create({
name: 'Triage',
instructions: 'Route billing cases to the billing specialist when needed.',
handoffs: [billingAgent],
});

After a run, check result.lastAgent to see which agent produced the final output.

Streaming

Pass stream: true to receive events as the run progresses:

import { Agent, run } from '@openai/agents';
const stream = await run(agent, prompt, { maxTurns: 8, stream: true });
process.stdout.write('Answer: ');
for await (const event of stream) {
if (event.type === 'raw_model_stream_event' && event.data.type === 'output_text_delta') {
process.stdout.write(event.data.delta);
}
if (event.type === 'run_item_stream_event' && event.name === 'tool_called') {
console.error(`\nTool: ${event.item.rawItem.name}`);
}
}
await stream.completed;
console.log('\nDone:', stream.finalOutput);

Text streams incrementally. Tool calls appear as run_item_stream_event events between text segments.

Production notes

  • Always set maxTurns - do not rely on the default cap without monitoring
  • Cost - each turn is another model call; inspect newItems or stream events for tool usage
  • Tool errors - return structured errors from execute instead of throwing when the model should retry or escalate
  • Instructions - keep policy rules in instructions, not only in the user prompt
  • Tracing - use the OpenAI Traces dashboard to debug multi-turn runs
  • Alternatives - hosted tools (web search, code interpreter), MCP servers, and sandbox agents are covered in the official docs

Demo

Runnable scripts for each section live in the openai-agents-sdk-demo folder. Get access via code demos.

AI image generation with OpenAI API

June 10, 2026

OpenAI exposes image generation through the Image API (POST /images/generations). The official openai npm package wraps it as client.images.generate. This post walks through the main request parameters and how to save generated images from Node.js.

The examples use gpt-image-2, OpenAI's latest GPT Image model. GPT Image models always return base64-encoded image data in data[].b64_json. Use output_format for the on-disk file type and put artistic direction in the prompt.

For text generation with the same package, see the Chat Completions API and Responses API posts. Image generation is also available through Responses API tools, but this post focuses on the dedicated Image API endpoint.

The running scenario: generate marketing hero images for a fictional todo app.

Prerequisites

  • OpenAI account
  • Generated API key
  • Enabled billing
  • Node.js version 26
  • openai package installed (npm i openai)

Client setup

Create a client with your API key (read from the environment in production).

import OpenAI from 'openai';
const client = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });

The same SDK can target other hosts that implement a compatible API by setting baseURL and apiKey:

const client = new OpenAI({
apiKey: process.env.LLM_API_KEY,
baseURL: 'https://your-gateway.example/v1',
});

Azure OpenAI uses AzureOpenAI instead. Confirm your provider supports the Image API and the model you pass to model.

Basic integration

Call client.images.generate with model and prompt. The examples use gpt-image-2, older snapshots include gpt-image-1.5, gpt-image-1, and gpt-image-1-mini. Pin a snapshot (for example gpt-image-2-2026-04-21) when you need stable behavior across deploys.

The prompt describes what to generate. GPT Image models accept up to about 32,000 characters. Be specific about subject, layout, colors, and style.

GPT Image models always return base64 in data[].b64_json. Decode it and write the file yourself.

import { writeFile } from 'node:fs/promises';
const prompt = `
Minimal flat illustration for a productivity app landing page.
Show a todo dashboard with a checklist, calendar widget, and soft pastel palette.
No text labels on screen elements.
`.trim();
const result = await client.images.generate({
model: 'gpt-image-2',
prompt,
});
await writeFile('hero.png', Buffer.from(result.data[0].b64_json, 'base64'));

n

Use n to generate multiple images in one request (default 1, maximum 10). Loop over result.data to save each image.

import { writeFile } from 'node:fs/promises';
const result = await client.images.generate({
model: 'gpt-image-2',
prompt:
'Minimal flat illustration of a todo app dashboard, variant layout, soft pastel colors',
n: 2,
});
for (const [index, item] of result.data.entries()) {
await writeFile(
`hero-${index}.png`,
Buffer.from(item.b64_json, 'base64'),
);
}

Size

Control dimensions with size. Common presets are 1024x1024 (square), 1536x1024 (landscape), and 1024x1536 (portrait). auto lets the model pick based on the prompt.

gpt-image-2 also accepts custom WIDTHxHEIGHT strings when width and height are multiples of 16, the aspect ratio is between 1:3 and 3:1, and total pixels stay within the documented limits.

const result = await client.images.generate({
model: 'gpt-image-2',
prompt:
'Minimal flat illustration of a todo app dashboard, portrait orientation, soft pastel colors',
size: '1024x1536',
});

Quality

Set rendering quality with quality. Use low for fast drafts and iterations, then medium or high for final assets. Default is auto.

const draft = await client.images.generate({
model: 'gpt-image-2',
prompt:
'Minimal flat illustration of a todo app dashboard, soft pastel colors',
quality: 'low',
});
const final = await client.images.generate({
model: 'gpt-image-2',
prompt:
'Minimal flat illustration of a todo app dashboard, soft pastel colors, polished details',
quality: 'high',
size: '1024x1536',
});

Output format

GPT Image models return base64 in the JSON response. Use output_format to control the encoded file type: png (default), jpeg, or webp.

import { writeFile } from 'node:fs/promises';
const result = await client.images.generate({
model: 'gpt-image-2',
prompt:
'Minimal flat illustration of a todo app dashboard, soft pastel colors',
output_format: 'jpeg',
});
await writeFile('hero.jpg', Buffer.from(result.data[0].b64_json, 'base64'));

Compression

When output_format is jpeg or webp, set output_compression from 0 to 100 to trade file size for quality. JPEG is often faster than PNG when latency matters.

const result = await client.images.generate({
model: 'gpt-image-2',
prompt:
'Minimal flat illustration of a todo app dashboard, soft pastel colors',
output_format: 'webp',
output_compression: 50,
});

Background

Use background: 'transparent' with png or webp on models that support it when you need a cutout asset. gpt-image-2 does not support transparent backgrounds; use gpt-image-1.5 or an earlier GPT Image model for that workflow, or bake the background into the prompt.

const result = await client.images.generate({
model: 'gpt-image-1.5',
prompt: 'Flat icon of a checkmark, no background, centered',
output_format: 'png',
background: 'transparent',
});

Production notes

  • Cost scales with quality and size. See OpenAI pricing before generating at scale.
  • Moderation - use moderation: 'auto' (default) or 'low' on GPT Image models when you need less restrictive filtering.
  • Errors - handle image_generation_user_error (for example moderation_blocked) by changing the prompt or inputs; do not blindly retry.
  • Latency - complex prompts can take up to about two minutes.
  • Storage - decode and persist files yourself. GPT Image responses are base64 in JSON.

Demo

Runnable scripts for each section live in the openai-image-generation-demo folder. Get access via code demos.

Building AI agents with Vercel AI SDK

June 9, 2026

The Vercel AI SDK treats agents as tool-calling loops: the model generates text or invokes tools, the SDK runs those tools, and the loop continues until the model answers or a stop condition fires.

This post builds a support triage agent that looks up customers and invoices, searches an internal knowledge base, and either opens a ticket or escalates to a human. It builds on the LLM integration with Vercel AI SDK post and focuses on multiple tools, stopWhen, and stepCountIs.

For external tools exposed over MCP instead of SDK-native tool() handlers, see the MCP server with Node.js post. For the same triage scenario with the official OpenAI Agents SDK (@openai/agents, run(), maxTurns), see the dedicated post. For the LangChain stack (createAgent, tool(), LangGraph loop), see Building AI agents with LangChain.

Prerequisites

  • OpenAI account
  • Generated API key
  • Enabled billing
  • Node.js version 26
  • ai, @ai-sdk/openai, and zod installed (npm i ai @ai-sdk/openai zod)
  • Client setup from the Vercel AI SDK integration post

Mental model - steps and the tool loop

A step is one model generation. In that step the model either:

  • returns text (the loop ends), or
  • returns tool calls (the SDK executes them and starts another step with the results)

Typical flow for the support triage agent: user question → model calls lookup tools (getCustomer, getInvoice, searchKnowledgeBase) → model creates a ticket or escalates → final answer. stopWhen can end the loop before or after the write tools run.

stepCountIs(5) means "stop after 5 steps" (five model generations), not five individual tool calls. A single step can include multiple parallel tool calls.

When you pass tools without stopWhen, the SDK defaults to stepCountIs(20) as a safety cap.

Support triage scenario

Example prompt:

Customer cus_1042 says they were charged twice for invoice inv_8891. What should we do?

A realistic chain:

  1. getCustomer - plan tier, open ticket count
  2. getInvoice - amount, status, payment IDs
  3. searchKnowledgeBase - duplicate-charge and refund policy
  4. createSupportTicket or escalateToHuman - write action or sentinel stop

The demo uses in-memory fixtures (customers, invoices, knowledge-base articles) so scripts run without a database.

Defining multiple tools

Register tools with tool() and Zod inputSchema. Clear description values help the model pick the right tool.

import { tool } from 'ai';
import { z } from 'zod';
const getCustomer = tool({
description: 'Look up a customer account by ID',
inputSchema: z.object({
customerId: z.string().describe('Customer ID, e.g. cus_1042'),
}),
execute: async ({ customerId }) => {
const customer = customers.find((item) => item.id === customerId);
if (!customer) {
return { found: false, customerId, error: 'Customer not found' };
}
return { found: true, customer };
},
});
const getInvoice = tool({
description: 'Look up an invoice by ID, including payment IDs and status',
inputSchema: z.object({
invoiceId: z.string().describe('Invoice ID, e.g. inv_8891'),
}),
execute: async ({ invoiceId }) => {
const invoice = invoices.find((item) => item.id === invoiceId);
if (!invoice) {
return { found: false, invoiceId, error: 'Invoice not found' };
}
return { found: true, invoice };
},
});
const searchKnowledgeBase = tool({
description: 'Search internal support articles by keyword',
inputSchema: z.object({
query: z.string().describe('Search terms, e.g. duplicate charge refund'),
}),
execute: async ({ query }) => {
// keyword match against mocked articles
return { query, articles: matches };
},
});

Add write tools for outcomes:

const createSupportTicket = tool({
description: 'Create a support ticket after gathering customer and policy context',
inputSchema: z.object({
customerId: z.string(),
subject: z.string().min(3),
priority: z.enum(['low', 'medium', 'high']),
summary: z.string().min(10),
}),
execute: async (input) => {
const ticket = createTicket(input);
return { created: true, ticket };
},
});
const escalateToHuman = tool({
description: 'Escalate when policy requires manual review',
inputSchema: z.object({
customerId: z.string(),
reason: z.string().min(10),
urgency: z.enum(['normal', 'high']),
}),
execute: async (input) => ({
escalated: true,
queue: input.urgency === 'high' ? 'billing-urgent' : 'billing-standard',
...input,
}),
});

Return structured objects from execute. The SDK serializes them as tool results for the next step. Return explicit errors (for example { found: false, error: '...' }) so the model can recover instead of throwing.

Multi-step triage with generateText

Pass all tools and a system prompt with triage rules:

import { generateText, stepCountIs } from 'ai';
const { text, steps } = await generateText({
model: openai('gpt-5.5'),
system: `You are a billing support triage agent.
- Look up customer and invoice before recommending refunds.
- Search the knowledge base for policy guidance.
- Create a ticket when you can resolve within policy.
- Call escalateToHuman when manual review is required.`,
tools: {
getCustomer,
getInvoice,
searchKnowledgeBase,
createSupportTicket,
escalateToHuman,
},
stopWhen: stepCountIs(8),
prompt:
'Customer cus_1042 says they were charged twice for invoice inv_8891. What should we do?',
});
console.log('steps:', steps.length);
console.log(text);

Use a model that supports tool calling (same requirement as web search in the Vercel AI SDK post).

stopWhen - when the loop stops

stopWhen defines stopping conditions for the tool loop. Conditions are evaluated only when the last step contains tool results.

  • A single condition stops when that condition returns true
  • An array stops when any condition returns true (OR logic)
  • Without stopWhen, the SDK applies stepCountIs(20)

The loop also ends naturally when the model returns text without further tool calls.

stepCountIs - cap the number of steps

stepCountIs(n) stops once steps.length reaches n. Use it on every production agent to prevent runaway loops and unbounded API cost.

Use caseSuggested cap
Single tool, then answer2 (tool step + text step)
Chat with occasional tool use3-5
Task agents (triage, research)8-15
Long autonomous workflows15-20 (with monitoring)

Tight vs relaxed cap on the same prompt:

import { generateText, stepCountIs } from 'ai';
// Stops after 3 steps even if the model still wants more context
const capped = await generateText({
model: openai('gpt-5.5'),
tools: supportTools,
stopWhen: stepCountIs(3),
prompt: '...',
});
// Allows a fuller investigation chain
const relaxed = await generateText({
model: openai('gpt-5.5'),
tools: supportTools,
stopWhen: stepCountIs(8),
prompt: '...',
});

Combining hasToolCall with stepCountIs

hasToolCall('toolName') stops when the model invokes a specific tool in the latest step. Pair it with stepCountIs for a hard cap plus a sentinel tool:

import { generateText, stepCountIs, hasToolCall } from 'ai';
const { text, steps } = await generateText({
model: openai('gpt-5.5'),
system: TRIAGE_INSTRUCTIONS,
tools: supportTools,
stopWhen: [stepCountIs(10), hasToolCall('escalateToHuman')],
prompt:
'Customer cus_2201 on the starter plan reports a duplicate $190 charge on invoice inv_9104.',
});

escalateToHuman works well as a sentinel: the loop stops as soon as the model decides the case needs a human, without waiting for a final text-only step.

Inspecting steps and usage

The steps array on the result contains per-step tool calls, tool results, finish reason, and usage. Use it for debugging and cost tracking:

const { text, steps, totalUsage } = await generateText({
model: openai('gpt-5.5'),
tools: supportTools,
stopWhen: stepCountIs(8),
prompt: '...',
});
for (const [index, step] of steps.entries()) {
console.log(`step ${index + 1}`);
console.log(' toolCalls:', step.toolCalls?.map((c) => c.toolName));
console.log(' usage:', step.usage);
}
console.log('totalUsage:', totalUsage);

With streamText, pass onStepFinish to log each step as it completes.

ToolLoopAgent - reusable agent definition

ToolLoopAgent wraps the same loop for reuse across scripts and API routes. It accepts the same settings as generateText (tools, stopWhen, instructions).

import { ToolLoopAgent, stepCountIs } from 'ai';
const supportTriageAgent = new ToolLoopAgent({
model: openai('gpt-5.5'),
instructions: TRIAGE_INSTRUCTIONS,
tools: supportTools,
stopWhen: stepCountIs(8),
});
const result = await supportTriageAgent.generate({
prompt:
'Customer cus_1042 says they were charged twice for invoice inv_8891. What should we do?',
onStepFinish: async ({ stepNumber, usage, toolCalls }) => {
console.log(`step ${stepNumber + 1}`, {
tokens: usage.totalTokens,
tools: toolCalls?.map((call) => call.toolName),
});
},
});
console.log(result.text);

Use .stream() for streaming. For Next.js chat UIs, see createAgentUIStreamResponse in the AI SDK agents docs.

Streaming with tools

streamText supports the same tools and stopWhen settings:

import { streamText, stepCountIs } from 'ai';
const result = streamText({
model: openai('gpt-5.5'),
system: TRIAGE_INSTRUCTIONS,
tools: supportTools,
stopWhen: stepCountIs(8),
prompt: 'Customer cus_1042 says they were charged twice for invoice inv_8891.',
onStepFinish: async ({ stepNumber, toolCalls }) => {
console.error(`step ${stepNumber + 1}:`, toolCalls?.map((c) => c.toolName));
},
});
for await (const part of result.textStream) {
process.stdout.write(part);
}

Text streams incrementally. Tool calls run between text segments as the loop progresses.

Production notes

  • Always set stopWhen - do not rely on the default stepCountIs(20) in production without monitoring
  • Cost - each step is another model call; log steps or onStepFinish usage
  • Tool errors - return structured errors from execute instead of throwing when the model should retry or escalate
  • Instructions - keep policy rules in system / instructions, not only in the user prompt
  • Same patterns elsewhere - PR review (listPRsgetCheckssubmitReview) or job-fit scoring use the same loop mechanics with different tools

Demo

Runnable scripts for each section live in the vercel-ai-sdk-agents-demo folder. Get access via code demos.

LLM integration with OpenRouter

June 8, 2026

OpenRouter is a unified API gateway to hundreds of language models from providers such as OpenAI, Anthropic, Google, and Meta. You use one API key and one billing surface, and swap models by changing a provider/model slug. OpenRouter exposes a Chat Completions-compatible HTTP API.

This post shows three Node.js integration paths: the official @openrouter/sdk, the openai package with baseURL, and the Vercel AI SDK with @openrouter/ai-sdk-provider.

For deeper patterns on each stack, see the Chat Completions API, OpenAI Responses API (OpenAI direct only), and Vercel AI SDK posts.

Prerequisites

  • OpenRouter account
  • API key
  • Credits or billing enabled as needed
  • Node.js version 26
  • Install packages for the path you use:
    • @openrouter/sdk (npm i @openrouter/sdk)
    • openai (npm i openai)
    • ai and @openrouter/ai-sdk-provider (npm i ai @openrouter/ai-sdk-provider)

Configuration

Read credentials from the environment in production.

VariablePurpose
OPENROUTER_API_KEYBearer token from OpenRouter settings
OPENROUTER_MODELDefault model slug, for example openai/gpt-5.5
OPENROUTER_SITE_URLOptional site URL sent as HTTP-Referer for rankings on openrouter.ai
OPENROUTER_SITE_TITLEOptional app name sent as X-OpenRouter-Title

Model IDs use the provider/model format, for example openai/gpt-5.5, anthropic/claude-opus-4.8, or google/gemini-3.1-flash-lite. Browse the full catalog at openrouter.ai/models.

The examples below use openai/gpt-5.5, matching the model in the other LLM posts in this series. Override it with OPENROUTER_MODEL when you want a different model.

@openrouter/sdk

OpenRouter's official TypeScript SDK is type-safe and generated from the OpenAPI spec.

Client setup

import { OpenRouter } from '@openrouter/sdk';
const client = new OpenRouter({
apiKey: process.env.OPENROUTER_API_KEY,
httpReferer: process.env.OPENROUTER_SITE_URL,
appTitle: process.env.OPENROUTER_SITE_TITLE,
});

Basic integration

const response = await client.chat.send({
chatRequest: {
model: process.env.OPENROUTER_MODEL ?? 'openai/gpt-5.5',
messages: [
{ role: 'user', content: 'Write a one-sentence bedtime story about a unicorn.' },
],
},
});
console.log(response.choices[0].message.content);

System prompt

Add a system message before the user turn to set tone, format, and role.

const response = await client.chat.send({
chatRequest: {
model: process.env.OPENROUTER_MODEL ?? 'openai/gpt-5.5',
messages: [
{ role: 'system', content: 'Reply in one short sentence. Use plain language.' },
{ role: 'user', content: 'Explain what an LLM is.' },
],
},
});
console.log(response.choices[0].message.content);

Streaming

Set stream: true and read incremental text from choices[0].delta.content.

const stream = await client.chat.send({
chatRequest: {
model: process.env.OPENROUTER_MODEL ?? 'openai/gpt-5.5',
messages: [{ role: 'user', content: 'List three colors.' }],
stream: true,
},
});
process.stdout.write('[stream] ');
for await (const chunk of stream) {
const delta = chunk.choices[0]?.delta?.content;
if (delta) {
process.stdout.write(delta);
}
}
process.stdout.write('\n');

Model switching

Change only the model string to route the same code to a different provider.

const models = ['openai/gpt-5.5', 'google/gemini-3.1-flash-lite'];
for (const model of models) {
const response = await client.chat.send({
chatRequest: {
model,
messages: [{ role: 'user', content: 'Reply with exactly one word: ok.' }],
},
});
console.log(model, '->', response.choices[0].message.content);
}

openai package

If you already use the OpenAI SDK, point it at OpenRouter with baseURL. The request shape matches the Chat Completions API.

Client setup

import OpenAI from 'openai';
const client = new OpenAI({
apiKey: process.env.OPENROUTER_API_KEY,
baseURL: 'https://openrouter.ai/api/v1',
defaultHeaders: {
'HTTP-Referer': process.env.OPENROUTER_SITE_URL,
'X-OpenRouter-Title': process.env.OPENROUTER_SITE_TITLE,
},
});

Basic integration

const completion = await client.chat.completions.create({
model: process.env.OPENROUTER_MODEL ?? 'openai/gpt-5.5',
messages: [
{ role: 'user', content: 'Write a one-sentence bedtime story about a unicorn.' },
],
});
console.log(completion.choices[0].message.content);

System prompt

const completion = await client.chat.completions.create({
model: process.env.OPENROUTER_MODEL ?? 'openai/gpt-5.5',
messages: [
{ role: 'system', content: 'Reply in one short sentence. Use plain language.' },
{ role: 'user', content: 'Explain what an LLM is.' },
],
});
console.log(completion.choices[0].message.content);

Streaming

const stream = await client.chat.completions.create({
model: process.env.OPENROUTER_MODEL ?? 'openai/gpt-5.5',
messages: [{ role: 'user', content: 'List three colors.' }],
stream: true,
});
process.stdout.write('[stream] ');
for await (const chunk of stream) {
const delta = chunk.choices[0]?.delta?.content;
if (delta) {
process.stdout.write(delta);
}
}
process.stdout.write('\n');

For JSON schema output, Markdown-to-HTML, and few-shot prompting, reuse the patterns from the Chat Completions post with the OpenRouter client and model slug above.

Vercel AI SDK

The @openrouter/ai-sdk-provider package exposes OpenRouter models to generateText, streamText, and related helpers from the ai package. See the OpenRouter Vercel AI SDK guide for the full integration reference.

Client setup

import { createOpenRouter } from '@openrouter/ai-sdk-provider';
const openrouter = createOpenRouter({
apiKey: process.env.OPENROUTER_API_KEY,
appUrl: process.env.OPENROUTER_SITE_URL,
appName: process.env.OPENROUTER_SITE_TITLE,
});

The returned provider is callable. Pass a model slug directly: openrouter('openai/gpt-5.5').

Basic integration

import { generateText } from 'ai';
const { text } = await generateText({
model: openrouter(process.env.OPENROUTER_MODEL ?? 'openai/gpt-5.5'),
prompt: 'Write a one-sentence bedtime story about a unicorn.',
});
console.log(text);

System prompt

const { text } = await generateText({
model: openrouter(process.env.OPENROUTER_MODEL ?? 'openai/gpt-5.5'),
system: 'Reply in one short sentence. Use plain language.',
prompt: 'Explain what an LLM is.',
});
console.log(text);

Streaming

import { streamText } from 'ai';
const result = streamText({
model: openrouter(process.env.OPENROUTER_MODEL ?? 'openai/gpt-5.5'),
prompt: 'List three colors.',
});
process.stdout.write('[stream] ');
for await (const part of result.textStream) {
process.stdout.write(part);
}
process.stdout.write('\n');

For structured output, embeddings, and web search, see the Vercel AI SDK post. Those patterns apply when you call OpenAI directly; OpenRouter coverage depends on the model and endpoint.

Demo

Runnable scripts for each integration path live in the openrouter-demo folder. Get access via code demos.

LLM integration with Vercel AI SDK

June 7, 2026

Large language models (LLMs) understand and generate text from prompts. The Vercel AI SDK is a provider-agnostic layer over LLM APIs - core functions are generateText, streamText, and embed. This post uses the OpenAI provider and mirrors the patterns from the OpenAI Responses API post.

For the lower-level openai npm package, see the Chat Completions API and Responses API posts. For multi-tool agents with stopWhen and stepCountIs, see the Building AI agents with Vercel AI SDK post. For the same triage scenario with the OpenAI Agents SDK, see the dedicated post.

Prerequisites

  • OpenAI account
  • Generated API key
  • Enabled billing
  • Node.js version 26
  • ai, @ai-sdk/openai, and zod installed (npm i ai @ai-sdk/openai zod)
  • For Markdown output: marked, dompurify, and jsdom (npm i marked dompurify jsdom)

Client setup

Create an OpenAI provider with your API key (read from the environment in production).

import { createOpenAI } from '@ai-sdk/openai';
const openai = createOpenAI({ apiKey: process.env.OPENAI_API_KEY });

For OpenRouter, use the dedicated @openrouter/ai-sdk-provider package - see the OpenRouter integration post. The same provider can target other hosts that implement a compatible API by setting baseURL and apiKey:

const openai = createOpenAI({
apiKey: process.env.LLM_API_KEY,
baseURL: 'https://your-gateway.example/v1',
});

Many third-party gateways support Chat Completions only. The examples below use openai(model) (Responses API path). If your provider does not support it, switch to openai.chat(model) and skip the web search example.

Basic integration

Pass a string as prompt and read text from the result.

import { generateText } from 'ai';
import { createOpenAI } from '@ai-sdk/openai';
const openai = createOpenAI({ apiKey: process.env.OPENAI_API_KEY });
const { text } = await generateText({
model: openai('gpt-5.5'),
prompt: 'Write a one-sentence bedtime story about a unicorn.',
});
console.log(text);

System prompt

Use the system parameter for stable behavior (tone, format, role). It takes precedence over casual wording in the user message.

const { text } = await generateText({
model: openai('gpt-5.5'),
system: 'Reply in one short sentence. Use plain language.',
prompt: 'Explain what an LLM is.',
});
console.log(text);

Few-shot prompting

Pass prior turns in a messages array with user and assistant roles, then the new user message. Keep task rules in system.

const { text } = await generateText({
model: openai('gpt-5.5'),
system:
'Classify sentiment as exactly one word: positive, negative, or neutral.',
messages: [
{ role: 'user', content: 'I love this!' },
{ role: 'assistant', content: 'positive' },
{ role: 'user', content: 'This is awful.' },
{ role: 'assistant', content: 'negative' },
{ role: 'user', content: 'It is fine I guess.' },
],
});
console.log(text);

Streaming

Use streamText and iterate over textStream for incremental text.

import { streamText } from 'ai';
const result = streamText({
model: openai('gpt-5.5'),
prompt: 'List three colors.',
});
for await (const part of result.textStream) {
process.stdout.write(part);
}

Structured output with JSON schema

Constrain the model to JSON matching your schema via Output.object() and a Zod schema. The SDK validates the result.

import { generateText, Output } from 'ai';
import { z } from 'zod';
const { output } = await generateText({
model: openai('gpt-5.5'),
prompt: 'The film Inception was directed by Christopher Nolan.',
output: Output.object({
schema: z.object({
title: z.string(),
director: z.string(),
}),
schemaName: 'movie_summary',
}),
});
console.log(output.title, output.director);

Markdown output to HTML

Ask for Markdown in system, then convert text to HTML and sanitize before rendering (for example with innerHTML in the browser or when storing HTML).

import { marked } from 'marked';
import { JSDOM } from 'jsdom';
import DOMPurify from 'dompurify';
const purify = DOMPurify(new JSDOM('').window);
const { text } = await generateText({
model: openai('gpt-5.5'),
system: 'Reply in Markdown only. Use a heading and a short bullet list.',
prompt: 'Explain what an LLM is in three bullet points.',
});
const markdown = text;
const html = marked.parse(markdown);
const safeHtml = purify.sanitize(html);

Always run DOMPurify.sanitize on model-generated HTML. The model can emit unsafe markup. Sanitization strips scripts and other dangerous content.

Web search tool

Enable the built-in web search tool when the answer should use current information from the web.

const result = await generateText({
model: openai('gpt-5.5'),
tools: { web_search: openai.tools.webSearch() },
prompt: 'What was a major tech headline this week? Cite sources briefly.',
});
console.log(result.text);

Web search adds latency and tool usage cost. Use a model that supports tools.

Embeddings

Embeddings are numeric vectors that represent the semantic meaning of text. Use them for semantic search, clustering, and RAG.

Pass a single string to embed and read the vector from embedding.

import { embed } from 'ai';
const { embedding } = await embed({
model: openai.embedding('text-embedding-3-small'),
value: 'How do I connect pgvector to PostgreSQL?',
});
console.log(embedding.length);

Pass multiple strings in a values array with embedMany. Results are in the same order as the input.

import { embedMany } from 'ai';
const chunks = [
'pgvector adds vector similarity search to PostgreSQL.',
'LangChain helps split long documents into retrieval-friendly chunks.',
'RAG retrieves context first, then asks an LLM to answer.',
];
const { embeddings } = await embedMany({
model: openai.embedding('text-embedding-3-small'),
values: chunks,
});
console.log(embeddings.length); // 3

For a full RAG flow with pgvector, see the RAG with OpenAI embeddings post. For LangChain basics (LCEL, Runnables, when to use LangChain), see the LangChain overview post.

Demo

Runnable scripts for each section live in the vercel-ai-sdk-demo folder. Get access via code demos.

Building an MCP server with Node.js

June 6, 2026

The Model Context Protocol (MCP) is an open standard for connecting AI hosts (Claude, ChatGPT, Cursor, VS Code, and others) to external context and actions through a structured protocol instead of ad-hoc plugins.

The host runs an MCP client. Your application is the MCP server, exposing tools (actions the model can invoke), resources (read-only data), and optionally prompts (reusable message templates). Communication uses JSON-RPC over a transport such as stdio or HTTP. After the client connects, the server completes an initialization handshake (initializeinitialized). The host discovers capabilities via tools/list, resources/list, and prompts/list, then calls tools or reads resources at runtime.

This post shows how to build a small todo MCP server with Node.js using the official @modelcontextprotocol/sdk package and Zod schemas.

Prerequisites

  • Node.js version 26
  • npm i @modelcontextprotocol/sdk zod
  • Optional for client testing: Claude Desktop and/or ChatGPT (Connectors / Apps)

The stable v1 SDK is @modelcontextprotocol/sdk. A v2 split (@modelcontextprotocol/server, @modelcontextprotocol/client) is in pre-release - this post uses v1, which matches current production tooling.

MCP capabilities - tools, resources, prompts

CapabilityPurposeDemo example
ToolsModel-invoked actions with typed inputsadd_todo, list_todos, mark_todo_done
ResourcesRead-only context the host can fetchtodo://all JSON snapshot
Prompts (optional)Named templates with argumentssummarize-open-todos

Tools are the main integration surface - the model calls them via tools/call. Resources are fetched with resources/read and should stay read-only. Prompts return pre-built messages via prompts/get.

Tool inputs need a schema so clients know parameters. With the TypeScript SDK, pass Zod fields in inputSchema:

import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { z } from 'zod';
const server = new McpServer({ name: 'todo-mcp-server', version: '1.0.0' });
server.registerTool(
'add_todo',
{
description: 'Add a new todo item',
inputSchema: { title: z.string().min(1) },
},
async ({ title }) => ({
content: [{ type: 'text', text: JSON.stringify({ title, done: false }) }],
})
);

Register a resource at a fixed URI:

server.registerResource(
'all-todos',
'todo://all',
{
title: 'All todos',
mimeType: 'application/json',
},
async (uri) => ({
contents: [
{
uri: uri.href,
mimeType: 'application/json',
text: JSON.stringify([{ id: 1, title: 'Learn MCP', done: false }]),
},
],
})
);

Register an optional prompt:

server.registerPrompt(
'summarize-open-todos',
{
title: 'Summarize open todos',
description: 'Ask the model to summarize open todos',
},
() => ({
messages: [
{
role: 'user',
content: {
type: 'text',
text: 'Summarize my open todos and suggest a priority order.',
},
},
],
})
);

Building the server

Use a factory so the same server definition works with multiple transports:

import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { z } from 'zod';
const todos = [
{ id: 1, title: 'Learn MCP basics', done: false },
{ id: 2, title: 'Ship demo server', done: false },
];
let nextId = 3;
export function createMcpServer() {
const server = new McpServer({ name: 'todo-mcp-server', version: '1.0.0' });
server.registerTool(
'add_todo',
{
description: 'Add a new todo item',
inputSchema: { title: z.string().min(1) },
},
async ({ title }) => {
const todo = { id: nextId++, title, done: false };
todos.push(todo);
return { content: [{ type: 'text', text: JSON.stringify(todo, null, 2) }] };
}
);
server.registerTool('list_todos', { description: 'List all todo items' }, async () => ({
content: [{ type: 'text', text: JSON.stringify(todos, null, 2) }],
}));
server.registerTool(
'mark_todo_done',
{
description: 'Mark a todo item as done by id',
inputSchema: { id: z.number().int().positive() },
},
async ({ id }) => {
const todo = todos.find((item) => item.id === id);
if (!todo) {
return { content: [{ type: 'text', text: `Todo ${id} not found` }], isError: true };
}
todo.done = true;
return { content: [{ type: 'text', text: JSON.stringify(todo, null, 2) }] };
}
);
// register resource and prompt here (see snippets above)
return server;
}

Tool handlers return { content: [...] }. Set isError: true when a tool fails so the host can surface the error. Resource handlers return { contents: [...] }. Prompt handlers return { messages: [...] }.

The demo uses an in-memory store so you can run it without API keys or a database.

Transports - stdio vs SSE / Streamable HTTP

MCP separates protocol (JSON-RPC messages) from transport (how bytes move between client and server).

Stdio (StdioServerTransport)

The client spawns your server as a child process. JSON-RPC goes over stdin/stdout.

  • Use when: Claude Desktop, Cursor, VS Code, Claude Code, local CLI agents
  • Pros: simplest setup, no ports or firewall rules, no OAuth
  • Cons: one client per process; cloud hosts cannot spawn your local binary
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { createMcpServer } from './create-server.js';
const server = createMcpServer();
await server.connect(new StdioServerTransport());

Write logs to stderr only - stdout is the protocol channel.

Remote HTTP - SSE (legacy) vs Streamable HTTP (current)

Early MCP remote servers used HTTP + SSE: POST for client→server requests, Server-Sent Events for server→client streaming. That transport is deprecated.

New servers should use Streamable HTTP (StreamableHTTPServerTransport). It supports POST request/response, optional SSE for notifications, and session management. The v2 SDK removes server-side SSE entirely; client-side SSE remains for legacy servers.

ScenarioTransport
Claude Desktop, local devstdio
Cursor / VS Code project MCPstdio
ChatGPT Apps / ConnectorsStreamable HTTP over public HTTPS
Legacy SSE-only clientsSSE client transport still exists; prefer Streamable HTTP for new servers

Streamable HTTP entry (stateless):

import { createMcpExpressApp } from '@modelcontextprotocol/sdk/server/express.js';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import { createMcpServer } from './create-server.js';
const app = createMcpExpressApp();
const PORT = Number(process.env.PORT) || 3000;
app.post('/mcp', async (req, res) => {
const server = createMcpServer();
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
await server.connect(transport);
await transport.handleRequest(req, res, req.body);
res.on('close', () => {
transport.close();
server.close();
});
});
app.listen(PORT, () => {
console.error(`MCP server listening on http://127.0.0.1:${PORT}/mcp`);
});

createMcpExpressApp() enables DNS rebinding protection when binding to localhost - recommended for local HTTP servers.

Deploy Streamable HTTP behind HTTPS before exposing it to cloud clients. ChatGPT Connectors also require OAuth 2.1 for production use.

Connecting MCP clients

Claude Desktop (stdio - primary demo path)

Config file on Windows: %APPDATA%\Claude\claude_desktop_config.json. Open it via Settings → Developer → Edit Config.

{
"mcpServers": {
"todo-mcp": {
"command": "node",
"args": ["C:/path/to/demos/ai/mcp-server-nodejs-demo/src/stdio.js"]
}
}
}

Restart Claude Desktop after saving. Claude shows a tool approval UI before executing write operations.

Claude Desktop (remote HTTP)

claude_desktop_config.json validates stdio servers only - do not put a bare url field there expecting it to work. For a public HTTPS MCP server, use Settings → Connectors → Add custom connector. For local HTTP during development, bridge with mcp-remote as a stdio-launched proxy.

ChatGPT (Connectors / Apps)

ChatGPT has no local MCP config file. Register servers in Settings → Apps (or Connectors) with a name, description, and MCP server URL.

Requirements:

  • Public HTTPS endpoint (Streamable HTTP)
  • OAuth 2.1 for production connectors
  • Stdio-only servers need an HTTP wrapper or tunnel before ChatGPT can reach them

ChatGPT shows confirmation modals before write/modify tool calls.

Cursor

Cursor uses .cursor/mcp.json with the same stdio shape as Claude Desktop (command + args).

What else matters

  • Security - validate tool inputs, scope tools narrowly, and never expose secrets in resources.
  • Logging - stderr only for stdio servers; avoid console.log on stdout.
  • Testing - use @modelcontextprotocol/sdk client helpers with StdioClientTransport for smoke tests.
  • Spec evolution - prefer Streamable HTTP over legacy SSE; watch the v2 SDK split when upgrading.

Demo

Runnable scripts for this post live in the mcp-server-nodejs-demo folder in the private demos repository. Get access via code demos.

Supabase basics with Node.js

June 3, 2026

Supabase is an open-source backend platform built around managed PostgreSQL. You get a database, auto-generated REST APIs (via PostgREST), Auth, file Storage, Realtime subscriptions, and Edge Functions - with a dashboard and SQL editor on top.

Compared to running Postgres yourself, Supabase adds hosted infra, API layers, and product features without you wiring them up. Compared to an ORM-only stack, you often talk to Postgres through the Supabase client or SQL, with RLS (row level security) enforcing access at the database layer.

Prerequisites

  • Supabase account (free tier is enough for learning)
  • Node.js version 26
  • @supabase/supabase-js installed (npm i @supabase/supabase-js)

Create a project and database

  1. In the Supabase dashboard, choose New project, pick a region, and set the database password.
  2. Wait until the project is ready.
  3. Open SQL Editor and run the schema below.

The Table Editor is fine for quick experiments; SQL Editor keeps schema reproducible in git and reviews.

create table if not exists public.todos (
id bigint generated always as identity primary key,
title text not null,
done boolean not null default false,
created_at timestamptz not null default now()
);
create or replace function public.list_open_todos()
returns setof public.todos
language sql
security definer
set search_path = public
as $$
select * from public.todos where done = false order by id;
$$;
create or replace function public.mark_todo_done(todo_id bigint)
returns setof public.todos
language sql
security definer
set search_path = public
as $$
update public.todos set done = true where id = todo_id returning *;
$$;
grant execute on function public.list_open_todos() to service_role;
grant execute on function public.mark_todo_done(bigint) to service_role;

This schema skips RLS setup because this post uses the secret key from Node.js, which bypasses RLS. For browser or mobile clients, use the publishable key instead - it obeys Row Level Security when you enable it on tables.

list_open_todos is a Postgres function exposed as an RPC endpoint.

Postgres functions default to security invoker: they run with the caller's permissions, so RLS applies as that role. security definer runs the function as its owner (often a privileged database role), not as the caller.

API keys and connection

On the project Overview tab you will find:

  • Project URL (https://<ref>.supabase.co) - for SUPABASE_URL
  • Publishable key (sb_publishable_..., legacy anon) - for browsers and mobile apps where RLS should limit access; this post does not use it

For Node.js backends, use a secret key from Project SettingsAPI Keys:

  • Secret key(s) (sb_secret_...) - full data access, bypasses RLS; trusted servers only, never in client bundles or public repos. Replaces the legacy service_role key.
  • Reveal or create a secret key in the dashboard (legacy projects: Legacy anon, service_role API keys tab → service_role).

The Supabase JS client talks to PostgREST and can query tables (from) and call registered functions (rpc) only after they exist. It does not run DDL (create table, create function, grant, and similar), so apply the schema in the SQL Editor (or via Supabase CLI migrations in larger projects).

Store values in environment variables:

SUPABASE_URL=https://<ref>.supabase.co
SUPABASE_SECRET_KEY=your-secret-key

Client setup

Create a client with the project URL and secret key. Read values from the environment in real apps; never commit the secret key.

import { createClient } from '@supabase/supabase-js';
const supabase = createClient(
process.env.SUPABASE_URL,
process.env.SUPABASE_SECRET_KEY
);

Every table you create in the public schema is available as supabase.from('<table>'). Custom SQL functions are called with supabase.rpc('<function_name>').

Insert data

Insert one or more rows and return the created records with .select().

const { data, error } = await supabase
.from('todos')
.insert([
{ title: 'Learn Supabase client', done: false },
{ title: 'Run RPC example', done: false },
{ title: 'Ship demo', done: true },
])
.select();
if (error) {
throw new Error(error.message);
}
console.log(data);

Read, update, and delete

Select with filters and ordering:

const { data, error } = await supabase
.from('todos')
.select('*')
.eq('done', false)
.order('id');

Update matching rows:

const { data, error } = await supabase
.from('todos')
.update({ done: true })
.eq('id', 1)
.select();

Delete matching rows:

const { error } = await supabase
.from('todos')
.delete()
.eq('id', 1);

Chain .eq(), .in(), .limit(), and other filter helpers the same way across operations.

RPC

Call a database function by name. Our list_open_todos() returns open todos without repeating filter logic in the app.

const { data, error } = await supabase.rpc('list_open_todos');
if (error) {
throw new Error(error.message);
}
console.log(data);

Functions with parameters map to a second argument:

await supabase.rpc('mark_todo_done', { todo_id: 42 });

Define parameters in SQL (todo_id bigint) and grant execute to service_role when calling RPC from a secret-key backend.

What else matters

  • Row Level Security (RLS) - When enabled on a table, Postgres denies access until policies allow it. The publishable key is subject to RLS; the secret key is not. Add policies per role (anon, authenticated) and operation when you turn RLS on.
  • Auth - Supabase Auth stores users in auth.users. After sign-in, the client JWT includes the authenticated role so policies can use auth.uid() for per-user rows.
  • Migrations - Avoid changing production only via the dashboard. Track SQL in versioned migration files and apply with the Supabase CLI (supabase db push, linked projects) so environments stay aligned.
  • Generated types - Run supabase gen types typescript --project-id <ref> to emit TypeScript types from your schema for a type-safe client.
  • Storage - S3-compatible buckets for files (images, PDFs) with bucket policies analogous to RLS.
  • Realtime - Subscribe to insert/update/delete on tables or channels for live UI updates.
  • Edge Functions - Deno functions for webhooks, light API logic, or tasks that should not live in the database.
  • Local dev - supabase init and supabase start spin up local Postgres and services; useful for offline work, while cloud projects are fastest for a first tutorial.

Demo

Runnable scripts for this post live in the supabase-basics-demo folder in the private demos repository. Get access via code demos.

RAG with OpenAI Embeddings, pgvector and LangChain

June 2, 2026

Retrieval-Augmented Generation (RAG) is a practical pattern: store knowledge as embeddings, retrieve the most relevant chunks with semantic search, then generate an answer grounded in that context.

This guide shows an end-to-end RAG flow with LangChain, OpenAI embeddings, PostgreSQL + pgvector, and an LCEL answer chain. For LangChain basics, see the LangChain overview post. For loaders and splitter choice, see the loaders and chunking post.

Prerequisites

  • OpenAI account
  • Generated API key
  • Enabled billing
  • Node.js version 26
  • PostgreSQL with pgvector extension enabled
  • npm packages: @langchain/pgvector, @langchain/openai, @langchain/core, @langchain/textsplitters, langchain, pg
npm i @langchain/pgvector @langchain/openai @langchain/core @langchain/textsplitters langchain pg

What are embeddings?

Embeddings are numeric vectors that represent the semantic meaning of text. Similar text should produce vectors that are close in vector space.

In this pipeline:

  • Split source documents into chunks
  • Embed chunks with OpenAIEmbeddings and store them in pgvector via PGVectorStore
  • Embed the user question at query time and retrieve nearest chunks with a LangChain retriever
  • Pass retrieved context into an LCEL chain that calls ChatOpenAI

Chunk documents

Chunking makes retrieval more precise. Instead of embedding one large document, split it into smaller overlapping parts. Start with chunkSize: 800 and chunkOverlap: 120, then adjust based on your document style and answer quality.

import { RecursiveCharacterTextSplitter } from '@langchain/textsplitters';
const splitter = new RecursiveCharacterTextSplitter({
chunkSize: 800,
chunkOverlap: 120
});
const docs = await splitter.createDocuments(
['RAG combines retrieval and generation. Store chunks as vectors and fetch similar chunks at query time.'],
[{ source: 'notes.md' }]
);

Store chunks in pgvector

Use PGVectorStore from @langchain/pgvector. It creates the table if needed, embeds documents, and stores vectors with metadata.

import pg from 'pg';
import { OpenAIEmbeddings } from '@langchain/openai';
import { PGVectorStore } from '@langchain/pgvector';
const embeddings = new OpenAIEmbeddings({ model: 'text-embedding-3-small' });
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });
const vectorStore = await PGVectorStore.initialize(embeddings, {
pool,
tableName: 'rag_documents',
columns: {
idColumnName: 'id',
vectorColumnName: 'vector',
contentColumnName: 'content',
metadataColumnName: 'metadata'
},
distanceStrategy: 'cosine'
});
await vectorStore.addDocuments(docs);

Retrieve context

Turn the vector store into a retriever to fetch the top-k relevant chunks for a question:

const retriever = vectorStore.asRetriever({ k: 4 });
const chunks = await retriever.invoke('How does pgvector semantic search work?');

RAG chain with LCEL

Wire retrieval and generation with LCEL. The retriever supplies context; the model answers from that context only.

import { ChatPromptTemplate } from '@langchain/core/prompts';
import { StringOutputParser } from '@langchain/core/output_parsers';
import { RunnablePassthrough, RunnableSequence } from '@langchain/core/runnables';
import { ChatOpenAI } from '@langchain/openai';
const prompt = ChatPromptTemplate.fromMessages([
[
'system',
'Answer only from the provided context. If context is insufficient, say you need more data.'
],
['human', 'Context:\n{context}\n\nQuestion: {question}']
]);
const model = new ChatOpenAI({ model: 'gpt-5.5' });
const formatDocs = (documents) =>
documents.map((doc) => doc.pageContent).join('\n\n---\n\n');
const chain = RunnableSequence.from([
{
context: retriever,
question: new RunnablePassthrough()
},
(input) => ({
context: formatDocs(input.context),
question: input.question
}),
prompt,
model,
new StringOutputParser()
]);
const answer = await chain.invoke('How does pgvector semantic search work?');
console.log(answer);

Demo

Runnable scripts for this post live in the rag-openai-embeddings-pgvector-demo folder in the private demos repository. Get access via code demos.

Integration with Hugging Face Inference API

June 1, 2026

Hugging Face hosts thousands of open models for NLP, vision, and other tasks. The Inference API (via Inference Providers) lets you call those models over HTTP. The @huggingface/inference package from huggingface.js is the Node.js client.

Prerequisites

  • Hugging Face account
  • Access token with permission to call Inference (create a token of type Read or Fine-grained with inference access)
  • Node.js version 26
  • @huggingface/inference installed (npm i @huggingface/inference)

Some models (especially image generation) are routed through Inference Providers. Enable providers and billing in account settings when a model requires it.

Client setup

Pass your token to InferenceClient. Read it from the environment in production.

import { InferenceClient } from '@huggingface/inference';
const client = new InferenceClient(process.env.HF_TOKEN);

Summarization

Shrink longer text into a short summary. Pick a model trained for summarization and respect its max input length.

const result = await client.summarization({
model: 'facebook/bart-large-cnn',
inputs: `The tower is 324 metres (1,063 ft) tall, about the same height as an 81-storey building, and the tallest structure in Paris. Its base is square, measuring 125 metres (410 ft) on each side. During its construction, the Eiffel Tower surpassed the Washington Monument to become the tallest man-made structure in the world, a title it held for 41 years until the Chrysler Building in New York City was finished in 1930. It was initially criticized by some artists and intellectuals, but it became a global cultural icon of France and one of the most recognizable structures in the world.`,
parameters: {
max_length: 80,
},
});
console.log(result.summary_text);

Text classification

Assign labels with confidence scores. Common use case: sentiment analysis on short text.

const labels = await client.textClassification({
model: 'distilbert-base-uncased-finetuned-sst-2-english',
inputs: 'I really enjoyed this workshop.',
});
for (const { label, score } of labels) {
console.log(label, score.toFixed(4));
}

Text to image

Generate an image from a text prompt. The client returns a Blob; write it to disk or serve it from your app.

import { writeFileSync } from 'node:fs';
const image = await client.textToImage({
provider: 'hf-inference',
model: 'black-forest-labs/FLUX.1-schnell',
inputs: 'A watercolor painting of a fox in an autumn forest',
parameters: {
num_inference_steps: 4,
},
});
const buffer = Buffer.from(await image.arrayBuffer());
writeFileSync('output.png', buffer);

With provider: 'hf-inference', use a model from the hf-inference catalog for that task (for example black-forest-labs/FLUX.1-schnell). Older Hub repos such as stabilityai/stable-diffusion-2-1 may no longer exist or lack provider mapping.

Image models are often slower and may incur provider usage charges.

Demo

Runnable scripts for each task live in the huggingface-inference-api-demo folder. Get access via code demos.

LLM integration with OpenAI Responses API

May 31, 2026

Large language models (LLMs) understand and generate text from prompts. OpenAI exposes models through the Responses API. The official openai npm package is the practical way to call it from Node.js. This post covers common patterns beyond a single prompt string.

For the Chat Completions API (messages, choices[0].message.content), see the dedicated post. For the same patterns with the Vercel AI SDK (generateText, streamText), see the dedicated post.

Prerequisites

  • OpenAI account
  • Generated API key
  • Enabled billing
  • Node.js version 26
  • openai package installed (npm i openai)
  • For Markdown output: marked, dompurify, and jsdom (npm i marked dompurify jsdom)

Client setup

Create a client with your API key (read from the environment in production).

import OpenAI from 'openai';
const client = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });

The same SDK can target other hosts that implement a compatible API by setting baseURL and apiKey:

const client = new OpenAI({
apiKey: process.env.LLM_API_KEY,
baseURL: 'https://your-gateway.example/v1',
});

Azure OpenAI uses AzureOpenAI instead. Many third-party gateways support Chat Completions only; the examples below use client.responses.*, so confirm your provider supports the Responses API (especially for tools like web search).

Basic integration

Pass a string as input and read output_text from the response.

const response = await client.responses.create({
model: 'gpt-5.5',
input: 'Write a one-sentence bedtime story about a unicorn.',
});
console.log(response.output_text);

System prompt

Use top-level instructions for stable behavior (tone, format, role). They take precedence over casual wording in the user message.

const response = await client.responses.create({
model: 'gpt-5.5',
instructions: 'Reply in one short sentence. Use plain language.',
input: 'Explain what an LLM is.',
});
console.log(response.output_text);

Few-shot prompting

Pass prior turns as an input array with user and assistant roles, then the new user message. Keep task rules in instructions.

const response = await client.responses.create({
model: 'gpt-5.5',
instructions:
'Classify sentiment as exactly one word: positive, negative, or neutral.',
input: [
{ role: 'user', content: 'I love this!' },
{ role: 'assistant', content: 'positive' },
{ role: 'user', content: 'This is awful.' },
{ role: 'assistant', content: 'negative' },
{ role: 'user', content: 'It is fine I guess.' },
],
});
console.log(response.output_text);

Streaming

Set stream: true and handle response.output_text.delta events for incremental text.

const stream = await client.responses.create({
model: 'gpt-5.5',
input: 'List three colors.',
stream: true,
});
for await (const event of stream) {
if (event.type === 'response.output_text.delta') {
process.stdout.write(event.delta);
}
}

Structured output with JSON schema

Constrain the model to JSON matching your schema via text.format. With strict: true, the output should match the schema.

const response = await client.responses.create({
model: 'gpt-5.5',
input: 'The film Inception was directed by Christopher Nolan.',
text: {
format: {
type: 'json_schema',
name: 'movie_summary',
strict: true,
schema: {
type: 'object',
properties: {
title: { type: 'string' },
director: { type: 'string' },
},
required: ['title', 'director'],
additionalProperties: false,
},
},
},
});
const data = JSON.parse(response.output_text);
console.log(data.title, data.director);

For typed parsing with Zod, you can use client.responses.parse() instead of JSON.parse.

Markdown output to HTML

Ask for Markdown in instructions, then convert output_text to HTML and sanitize before rendering (for example with innerHTML in the browser or when storing HTML).

import { marked } from 'marked';
import { JSDOM } from 'jsdom';
import DOMPurify from 'dompurify';
const purify = DOMPurify(new JSDOM('').window);
const response = await client.responses.create({
model: 'gpt-5.5',
instructions: 'Reply in Markdown only. Use a heading and a short bullet list.',
input: 'Explain what an LLM is in three bullet points.',
});
const markdown = response.output_text;
const html = marked.parse(markdown);
const safeHtml = purify.sanitize(html);

Always run DOMPurify.sanitize on model-generated HTML. The model can emit unsafe markup; sanitization strips scripts and other dangerous content.

Web search tool

Enable the built-in web search tool when the answer should use current information from the web.

const response = await client.responses.create({
model: 'gpt-5.5',
tools: [{ type: 'web_search' }],
include: ['web_search_call.action.sources'],
input: 'What was a major tech headline this week? Cite sources briefly.',
});
console.log(response.output_text);

Web search adds latency and tool usage cost. Use a model that supports tools.

Demo

Runnable scripts for each section live in the openai-responses-api-demo folder. Get access via code demos.

Browser automation with Playwright

May 17, 2026

Playwright is a headless browser library for automating browser tasks. Here's the list of some of the features (Playwright equivalents to the Puppeteer post):

  • Turn off headless mode

    import { chromium } from 'playwright';
    const browser = await chromium.launch({
    headless: false
    // ...
    });
  • Resize the viewport to the window size

    const context = await browser.newContext({
    viewport: null
    });
    const page = await context.newPage();
  • Emulate screen how it's shown to the user via the emulateMedia method

    await page.emulateMedia({ media: 'screen' });
  • Save the page as a PDF file with a specified path, format, scale factor, and page range

    await page.pdf({
    path: 'path.pdf',
    format: 'A3',
    scale: 1,
    pageRanges: '1-2',
    printBackground: true
    });
  • Use preexisting user's credentials to skip logging in to some websites. The user data directory is a parent of the Profile Path value from the chrome://version page. Use launchPersistentContext instead of launch + newContext.

    import { chromium } from 'playwright';
    const context = await chromium.launchPersistentContext(
    'C:\\Users\\<USERNAME>\\AppData\\Local\\Google\\Chrome\\User Data',
    { headless: false }
    );
    const page = context.pages()[0] ?? (await context.newPage());
  • Use Chrome instance instead of Chromium via the channel option. Close Chrome before running the script if the profile is in use.

    import { chromium } from 'playwright';
    const browser = await chromium.launch({
    channel: 'chrome'
    // ...
    });
  • Switch to the selected tab

    await page.bringToFront();
  • Get value based on evaluation in the browser page

    const shouldPaginate = await page.evaluate(
    ([param1, param2]) => {
    // ...
    },
    [param1, param2]
    );
  • Get HTML content from the specific element

    const html = await page.locator('.field--text').evaluate(
    (element) => element.outerHTML
    );
  • Wait for a specific selector to be loaded. You can also provide a timeout in milliseconds

    await page.waitForSelector('.success', { timeout: 5000 });
  • Manipulate with a specific element and click on some of the elements

    await page.locator('#header').evaluate(async (headerElement) => {
    // ...
    headerElement
    .querySelectorAll('svg')
    .item(13)
    .parentNode.click();
    });
  • Extend execution timeout for slow evaluate callbacks

    page.setDefaultTimeout(0);
    // or per action:
    await page.locator('#header').evaluate(async (headerElement) => {
    // ...
    }, { timeout: 0 });
  • Manipulate with multiple elements

    await page.locator('.some-class').evaluateAll(async (elements) => {
    // ...
    });
  • Wait for navigation (e.g., form submitting) to be done

    await page.waitForLoadState('networkidle', { timeout: 0 });

    Or wait for navigation triggered by a click:

    await Promise.all([
    page.waitForNavigation({ waitUntil: 'networkidle', timeout: 0 }),
    page.click('button[type="submit"]')
    ]);
  • Trigger hover event on some of the elements

    await page.locator('#header').hover();

    Or dispatch a custom event in the page:

    await page.locator('#header').evaluate((headerElement) => {
    const hoverEvent = new MouseEvent('mouseover', {
    view: window,
    bubbles: true,
    cancelable: true
    });
    headerElement.dispatchEvent(hoverEvent);
    });
  • Expose a function in the browser and use it in evaluate / evaluateAll callbacks (e.g., simulate typing using the window.type function)

    await page.exposeFunction('type', async (selector, text, options) => {
    await page.locator(selector).type(text, options);
    });
    await page.locator('.some-class').evaluateAll(async (elements) => {
    // ...
    await window.type(selector, text, { delay: 0 });
    });
  • Press the Enter button after typing the input field value

    await page.locator(selector).fill(text);
    await page.locator(selector).press('Enter');
  • Open a file chooser and select file for upload

    const fileChooserPromise = page.waitForEvent('filechooser');
    await page.locator(selector).click();
    const fileChooser = await fileChooserPromise;
    const filePath = `C:/Users/<USERNAME>/Downloads/test.jpeg`; // use "/" instead of "\" in file path
    await fileChooser.setFiles(filePath);
  • Remove the value from the input field before typing the new one

    await page.locator(selector).fill(text);

    Or select all and replace:

    await page.locator(selector).click({ clickCount: 3 });
    await page.locator(selector).type(text, options);
  • Pass a variable into evaluate / evaluateAll callbacks via extra arguments

    await page.locator('#element').evaluate(
    async (element, customVariable) => {
    // ...
    },
    customVariable
    );
  • Mock response for the specific request

    await page.route(REDIRECTION_URL, async (route) => {
    await route.fulfill({
    contentType: 'text/html',
    status: 304,
    body: '<body></body>'
    });
    });
  • Intercept page redirections (via route) and open them in new tabs rather than following them in the same tab

    await page.route(REDIRECTION_URL, async (route) => {
    const url = route.request().url();
    await route.fulfill({
    contentType: 'text/html',
    status: 304,
    body: '<body></body>'
    });
    const newPage = await context.newPage();
    await newPage.goto(url, { waitUntil: 'domcontentloaded', timeout: 0 });
    // ...
    await newPage.close();
    });
  • Intercept page response

    page.on('response', async (response) => {
    if (response.url() === RESPONSE_URL) {
    if (response.status() === 200) {
    // ...
    }
    // ...
    }
    });

Demo

Runnable tests for each API pattern live in the browser-automation-playwright-demo folder. Get access via code demos.

Load and stress testing with Artillery

April 25, 2026

Artillery is a performance testing tool. This post explains types of performance testing and dives into Artillery usage, from configuration to running tests and checking results.

Load and stress testing

Load and stress testing are two types of performance testing used to evaluate how well a system performs under various conditions.

Load testing determines how the system performs under expected user loads. The purpose is to identify performance bottlenecks.

Stress testing assesses how the system performs when loads are heavier than usual. The purpose is to find the limit at which the system fails and to observe how it recovers from such failures.

Prerequisites

  • Artillery available via npx (or installed in project dev dependencies)

  • YAML config file that defines target, phases and scenarios

  • Optional CSV payload file for dynamic test data

Configuration

Artillery test configuration is defined in a YAML file (for example, .artillery/main.yml). It typically contains:

  • target: base URL of the app (http://localhost:3000)
  • phases: load shape over time
  • payload: CSV file for dynamic values
  • scenarios: user flows and weights

For realistic load tests, configure these parts intentionally:

  • Define phases for warm-up, ramp-up, and sustained load.
  • Use a CSV payload when requests require dynamic values (IDs, emails, tokens, and similar).
  • Use weighted scenarios to simulate realistic traffic distribution across endpoints.
config:
target: 'http://localhost:3000'
phases:
- duration: 30
arrivalRate: 5
name: Warm up
- duration: 30
arrivalRate: 5
rampTo: 50
name: Ramp up load
- duration: 30
arrivalRate: 50
name: Sustained load
payload:
path: 'dynamic_data.csv'
fields:
- 'CustomerId'
scenarios:
- name: "Get customer's tracks"
flow:
- get:
url: '/customers/{{ CustomerId }}/tracks'
weight: 4
- name: 'Get customers pdf'
flow:
- get:
url: '/customers/pdf'
weight: 1

Scenario flow and dynamic data

Scenarios define user actions during the test.

In the example:

  • 80% of users run Get customer's tracks (weight: 4)
  • 20% of users run Get customers pdf (weight: 1)

Dynamic data is loaded from .artillery/dynamic_data.csv, and {{ CustomerId }} is injected into request URLs.

Running tests

Load tests can be executed through an npm script:

"scripts": {
"test:load": "artillery run ./.artillery/main.yml"
}

Run the test with:

npm run test:load

You can also run Artillery directly with npx:

npx artillery run ./.artillery/main.yml

Test report

By default, Artillery prints a terminal summary with key metrics such as:

  • failed virtual users and HTTP error/status-code distribution
  • response-time percentiles (especially p95 and p99)
  • median response time (p50)
  • requests per second and total request volume

To save raw results and generate an HTML report:

npx artillery run --output report.json ./.artillery/main.yml
npx artillery report report.json --output report.html

This generates a shareable HTML report with latency percentiles, throughput and error trends.

When reading Artillery reports, focus on these metrics first:

  • Error rate (vusers.failed, non-2xx/3xx codes): the most important health signal. Even small error percentages can indicate instability under load.
  • Tail latency (p95, p99): shows worst-case user experience. Systems can have a good median but still feel slow for a significant group of users.
  • Median latency (p50/median): useful for baseline responsiveness, but should always be evaluated together with p95/p99.
  • Throughput (http.requests, requests/sec): confirms how much traffic the system handled during the test.
  • Trend over time (intermediate snapshots): helps identify whether performance degrades during ramp-up or sustained load.

In short: start with reliability (errors), then check latency percentiles (especially p95/p99), and finally validate throughput and time-based stability.

2024

HTTP timeout with Axios

September 26, 2024

Setting up a timeout for HTTP requests can prevent the connection from hanging forever, waiting for the response. It can be set on the client side to improve user experience, and on the server side to improve inter-service communication.

axios package provides a timeout parameter for this functionality.

const HTTP_TIMEOUT = 3000;
const URL = 'https://www.google.com:81';
(async () => {
try {
await axios(URL, {
timeout: HTTP_TIMEOUT
});
} catch (error) {
console.error('Request timed out', error.cause);
}
})();

Use this snippet also to simulate aborted requests.

Demo

Runnable code for this post lives in the axios-http-demo folder. Get access via code demos.

Profiling Node.js apps with Chrome DevTools profiler

July 5, 2024

Profiling refers to analyzing and measuring an application's performance characteristics.

Profiling helps identify performance bottlenecks in a Node.js app, such as CPU-intensive tasks like cryptographic operations, image processing, or complex calculations.

This post covers running a profiler for various Node.js apps in Chrome DevTools.

Prerequisites

  • Google Chrome installed

  • Node.js app bootstrapped

Setup

  • Run node --inspect app.js to start the debugger.

  • Open chrome://inspect, click Open dedicated DevTools for Node and then navigate to the Performance tab. Start recording.

  • Run load testing via autocannon package using the following command format npx autocannon <COMMAND>.

  • Stop recording in Chrome DevTools.

Profiling

On Perfomance tab in Chrome DevTools open Bottom-Up subtab to identify which functions consume the most time.

Look for potential performance bottlenecks, such as synchronous functions for hashing (pbkdf2Sync) or file system operations (readFileSync).

Load and stress testing with k6

May 10, 2024

k6 is a performance testing tool. This post explains types of performance testing and dives into k6 usage, from configuration to running tests.

Load and stress testing

Load and stress testing are two types of performance testing used to evaluate how well a system performs under various conditions.

Load testing determines how the system performs under expected user loads. The purpose is to identify performance bottlenecks.

Stress testing assesses how the system performs when loads are heavier than usual. The purpose is to find the limit at which the system fails and to observe how it recovers from such failures.

Prerequisites

  • k6 installed

  • Script (Node.js) file with configuration and execution function

Configuration

Configuration is stored inside options variable, which allows you to set up different testing scenarios:

  • constant user load, the number of virtual users (vus) remains constant throughout the test period
export const options = {
vus: 30,
duration: '10m'
};
  • variable user load, the number of users increases and decreases over time
export const options = {
stages: [
{
duration: '1m',
target: 30
},
{
duration: '10m',
target: 30
},
{
duration: '5m',
target: 0
}
]
};

Environment variables can be passed through the command line and are accessible within the script via the __ENV object.

k6 -e TOKEN=token run script.js

Execution function

This function defines what virtual users will do during the test. This function is called for each virtual user and typically includes steps that simulate user actions on the app.

export default function() {
http.get(URL);
// Add more actions as required
}

Test report

k6 generates a report that provides detailed insights into various benchmarks, such as the number of virtual users, requests per second, request durations and error rates.

Example

This example utilizes k6 to conduct a load test using a variable user load approach:

  • User simulation: The script ramps up to 1,000 users, maintains that level to simulate sustained traffic, and gradually reduces to zero.

  • Request handling: During the test, each virtual user sends a POST request to an API, with pauses between requests to mimic real user behavior.

  • Performance insights: After the test, k6 provides a report that shows key information, such as how fast the app responds and how many requests fail.

Run it via k6 -e TOKEN=1234 run script.js command.

// script.js
import { check, sleep } from 'k6';
import { scenario } from 'k6/execution';
import http from 'k6/http';
export const options = {
stages: [
// Ramp up to 1000 users over 10 minutes
{
duration: '10m',
target: 1000
},
// Hold 1000 users for 30 minutes
{
duration: '30m',
target: 1000
},
// Ramp down to 0 users over 5 minutes
{
duration: '5m',
target: 0
}
]
};
export default () => {
const response = http.post(
URL,
JSON.stringify({
iteration: scenario.iterationInTest
}),
{
headers: {
Authorization: __ENV.TOKEN,
'Content-Type': 'application/json'
}
}
);
check(response, {
'response status was 200': (res) => res.status === 200
});
sleep(1);
};

Demo

Runnable scripts for each section live in the load-stress-testing-k6-demo folder. Get access via code demos.

Node Version Manager (nvm) overview

May 9, 2024

nvm facilitates switching between different Node versions across projects. This post covers nvm-sh on macOS and Linux. For Windows, see the nvm for Windows post.

As of mid-2026, Node.js 24 is Active LTS, 22 is Maintenance LTS, and 26 is the Current release.

Installation

To install nvm, execute the following commands in your terminal. This example uses zsh, but the process is similar for other shells like bash.

curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.5/install.sh | bash
source ~/.zshrc

Version management

  • Install the specific version. Including the v prefix is optional.

    nvm install v24.16.0
  • Install the latest version

    nvm install node
  • Install the latest Active LTS release

    nvm install --lts
  • Install the latest one for the specified major version

    nvm install 24
  • Switch to a specific installed version

    nvm use 24
  • Add .nvmrc file inside the project directory and run nvm use command to use the specified installed version.

    v24.16.0
  • Get the list of locally installed versions

    nvm ls
  • Get the list of available versions for installation

    nvm ls-remote

Debugging Node.js apps with Chrome DevTools debugger

April 12, 2024

Debugging with a debugger and breakpoints is recommended rather than using console logs. Chrome provides a built-in debugger for JavaScript-based apps.

This post covers configuring and running a debugger for various Node.js apps in Chrome DevTools.

Prerequisites

  • Google Chrome installed

  • Node.js app bootstrapped

Setup

Open chrome://inspect, click Open dedicated DevTools for Node and open the Connection tab. You should see a list of network endpoints for debugging.

Run node --inspect-brk app.js to start the debugger, it will log the debugger network. Choose the network in Chrome to open the debugger in Chrome DevTools.

Debugging basics

The variables tab shows local and global variables during debugging.

The step over next function call option goes to the following statement in the codebase, while the step into next function call option goes deeper into the current statement.

Add logs in the debug console via logpoints by selecting the specific part of the codebase so it logs when the selected codebase executes.

Sending e-mails with SendGrid

March 30, 2024

To send e-mails in a production environment, use services like Sendgrid.

Verify the e-mail address on the Sender Management page and create the SendGrid API key on the API keys page.

import nodemailer from 'nodemailer';
(async () => {
const emailConfiguration = {
auth: {
user: process.env.EMAIL_USERNAME, // 'apikey'
pass: process.env.EMAIL_PASSWORD
},
host: process.env.EMAIL_HOST, // 'smtp.sendgrid.net'
port: process.env.EMAIL_PORT, // 465
secure: process.env.EMAIL_SECURE // true
};
const transport = nodemailer.createTransport(emailConfiguration);
const info = await transport.sendMail({
from: '"Sender" <sender@example.com>',
to: 'recipient1@example.com, recipient2@example.com',
subject: 'Subject',
text: 'Text',
html: '<b>Text</b>'
});
console.log('Message sent: %s', info.messageId);
})();

Demo

Runnable code for this post lives in the emails-sendgrid-demo folder. Get access via code demos.

Web scraping with cheerio

January 19, 2024

Web scraping means extracting data from websites. This post covers extracting data from the page's HTML tags.

Prerequisites

  • cheerio package is installed

  • HTML page is retrieved via an HTTP client

Usage

  • create a scraper object with load method by passing HTML content as an argument

    • set decodeEntities option to false to preserve encoded characters (like &) in their original form
    const $ = load('<div><!-- HTML content --></div>', { decodeEntities: false });
  • find DOM elements by using CSS-like selectors

    const items = $('.item');
  • iterate through found elements using each method

    items.each((index, element) => {
    // ...
    });
  • access element content using specific methods

    • text - $(element).text()

    • HTML - $(element).html()

    • attributes

      • all - $(element).attr()
      • specific one - $(element).attr('href')
    • child elements

      • first - $(element).first()
      • last - $(element).last()
      • all - $(element).children()
      • specific one - $(element).find('a')
    • siblings

      • previous - $(element).prev()
      • next - $(element).next()

Disclaimer

Please check the website's terms of service before scraping it. Some websites may have terms of service that prohibit such activity.

Demo

Runnable code for this post lives in the scraping-cheerio-demo folder. Get access via code demos.

2023

Integration with GitHub GraphQL API

December 22, 2023

GitHub provides GraphQL API to create integrations, retrieve data, and automate workflows.

Prerequisites

  • GitHub token (Settings Developer Settings Personal access tokens)

Integration

Below is an example of retrieving sponsorable users by location.

export async function getUsersBy(location) {
return fetch('https://api.github.com/graphql', {
method: 'POST',
body: JSON.stringify({
query: `query {
search(type: USER, query: "location:${location} is:sponsorable", first: 100) {
edges {
node {
... on User {
bio
login
viewerCanSponsor
}
}
}
userCount
}
}`
}),
headers: {
ContentType: 'application/json',
Authorization: `Bearer ${process.env.GITHUB_TOKEN}`
}
})
.then((response) => response.json())
.then((response) => response.data?.search?.edges || []);
}

Demo

Runnable Node.js script for this post lives in the github-graphql-api-nodejs-demo folder. Get access via code demos.

Web scraping with jsdom

December 14, 2023

Web scraping means extracting data from websites. This post covers extracting data from the page's HTML when data is stored in JavaScript variable or stringified JSON.

The scraping prerequisite is retrieving an HTML page via an HTTP client.

Examples

The example below moves data into a global variable, executes the page scripts and accesses the data from the global variable.

import jsdom from 'jsdom';
fetch(URL)
.then((res) => res.text())
.then((response) => {
const dataVariable = 'someVariable.someField';
const html = response.replace(dataVariable, `var data=${dataVariable}`);
const dom = new jsdom.JSDOM(html, {
runScripts: 'dangerously',
virtualConsole: new jsdom.VirtualConsole()
});
console.log('data', dom?.window?.data);
});

The example below runs the page scripts, and access stringified JSON data.

import jsdom from 'jsdom';
fetch(URL)
.then((res) => res.text())
.then((response) => {
const dom = new jsdom.JSDOM(response, {
runScripts: 'dangerously',
virtualConsole: new jsdom.VirtualConsole()
});
const data = dom?.window?.document?.getElementById('someId')?.value;
console.log('data', JSON.parse(data));
});

Disclaimer

Please check the website's terms of service before scraping it. Some websites may have terms of service that prohibit such activity.

Demo

Runnable Node.js scripts for this post live in the scraping-jsdom-demo folder. Get access via code demos.

License key verification with Gumroad API

November 16, 2023

Gumroad allows verifying license keys via API calls to limit the usage of the keys. It can be helpful to prevent the redistribution of products like desktop apps.

Allow generating a unique license key per sale in product settings, and the product ID will be shown there. Below is the code snippet for verification.

try {
const requestBody = new URLSearchParams();
requestBody.append('product_id', process.env.PRODUCT_ID);
requestBody.append('license_key', process.env.LICENSE_KEY);
requestBody.append('increment_uses_count', true);
const response = await fetch('https://api.gumroad.com/v2/licenses/verify', {
method: 'POST',
body: requestBody
});
const data = await response.json();
if (data.purchase?.test) {
console.log('Skipping verification for test purchase');
return;
}
const verificationLimit = Number(process.env.VERIFICATION_LIMIT);
if (data.uses >= verificationLimit + 1) {
throw new Error('Verification limit exceeded');
}
if (!data.success) {
throw new Error(data.message);
}
} catch (error) {
if (error?.response?.status === 404) {
console.log("License key doesn't exist");
return;
}
console.log('Verifying license key failed', error);
}

Demo

Runnable code for this post lives in the license-key-verification-gumroad-api-demo folder. Get access via code demos.

PDF generation with Gotenberg

November 4, 2023

Gotenberg is a Docker-based stateless API for PDF generation from HTML and Markdown files.

To get started, configure Docker Compose and run docker compose up.

services:
gotenberg:
image: gotenberg/gotenberg:7
ports:
- 3000:3000

API is available on http://localhost:3000 address.

Run the following commands to generate PDFs

  • from the given URL

    curl \
    --request POST 'http://localhost:3000/forms/chromium/convert/url' \
    --form 'url="https://sparksuite.github.io/simple-html-invoice-template/"' \
    --form 'pdfFormat="PDF/A-1a"' \
    -o curl-url-response.pdf
  • from the given HTML file

    curl \
    --request POST 'http://localhost:3000/forms/chromium/convert/html' \
    --form 'files=@"./index.html"' \
    --form 'pdfFormat="PDF/A-1a"' \
    -o curl-html-response.pdf

PDF/A-1a format is used for the long-term preservation of electronic documents, ensuring that documents can be accessed and read even as technology changes.

Demo

Runnable scripts for each conversion approach live in the pdf-generation-gotenberg-demo folder. Get access via code demos.

Identifying missing variables in Handlebars templates

November 3, 2023

Handlebars is a template engine that can create server-side views, e-mail templates, and invoice templates by injecting JSON data into HTML.

Resolving all variables in a Handlebars template is essential to maintain the accuracy of the displayed information and to prevent incomplete content or layout problems.

The following snippet checks for missing variables by overriding the default nameLookup function. It logs a warning for unresolved variables and sets the default value, empty string, in this case.

const WARNING_LEVEL = 'warning';
const originalNameLookup = Handlebars.JavaScriptCompiler.prototype.nameLookup;
Handlebars.JavaScriptCompiler.prototype.nameLookup = function (
parent,
name,
type
) {
if (type === 'context') {
const messageLog = JSON.stringify({
message: `Variable is not resolved in the template: ${name}`,
level: WARNING_LEVEL
});
return `${parent} && ${parent}.${name} ? ${parent}.${name} : (console.log(${messageLog}), '')`;
}
return originalNameLookup.call(this, parent, name, type);
};
const result = Handlebars.compile(template)(data);

Demo

Runnable Handlebars scripts for this post live in the handlebars-template-missing-variables-demo folder. Get access via code demos.

Extending outdated TypeScript package declarations

November 2, 2023

Extending package declarations locally is one of the options for outdated package typings.

Create a declaration file .d.ts (e.g., handlebars.d.ts), and put it inside the src directories.

Find the exact name of the package namespace inside the node_modules types file (e.g. handlebars/types/index.d.ts).

Extend the found namespace with your needed properties, like classes, functions, etc.

// handlebars.d.ts
declare namespace Handlebars {
export class JavaScriptCompiler {
public nameLookup(
parent: string,
name: string,
type: string
): string | string[];
}
export function doSomething(name: string): void;
// ...
}

Integration with Notion API

September 9, 2023

Notion is a versatile workspace tool combining note-taking, task management, databases, and collaboration features into a single platform.

It also supports integration with Notion content, facilitating tasks such as creating pages, retrieving a block, and filtering database entries via API.

Prerequisites

  • Notion account
  • Generated Integration token (Settings & Members Connections Develop or manage integrations New integration)
  • Notion database ID (open database as full page, extract database ID from the URL (https://notion.so/<USERNAME>/<DATABASE_ID>?v=v))
  • Added Notion connection (three dots (...) menu Add Connections choose created integration)
  • @notionhq/client package installed

Integration

Below is an example of interacting with Notion API to create the page (within the chosen database) with icon, cover, properties, and child blocks.

const { Client } = require('@notionhq/client');
const notion = new Client({ auth: process.env.NOTION_INTEGRATION_TOKEN });
const response = await notion.pages.create({
parent: {
type: 'database_id',
database_id: process.env.NOTION_DATABASE_ID
},
icon: {
type: 'emoji',
emoji: '🆗'
},
cover: {
type: 'external',
external: {
url: 'https://cover.com'
}
},
properties: {
Name: {
title: [
{
type: 'text',
text: {
content: 'Some name'
}
}
]
},
Score: {
number: 42
},
Tags: {
multi_select: [
{
name: 'A'
},
{
name: 'B'
}
]
},
Generation: {
select: {
name: 'I'
}
}
// other properties
},
children: [
{
object: 'block',
type: 'bookmark',
bookmark: {
url: 'https://bookmark.com'
}
}
]
});

Browser automation with Puppeteer

August 26, 2023

Puppeteer is a headless browser for automating browser tasks. Here's the list of some of the features:

  • Turn off headless mode

    const browser = await puppeteer.launch({
    headless: false
    // ...
    });
  • Resize the viewport to the window size

    const browser = await puppeteer.launch({
    // ...
    defaultViewport: null
    });
  • Emulate screen how it's shown to the user via the emulateMediaType method

    await page.emulateMediaType('screen');
  • Save the page as a PDF file with a specified path, format, scale factor, and page range

    await page.pdf({
    path: 'path.pdf',
    format: 'A3',
    scale: 1,
    pageRanges: '1-2',
    printBackground: true
    });
  • Use preexisting user's credentials to skip logging in to some websites. The user data directory is a parent of the Profile Path value from the chrome://version page.

    const browser = await puppeteer.launch({
    userDataDir:
    'C:\\Users\\<USERNAME>\\AppData\\Local\\Google\\Chrome\\User Data',
    args: []
    });
  • Use Chrome instance instead of Chromium by utilizing the Executable Path from the chrome://version URL. Close Chrome browser before running the script

    const browser = await puppeteer.launch({
    executablePath: puppeteer.executablePath('chrome')
    // ...
    });
  • Switch to the selected tab

    await page.bringToFront();
  • Get value based on evaluation in the browser page

    const shouldPaginate = await page.evaluate(
    (param1, param2) => {
    // ...
    },
    param1,
    param2
    );
  • Get HTML content from the specific element

    const html = await page.evaluate(
    () => document.querySelector('.field--text').outerHTML
    );
  • Wait for a specific selector to be loaded. You can also provide a timeout in milliseconds

    await page.waitForSelector('.success', { timeout: 5000 });
  • Manipulate with a specific element and click on some of the elements

    await page.$eval('#header', async (headerElement) => {
    // ...
    headerElement
    .querySelectorAll('svg')
    .item(13)
    .parentNode.click();
    });
  • Extend execution of the $eval method

    const browser = await puppeteer.launch({
    // ...
    protocolTimeout: 0
    });
  • Manipulate with multiple elements

    await page.$$eval('.some-class', async (elements) => {
    // ...
    });
  • Wait for navigation (e.g., form submitting) to be done

    await page.waitForNavigation({ waitUntil: 'networkidle0', timeout: 0 });
  • Trigger hover event on some of the elements

    await page.$eval('#header', async (headerElement) => {
    const hoverEvent = new MouseEvent('mouseover', {
    view: window,
    bubbles: true,
    cancelable: true
    });
    headerElement.dispatchEvent(hoverEvent);
    });
  • Expose a function in the browser and use it in $eval and $$eval callbacks (e.g., simulate typing using the window.type function)

    await page.exposeFunction('type', async (selector, text, options) => {
    await page.type(selector, text, options);
    });
    await page.$$eval('.some-class', async (elements) => {
    // ...
    window.type(selector, text, { delay: 0 });
    });
  • Press the Enter button after typing the input field value

    await page.type(selector, `${text}${String.fromCharCode(13)}`, options);
  • Open a file chooser and select file for upload

    const [fileChooser] = await Promise.all([page.waitForFileChooser(), page.click(selector)]);
    const filePath = `C:/Users/<USERNAME>/Downloads/test.jpeg`; // use "/" instead of "\" in file path
    await fileChooser.accept([filePath]);
  • Remove the value from the input field before typing the new one

    await page.click(selector, { clickCount: 3 });
    await page.type(selector, text, options);
  • Expose a variable in the browser by passing it as the third argument for $eval and $$eval methods and use it in $eval and $$eval callbacks

    await page.$eval(
    '#element',
    async (element, customVariable) => {
    // ...
    },
    customVariable
    );
  • Mock response for the specific request

    await page.setRequestInterception(true);
    page.on('request', async function(request) {
    const url = request.url();
    if (url !== REDIRECTION_URL) {
    return request.continue();
    }
    await request.respond({
    contentType: 'text/html',
    status: 304,
    body: '<body></body>'
    });
    });
  • Intercept page redirections (via interceptor) and open them in new tabs rather than following them in the same tab

    await page.setRequestInterception(true);
    page.on('request', async function(request) {
    const url = request.url();
    if (url !== REDIRECTION_URL) {
    return request.continue();
    }
    await request.respond({
    contentType: 'text/html',
    status: 304,
    body: '<body></body>'
    });
    const newPage = await browser.newPage();
    await newPage.goto(url, { waitUntil: 'domcontentloaded', timeout: 0 });
    // ...
    await newPage.close();
    });
  • Intercept page response

    page.on('response', async (response) => {
    if (response.url() === RESPONSE_URL) {
    if (response.status() === 200) {
    // ...
    }
    // ...
    }
    });

Demo

Runnable tests for each API pattern live in the browser-automation-puppeteer-demo folder. Get access via code demos.

AI bulk image upscaler with Node.js

August 4, 2023

Image upscaling can be done using Real-ESRGAN, a super-resolution algorithm. Super-resolution is the process of increasing the resolution of the image.

Real-ESRGAN provides Linux, Windows and MacOS executable files and models for Intel/AMD/Nvidia GPUs.

The snippet below demonstrates bulk image upscaling with scale factor 4 and using the realesrgan-x4plus-anime model.

(async () => {
const inputDirectory = path.resolve(path.join(__dirname, 'pictures'));
const outputDirectory = path.resolve(
path.join(__dirname, 'pictures_upscaled')
);
const modelsPath = path.resolve(path.join(__dirname, 'resources', 'models'));
const execName = 'realesrgan-ncnn-vulkan';
const execPath = path.resolve(
path.join(__dirname, 'resources', getPlatform(), 'bin', execName)
);
const scaleFactor = 4;
const modelName = 'realesrgan-x4plus-anime';
if (!fs.existsSync(outputDirectory)) {
await fs.promises.mkdir(outputDirectory, { recursive: true });
}
const commands = [
'-i',
inputDirectory,
'-o',
outputDirectory,
'-s',
scaleFactor,
'-m',
modelsPath,
'-n',
modelName
];
const upscaler = spawn(execPath, commands, {
cwd: undefined,
detached: false
});
upscaler.stderr.on('data', (data) => {
console.log(data.toString());
});
await timers.setTimeout(600 * 1000);
})();

Demo

Runnable code for this post lives in the upscaler-demo folder. Get access via code demos.

Publishing Electron apps to GitHub with Electron Forge

July 19, 2023

Releasing Electron desktop apps can be automated with Electron Forge and GitHub Actions. This post covers the main steps for automation.

Prerequisites

  • bootstrapped Electron app
  • GitHub personal access token (with repo and write:packages permissions) as a GitHub Action secret (GH_TOKEN)

Setup

Run the following commands to configure Electron Forge for the app release.

npm i @electron-forge/cli @electron-forge/publisher-github -D
npm i electron-squirrel-startup
npx electron-forge import

The last command should install the necessary dependencies and add a configuration file.

Update the forge.config.js file with the bin field containing the app name and ensure the GitHub publisher points to the right repository.

Put Windows and MacOS icons paths in the packagerConfig.icon field, Windows supports ico files with 256x256 resolution, and MacOS supports icns icons with 512x512 resolution (1024x1024 for Retina displays). Linux supports png icons with 512x512 resolution, also include its path in the BrowserWindow constructor config within the icon field.

// forge.config.js
const path = require('path');
module.exports = {
packagerConfig: {
asar: true,
icon: path.join(process.cwd(), 'main', 'build', 'icon')
},
rebuildConfig: {},
makers: [
{
name: '@electron-forge/maker-squirrel',
config: {
bin: 'Electron Starter'
}
},
{
name: '@electron-forge/maker-dmg',
config: {
bin: 'Electron Starter'
}
},
{
name: '@electron-forge/maker-deb',
config: {
bin: 'Electron Starter',
options: {
icon: path.join(process.cwd(), 'main', 'build', 'icon.png')
}
}
},
{
name: '@electron-forge/maker-rpm',
config: {
bin: 'Electron Starter',
icon: path.join(process.cwd(), 'main', 'build', 'icon.png')
}
}
],
plugins: [
{
name: '@electron-forge/plugin-auto-unpack-natives',
config: {}
}
],
publishers: [
{
name: '@electron-forge/publisher-github',
config: {
repository: {
owner: 'delimitertech',
name: 'electron-starter'
},
prerelease: true
}
}
]
};

Upgrade the package version before releasing the app. The npm script for publishing should use publish command. Set productName field to the app name.

// package.json
{
// ...
"version": "1.0.1",
"scripts": {
// ...
"publish": "electron-forge publish"
},
"productName": "Electron Starter"
}

GitHub Action workflow for manually releasing the app for Linux, Windows, and MacOS should contain the below configuration.

# .github/workflows/release.yml
name: Release app
on:
workflow_dispatch:
jobs:
build:
strategy:
matrix:
os:
[
{ name: 'linux', image: 'ubuntu-latest' },
{ name: 'windows', image: 'windows-latest' },
{ name: 'macos', image: 'macos-latest' },
]
runs-on: ${{ matrix.os.image }}
steps:
- name: Github checkout
uses: actions/checkout@v4
- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci
- name: Publish app
env:
GITHUB_TOKEN: ${{ secrets.GH_TOKEN }}
run: npm run publish

Windows startup events

Add the following code in the main process to prevent Squirrel.Windows launches your app multiple times during the installation/updating/uninstallation.

// main/index.js
if (require('electron-squirrel-startup') === true) app.quit();

Formatting Node.js codebase with Prettier

July 3, 2023

Formatting helps to stay consistent with code style throughout the whole codebase. Include format script in pre-hooks (pre-commit or pre-push). This post covers Prettier setup with JavaScript and TypeScript code.

Start by installing the prettier package as a dev dependency.

npm i prettier -D

Specify rules inside the .prettierrc config file.

{
"singleQuote": true,
"trailingComma": "all"
}

Add format script in the package.json file.

{
"scripts": {
// ...
"format": "prettier --write \"{src,test}/**/*.{js,ts}\""
}
}

Notes

If you use Eslint, install the eslint-config-prettier package as a dev dependency and update the Eslint configuration to use the Prettier config.

// eslint.config.js
// ...
import eslintConfigPrettier from 'eslint-config-prettier';
export default [
// ...
eslintConfigPrettier
];

Using Visual Studio Code, you can install a prettier-vscode extension and activate formatting when file changes are saved.

Demo

Sample codebase for this post lives in the formatting-prettier-demo folder. Get access via code demos.

Tracing Node.js Microservices with OpenTelemetry

June 30, 2023

Regarding microservices observability, tracing is important to catch bottlenecks of the services like slow requests and database queries.

OpenTelemetry is a set of monitoring tools that support integration with distributed tracing platforms like Jaeger, Zipkin, and New Relic. This post covers Jaeger v2 tracing setup for Node.js projects.

Prerequisites

  • Docker
  • Node.js version 26
  • OpenTelemetry packages:
npm i @opentelemetry/sdk-node @opentelemetry/auto-instrumentations-node \
@opentelemetry/exporter-trace-otlp-http @opentelemetry/resources \
@opentelemetry/sdk-trace-base @opentelemetry/semantic-conventions express

Jaeger v2

Start Jaeger in all-in-one mode with Docker Compose. Jaeger UI is at http://localhost:16686. OTLP HTTP receiver listens on port 4318.

services:
jaeger:
image: jaegertracing/jaeger:2.19.0
ports:
- 16686:16686
- 4317:4317
- 4318:4318

Run docker compose up -d from the demo folder (see below).

OpenTelemetry setup

Send traces to Jaeger over OTLP HTTP. Use resourceFromAttributes and semantic convention constants (ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION) to label the service. Auto-instrumentation picks up Express, HTTP clients, databases, and other supported libraries.

Process spans in batches with BatchSpanProcessor and shut the SDK down gracefully on SIGTERM.

import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node';
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
import { resourceFromAttributes } from '@opentelemetry/resources';
import { BatchSpanProcessor } from '@opentelemetry/sdk-trace-base';
import { NodeSDK } from '@opentelemetry/sdk-node';
import {
ATTR_DEPLOYMENT_ENVIRONMENT_NAME,
ATTR_SERVICE_NAME,
ATTR_SERVICE_VERSION,
} from '@opentelemetry/semantic-conventions';
const traceExporter = new OTLPTraceExporter({
url: 'http://localhost:4318/v1/traces',
});
const sdk = new NodeSDK({
resource: resourceFromAttributes({
[ATTR_SERVICE_NAME]: `<service-name>-${process.env.NODE_ENV ?? 'dev'}`,
[ATTR_SERVICE_VERSION]: process.env.npm_package_version ?? '0.0.0',
[ATTR_DEPLOYMENT_ENVIRONMENT_NAME]: process.env.NODE_ENV ?? 'dev',
}),
instrumentations: [getNodeAutoInstrumentations()],
spanProcessor: new BatchSpanProcessor(traceExporter),
});
sdk.start();
process.on('SIGTERM', () => {
sdk
.shutdown()
.then(() => console.log('Tracing terminated'))
.catch((error) => console.error('Error terminating tracing', error))
.finally(() => process.exit(0));
});

Import the tracing module before any other application code:

import './tracing.js';
// ...

Hit the app, then open Jaeger UI → SearchService and pick your service name (for example express-starter-dev in the demo).

Demo

Runnable code for this post lives in the jaeger-tracing-demo folder. Get access via code demos.

Streaming binary and base64 files with NestJS

June 25, 2023

Streaming is useful when dealing with big files in web apps. Instead of loading the entire file into memory before sending it to the client, streaming allows you to send it in small chunks, improving memory efficiency and reducing response time.

The code snippet below shows streaming the binary CSV and base64-encoded PDF files with NestJS. Use the same approach for other types of files, like JSON files.

Set content type and filename headers so files are streamed and downloaded correctly. Base64 file is converted to a buffer and streamed afterward. Read files from a file system or by API calls.

import { Controller, Get, Param, Res } from '@nestjs/common';
import { Response } from 'express';
import { createReadStream } from 'fs';
import { readFile } from 'fs/promises';
import { join } from 'path';
import { Readable } from 'stream';
@Controller('templates')
export class TemplatesController {
@Get('csv')
getCsvTemplate(@Res() res: Response): void {
const file = createReadStream(join(process.cwd(), 'template.csv'));
res.set({
'Content-Type': 'text/csv',
'Content-Disposition': 'attachment; filename="template.csv"'
});
file.pipe(res);
}
@Get('pdf/:id')
async getPdfTemplate(
@Param('id') id: string,
@Res() res: Response
): Promise<void> {
const fileBase64 = await readFile(
join(process.cwd(), 'template.pdf'),
'base64'
);
// const fileBase64 = await apiCall();
const fileBuffer = Buffer.from(fileBase64, 'base64');
const fileStream = Readable.from(fileBuffer);
res.set({
'Content-Type': 'application/pdf',
'Content-Disposition': `attachment; filename="template_${id}.pdf"`
});
fileStream.pipe(res);
}
}

Demo

Runnable NestJS app for this post lives in the streaming-files-demo folder. Get access via code demos.

Spies and mocking with Node test runner (node:test)

June 24, 2023

Node.js version 20 brings a stable test runner so you can run tests inside *.test.js files with node --test command. This post covers the primary usage of it regarding spies and mocking for the unit tests.

Spies are functions that let you spy on the behavior of functions called indirectly by some other code while mocking injects test values into the code during the tests.

mock.method can create spies and mock async, rejected async, sync, chained methods, and external and built-in modules.

  • Async function
import assert from 'node:assert/strict';
import { describe, it, mock } from 'node:test';
const calculationService = {
calculate: () => // implementation
};
describe('mocking resolved value', () => {
it('should resolve mocked value', async () => {
const value = 2;
mock.method(calculationService, 'calculate', async () => value);
const result = await calculationService.calculate();
assert.equal(result, value);
});
});
  • Rejected async function
const error = new Error('some error message');
mock.method(calculationService, 'calculate', async () => Promise.reject(error));
await assert.rejects(async () => calculateSomething(calculationService), error);
  • Sync function
mock.method(calculationService, 'calculate', () => value);
  • Chained methods
mock.method(calculationService, 'get', () => calculationService);
mock.method(calculationService, 'calculate', async () => value);
const result = await calculationService.get().calculate();
  • External modules
import axios from 'axios';
mock.method(axios, 'get', async () => ({ data: value }));
  • Built-in modules
import fs from 'fs/promises';
mock.method(fs, 'readFile', async () => fileContent);
  • Async and sync functions called multiple times can be mocked with different values using context.mock.fn and mockedFunction.mock.mockImplementationOnce.
describe('mocking same method multiple times with different values', () => {
it('should resolve mocked values', async (context) => {
const firstValue = 2;
const secondValue = 3;
const calculateMock = context.mock.fn(calculationService.calculate);
calculateMock.mock.mockImplementationOnce(async () => firstValue, 0);
calculateMock.mock.mockImplementationOnce(async () => secondValue, 1);
const firstResult = await calculateMock();
const secondResult = await calculateMock();
assert.equal(firstResult, firstValue);
assert.equal(secondResult, secondValue);
});
});
  • To assert called arguments for a spy, use mockedFunction.mock.calls[0] value.
mock.method(calculationService, 'calculate');
await calculateSomething(calculationService, firstValue, secondValue);
const call = calculationService.calculate.mock.calls[0];
assert.deepEqual(call.arguments, [firstValue, secondValue]);
  • To assert skipped call for a spy, use mockedFunction.mock.calls.length value.
mock.method(calculationService, 'calculate');
assert.equal(calculationService.calculate.mock.calls.length, 0);
  • To assert how many times mocked function is called, use mockedFunction.mock.calls.length value.
mock.method(calculationService, 'calculate');
calculationService.calculate(3);
calculationService.calculate(2);
assert.equal(calculationService.calculate.mock.calls.length, 2);
  • To assert called arguments for the exact call when a mocked function is called multiple times, an assertion can be done using mockedFunction.mock.calls[index] and call.arguments values.
const calculateMock = context.mock.fn(calculationService.calculate);
calculateMock.mock.mockImplementationOnce((a) => a + 2, 0);
calculateMock.mock.mockImplementationOnce((a) => a + 3, 1);
calculateMock(firstValue);
calculateMock(secondValue);
[firstValue, secondValue].forEach((argument, index) => {
const call = calculateMock.mock.calls[index];
assert.deepEqual(call.arguments, [argument]);
});

Running TypeScript tests

Node.js can run .ts test files natively with node --test. Since Node 22.18 / 23.6, type stripping is enabled by default for erasable TypeScript syntax (type annotations, interfaces, import type, etc.).

{
"type": "module",
"scripts": {
"test": "node --test",
"test:ts": "node --test ./src/**/*.{spec,test}.ts"
}
}

Requires Node >= 22.18 (or >= 23.6). Node strips types but does not type-check - run tsc --noEmit separately if you want type checking.

For non-erasable features (enums, namespaces with runtime code, parameter properties), use a loader like tsx (node --import=tsx --test ...) or compile with tsc first.

Demo

Runnable tests for each section live in the node-test-spies-mocking-demo folder. Get access via code demos.

Async API documentation 101

May 21, 2023

Async API documentation is used for documenting events in event-driven systems, like Kafka events. All of the event DTOs are stored in one place. It supports YAML and JSON formats.

It contains information about channels and components. Channels and components are defined with their messages and DTO schemas, respectively.

{
"asyncapi": "2.6.0",
"info": {
"title": "Events docs",
"version": "1.0.0"
},
"channels": {
"topic_name": {
"publish": {
"message": {
"schemaFormat": "application/vnd.oai.openapi;version=3.0.0",
"payload": {
"type": "object",
"properties": {
"counter": {
"type": "number"
}
},
"required": ["counter"]
}
}
}
}
},
"components": {
"schemas": {
"EventDto": {
"type": "object",
"properties": {
"counter": {
"type": "number"
}
},
"required": ["counter"]
}
}
}
}

Autogeneration

Async API docs can be autogenerated by following multiple steps:

  • define DTOs and their required and optional fields with ApiProperty and ApiPropertyOptional decorators (from the @nestjs/swagger package), respectively
  • generate OpenAPI docs from the defined DTOs
  • parse and reuse component schemas from generated OpenAPI documentation to build channel messages and component schemas for Async API docs

Validation

Use AsyncAPI Studio to validate the written specification.

Preview

There are multiple options

  • AsyncAPI Studio

  • VSCode extension asyncapi-preview, open the command palette, and run the Preview AsyncAPI command.

UI generation

  • Install @asyncapi/cli and corresponding template package (e.g., @asyncapi/html-template, @asyncapi/markdown-template)
  • Update package.json with scripts
{
"scripts": {
// ...
"generate-docs:html": "asyncapi generate fromTemplate ./asyncapi/asyncapi.json @asyncapi/html-template --output ./docs/html",
"generate-docs:markdown": "asyncapi generate fromTemplate ./asyncapi/asyncapi.json @asyncapi/markdown-template --output ./docs/markdown"
}
}

Linting JavaScript codebase with Eslint

April 5, 2023

Linting represents static code analysis based on specified rules. Please include it in the CI pipeline.

Setup

Run the following commands to generate the linter configuration using the eslint package.

npm init -y
npm init @eslint/config

Below is an example of the configuration. Some rules can be ignored or suppressed as warnings, ignore the files using ignores field.

// eslint.config.js
import globals from 'globals';
import pluginJs from '@eslint/js';
export default [
{ languageOptions: { globals: globals.node } },
pluginJs.configs.recommended,
{
ignores: ['dist/**/*.js']
},
{
rules: {
'no-console': ['off'],
'no-unused-vars': ['warn']
}
}
];

Linting

Configure and run the script with the npm run lint command. Some errors can be fixed automatically with the --fix option.

// package.json
{
"scripts": {
// ...
"lint": "eslint .",
"lint:fix": "npm run lint -- --fix"
}
}

Demo

Sample codebase for this post lives in the linting-eslint-demo folder. Get access via code demos.

Migrating Node.js app from Heroku to Fly.io

April 1, 2023

I recently migrated the Node.js app from Heroku to Fly.io, mainly due to reduced costs.

This blog post will cover the necessary steps in the migration process.

Prerequisites

  • Heroku app running

  • Use the exact versions for dependencies and dev dependencies in package.json so installation and build steps can pass successfully

  • Use the same Node.js version in Dockerfile, package.json, and GitHub Actions workflow

  • Use API gateway or custom domain for the service so web apps and mobile apps don't get affected by changing the URL of the service

Migration steps

  • Migrate environment variables and secrets

  • Migrate the Postgres database with the following commands (the ssl field in database configuration options is not needed)

fly secrets set HEROKU_DATABASE_URL=$(heroku config:get DATABASE_URL)
fly ssh console
apt update && apt install postgresql-client
pg_dump -Fc --no-acl --no-owner -d $HEROKU_DATABASE_URL | pg_restore --verbose --clean --no-acl --no-owner -d $DATABASE_URL
exit
fly secrets unset HEROKU_DATABASE_URL
  • Migrate the Redis database if it's used

  • Include the deployment step in the GitHub Actions workflow

LLM integration with OpenAI Completions API

March 19, 2023

ChatGPT is a large language model (LLM) that understands and processes human prompts to produce helpful responses. OpenAI exposes models through the Chat Completions API. The official openai npm package is the practical way to call it from Node.js. This post covers common patterns beyond a single user message.

For the newer Responses API (web search, instructions, and input), see the dedicated post.

Prerequisites

  • OpenAI account
  • Generated API key
  • Enabled billing
  • Node.js version 26
  • openai package installed (npm i openai)
  • For Markdown output: marked, dompurify, and jsdom (npm i marked dompurify jsdom)

Client setup

Create a client with your API key (read from the environment in production).

import OpenAI from 'openai';
const client = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });

For OpenRouter specifically, see the OpenRouter integration post. The same SDK can target OpenAI-compatible gateways by setting baseURL and apiKey:

const client = new OpenAI({
apiKey: process.env.LLM_API_KEY,
baseURL: 'https://your-gateway.example/v1',
});

Azure OpenAI uses AzureOpenAI instead. Many third-party hosts implement Chat Completions, so this API is a common integration path.

Basic integration

Send a user message and read the assistant reply from choices[0].message.content.

const completion = await client.chat.completions.create({
model: 'gpt-5.5',
messages: [
{ role: 'user', content: 'Write a one-sentence bedtime story about a unicorn.' },
],
});
console.log(completion.choices[0].message.content);

System prompt

Add a system message (or developer on newer models) before the user message to set tone, format, and role.

const completion = await client.chat.completions.create({
model: 'gpt-5.5',
messages: [
{ role: 'system', content: 'Reply in one short sentence. Use plain language.' },
{ role: 'user', content: 'Explain what an LLM is.' },
],
});
console.log(completion.choices[0].message.content);

Few-shot prompting

Include prior user and assistant turns in messages, then the new user question. Keep task rules in the system message.

const completion = await client.chat.completions.create({
model: 'gpt-5.5',
messages: [
{
role: 'system',
content:
'Classify sentiment as exactly one word: positive, negative, or neutral.',
},
{ role: 'user', content: 'I love this!' },
{ role: 'assistant', content: 'positive' },
{ role: 'user', content: 'This is awful.' },
{ role: 'assistant', content: 'negative' },
{ role: 'user', content: 'It is fine I guess.' },
],
});
console.log(completion.choices[0].message.content);

Streaming

Set stream: true and read incremental text from choices[0].delta.content.

const stream = await client.chat.completions.create({
model: 'gpt-5.5',
messages: [{ role: 'user', content: 'List three colors.' }],
stream: true,
});
process.stdout.write('[stream] ');
for await (const chunk of stream) {
const delta = chunk.choices[0]?.delta?.content;
if (delta) {
process.stdout.write(delta);
}
}
process.stdout.write('\n');

Structured output with JSON schema

Use response_format with type: 'json_schema' and strict: true so the model returns JSON matching your schema.

const completion = await client.chat.completions.create({
model: 'gpt-5.5',
messages: [
{
role: 'user',
content: 'The film Inception was directed by Christopher Nolan.',
},
],
response_format: {
type: 'json_schema',
json_schema: {
name: 'movie_summary',
strict: true,
schema: {
type: 'object',
properties: {
title: { type: 'string' },
director: { type: 'string' },
},
required: ['title', 'director'],
additionalProperties: false,
},
},
},
});
const data = JSON.parse(completion.choices[0].message.content);
console.log(data.title, data.director);

For typed parsing with Zod, you can use client.chat.completions.parse() instead of JSON.parse.

Markdown output to HTML

Ask for Markdown in the system message, then convert the assistant reply to HTML and sanitize before rendering.

import { marked } from 'marked';
import { JSDOM } from 'jsdom';
import DOMPurify from 'dompurify';
const purify = DOMPurify(new JSDOM('').window);
const completion = await client.chat.completions.create({
model: 'gpt-5.5',
messages: [
{
role: 'system',
content: 'Reply in Markdown only. Use a heading and a short bullet list.',
},
{ role: 'user', content: 'Explain what an LLM is in three bullet points.' },
],
});
const markdown = completion.choices[0].message.content;
const html = marked.parse(markdown);
const safeHtml = purify.sanitize(html);

Always run DOMPurify.sanitize on model-generated HTML. The model can emit unsafe markup; sanitization strips scripts and other dangerous content.

Demo

Runnable scripts for each section live in the openai-chat-completions-api-demo folder. Get access via code demos.

Node.js built-in module functions as Promises

February 28, 2023

Node.js provides asynchronous methods for fs, dns, stream, and timers modules that return Promises.

const {
createWriteStream,
promises: { readFile }
} = require('fs');
const dns = require('dns/promises');
const stream = require('stream/promises');
const timers = require('timers/promises');
const sleep = timers.setTimeout;
const SLEEP_TIMEOUT_MS = 2000;
(async () => {
const fileName = 'test-file';
const writeStream = createWriteStream(fileName, {
autoClose: true,
flags: 'w'
});
await stream.pipeline('some text', writeStream);
await sleep(SLEEP_TIMEOUT_MS);
const readFileResult = await readFile(fileName);
console.log(readFileResult.toString());
const lookupResult = await dns.lookup('google.com');
console.log(lookupResult);
})();

Use the promisify function to convert other callback-based functions to Promise-based.

const crypto = require('crypto');
const { promisify } = require('util');
const randomBytes = promisify(crypto.randomBytes);
const RANDOM_BYTES_LENGTH = 20;
(async () => {
const randomBytesResult = await randomBytes(RANDOM_BYTES_LENGTH);
console.log(randomBytesResult);
})();

Error tracking with Sentry

February 14, 2023

Error tracking and alerting are crucial in the production environment, proactively fixing the errors leads to a better user experience. Sentry is one of the error tracking services, and it provides alerting for unhandled exceptions. You should receive an email when something wrong happens.

Sentry issues show the error stack trace, device, operating system, and browser information. The project dashboard shows an unhandled exception once it's thrown. This post covers the integration of several technologies with Sentry.

Node.js

  • Create a Node.js project on Sentry

  • Install the package

npm i @sentry/node
  • Run the following script
const Sentry = require('@sentry/node');
Sentry.init({
dsn: SENTRY_DSN
});
test();

Next.js

  • Create a Next.js project on Sentry (version 13 is not yet supported)

  • Run the following commands for the setup

npm i @sentry/nextjs
npx @sentry/wizard -i nextjs

Gatsby

  • Create a Gatsby project on Sentry

  • Install the package

npm i @sentry/gatsby
  • Add plugin in Gatsby config
module.exports = {
plugins: [
// ...
{
resolve: '@sentry/gatsby',
options: {
dsn: SENTRY_DSN
}
}
]
};

React Native

  • Create a React Native project on Sentry

  • Run the following commands for the setup

npm i @sentry/react-native
npx @sentry/wizard -i reactNative -p android

Logging practices

February 7, 2023

This post covers some logging practices for the back-end (Node.js) apps.

  • Avoid putting unique identifiers (e.g., user id) within the message. A unique id will produce a lot of different messages with the same context. Use it as a message parameter.

  • Use the appropriate log level for the message. There are multiple log levels

    • info - app behavior, don't log every single step
    • error - app processing failure, something that needs to be fixed
    • debug - additional logs needed for troubleshooting
    • warning - something unexpected happened (e.g., third-party API fails)
    • fatal - app crash, needs to be fixed as soon as possible

Don't use the debug logs on production. Put log level as an environment variable.

  • Stream logs to the standard output in JSON format so logging aggregators (Graylog, e.g.) can collect and adequately parse them

  • Avoid logging any credentials, like passwords, auth tokens, etc.

  • Put correlation ID as a message parameter for tracing related logs.

  • Use a configurable logger like pino

const pino = require('pino');
const logger = pino({
level: process.env.LOG_LEVEL || 'info',
redact: {
paths: ['token'],
remove: true
}
});
logger.info({ someId: 'id' }, 'Started the app...');
const correlationId = request.headers['correlation-id'] || uuid.v4();
logger.debug(
{ data: 'some data useful for debugging', correlationId },
'Sending the request...'
);

Integration testing Node.js apps

January 25, 2023

Integration testing means testing a component with multiple sub-components and how they interact. Some sub-components can be external services, databases, and message queues.

External services are running, but their business logic is mocked based on received parameters (request headers, query parameters, etc.). Databases and message queues are spun up using test containers.

This post covers testing service as a component and its API endpoints. This approach can be used with any framework and language. NestJS and Express are used in the examples below.

API endpoints

Below is the controller for two endpoints. First communicates with an external service and retrieves some data based on the sent parameter. The second one retrieves the data from the database.

// users.controller.ts
@Controller('users')
export class UsersController {
constructor(private userService: UsersService) {}
@Get()
async getAll(@Query('type') type: string) {
return this.userService.findAll(type);
}
@Get(':id')
async getById(@Param('id', new ParseUUIDPipe()) id: string) {
return this.userService.findById(id);
}
}

External dependencies

External service is mocked to send data based on the received parameter.

export const createDummyUserServiceServer = async (): Promise<DummyServer> => {
return createDummyServer((app) => {
app.get('/users', (req, res) => {
if (req.query.type !== 'user') {
return res.status(403).send('User type is not valid');
}
res.json(usersResponse);
});
});
};

Tests setup

Tests for endpoints can be split into two parts. The first is related to the external dependencies setup.

The example below creates a mocked service and spins up the database using test containers. The environment variables are set for before mentioned dependencies, and the leading service starts running.

The database is cleaned before every test run. External dependencies (mocked service and database) are closed after tests finish.

// test/users.spec.ts
describe('UsersController (integration)', () => {
let app: INestApplication;
let dummyUserServiceServerClose: () => void;
let postgresContainer: StartedTestContainer;
let usersRepository: Repository<UsersEntity>;
const databaseConfig = {
databaseName: 'nestjs-starter-db',
databaseUsername: 'user',
databasePassword: 'some-r4ndom-pasS',
databasePort: 5432
};
beforeAll(async () => {
const dummyUserServiceServer = await createDummyUserServiceServer();
dummyUserServiceServerClose = dummyUserServiceServer.close;
postgresContainer = await new GenericContainer('postgres:15-alpine')
.withEnvironment({
POSTGRES_USER: databaseConfig.databaseUsername,
POSTGRES_PASSWORD: databaseConfig.databasePassword,
POSTGRES_DB: databaseConfig.databaseName
})
.withExposedPorts(databaseConfig.databasePort)
.start();
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule]
})
.overrideProvider(ConfigService)
.useValue({
get: (key: string): string => {
const map: Record<string, string | undefined> = process.env;
map.USER_SERVICE_URL = dummyUserServiceServer.url;
map.DATABASE_HOSTNAME = postgresContainer.getHost();
map.DATABASE_PORT = `${postgresContainer.getMappedPort(
databaseConfig.databasePort
)}`;
map.DATABASE_NAME = databaseConfig.databaseName;
map.DATABASE_USERNAME = databaseConfig.databaseUsername;
map.DATABASE_PASSWORD = databaseConfig.databasePassword;
return map[key] || '';
}
})
.compile();
app = moduleFixture.createNestApplication();
usersRepository = app.get(getRepositoryToken(UsersEntity));
await app.init();
});
beforeEach(async () => {
await usersRepository.delete({});
});
afterAll(async () => {
await app.close();
dummyUserServiceServerClose();
await postgresContainer.stop();
});
// ...
});

Tests

The second part covers tests for the implemented endpoints. The first test suite asserts retrieving data from the external service based on the sent type as a query parameter.

// test/users.spec.ts
describe('/users (GET)', () => {
it('should return list of users', async () => {
return request(app.getHttpServer())
.get('/users?type=user')
.expect(HttpStatus.OK)
.then((response) => {
expect(response.body).toEqual(usersResponse);
});
});
it('should throw an error when type is forbidden', async () => {
return request(app.getHttpServer())
.get('/users?type=admin')
.expect(HttpStatus.FORBIDDEN);
});
});

The second test suite asserts retrieving the data from the database.

// test/users.spec.ts
describe('/users/:id (GET)', () => {
it('should return found user', async () => {
const userId = 'b618445a-0089-43d5-b9ca-e6f2fc29a11d';
const userDetails = {
id: userId,
firstName: 'tester'
};
const newUser = await usersRepository.create(userDetails);
await usersRepository.save(newUser);
return request(app.getHttpServer())
.get(`/users/${userId}`)
.expect(HttpStatus.OK)
.then((response) => {
expect(response.body).toEqual(userDetails);
});
});
it('should return 404 error when user is not found', async () => {
const userId = 'b618445a-0089-43d5-b9ca-e6f2fc29a11d';
return request(app.getHttpServer())
.get(`/users/${userId}`)
.expect(HttpStatus.NOT_FOUND);
});
});

Demo

Runnable NestJS integration tests for this post live in the integration-testing-nodejs-demo folder. Get access via code demos.

2022

Debugging Node.js apps with Visual Studio Code debugger

December 28, 2022

Rather than doing it with console logs, debugging with a debugger and breakpoints is recommended. VSCode provides a built-in debugger for JavaScript-based apps.

This post covers configuring and running a debugger for various Node.js apps in VSCode.

Configuration basics

VSCode configurations can use runtime executables like npm and ts-node. The executables mentioned above should be installed globally before running the configurations.

A configuration can use the program field to point to the binary executable package inside node_modules directory to avoid installing packages globally.

Runtime executables and programs can have arguments defined in runtimeArgs and args fields, respectively.

A configuration can have different requests:

  • attach - the debugger is attached to the running process
  • launch - the debugger launches a new process and wraps it

There are multiple configuration types:

  • node - runs the program from program field, and logs are shown in debug console
  • node-terminal - runs the command from command field and shows the logs in the terminal

The configuration file is .vscode/launch.json. The selected configuration on the Run and Debug tab is used as the default one.

Launch configs

Node

Below are examples of configurations for running Node processes with the debugger.

{
"version": "0.2.0",
"configurations": [
// ...
{
"name": "Launch script in debug console",
"program": "index.js", // update entry point
"request": "launch",
"type": "node",
"skipFiles": ["<node_internals>/**"]
},
{
"name": "Launch script in the terminal",
"command": "node index.js", // update entry point
"request": "launch",
"type": "node-terminal",
"skipFiles": ["<node_internals>/**"]
}
]
}

Running npm scripts in debug mode

The debugger launches the following script in both configurations, dev in this case.

{
"version": "0.2.0",
"configurations": [
// ...
{
"name": "Launch dev script in debug console",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "dev"],
"request": "launch",
"type": "node",
"skipFiles": ["<node_internals>/**"]
},
{
"name": "Launch dev script in the terminal",
"command": "npm run dev",
"request": "launch",
"type": "node-terminal"
}
]
}

ts-node

The following configurations will wrap the debugger around ts-node entry point.

{
"version": "0.2.0",
"configurations": [
// ...
{
"name": "Launch ts-node script in debug console",
"program": "node_modules/.bin/ts-node",
"args": ["index.ts"], // update entry point
"request": "launch",
"type": "node",
"skipFiles": ["<node_internals>/**"]
},
{
"name": "Launch ts-node script in the terminal",
"command": "ts-node index.ts", // update entrypoint
"request": "launch",
"type": "node-terminal",
"skipFiles": ["<node_internals>/**"]
}
]
}

@babel/node

The following configuration will wrap the debugger around babel-node entry point.

{
"version": "0.2.0",
"configurations": [
// ...
{
"name": "Launch babel-node script in debug console",
"program": "node_modules/.bin/babel-node",
"args": ["src"], // update entry point
"request": "launch",
"type": "node",
"skipFiles": ["<node_internals>/**"]
}
]
}

Nodemon

Add new configuration with Run Add configuration option, select Node.js: Nodemon Setup.

Update program field to point to the nodemon executable package, and add arguments with args field to point to the entry point.

{
"version": "0.2.0",
"configurations": [
// ...
{
"name": "Launch nodemon script in debug console",
"program": "node_modules/.bin/nodemon",
"args": ["-r", "dotenv/config", "--exec", "babel-node", "src/index.js"], // update entry point
"request": "launch",
"type": "node",
"console": "integratedTerminal",
"internalConsoleOptions": "neverOpen",
"restart": true,
"skipFiles": ["<node_internals>/**"]
}
]
}

Mocha

Add a new configuration, and choose Node.js: Mocha Tests configuration.

Replace tdd with bdd as u parameter and program field to point to the mocha executable package.

{
"version": "0.2.0",
"configurations": [
// ...
{
"name": "Launch mocha tests",
"program": "node_modules/.bin/mocha",
"args": ["-u", "bdd", "--timeout", "999999", "--colors", "test"],
"request": "launch",
"type": "node",
"internalConsoleOptions": "openOnSessionStart",
"skipFiles": ["<node_internals>/**"]
}
]
}

Attach configs

Auto Attach should be activated in settings with the With Flag value. In that case, auto-attaching is done when --inspect flag is given.

The debugger should be attached when some of the following scripts are executed.

{
// ...
"scripts": {
// ...
"start:debug": "node --inspect index.js" // update entry point
}
}
Jest
{
// ...
"scripts": {
// ...
"test:debug": "node --inspect -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand"
}
}
NestJS
{
// ...
"scripts": {
// ...
"start:debug": "nest start --debug --watch"
}
}

Debugging basics

During the debugging, the variables tab shows local variables. The step over option goes to the following statement in the codebase, while step into option goes deeper into the current statement.

Log points can add logs in debug console when a certain part of the codebase is executed without pausing the process.