Building Agents¶
FlowForge provides first-class support for LLM-powered agents that reason, plan, and act by calling tools in a loop. This guide covers the built-in agent node, custom agent creation, node-to-tool conversion, MCP integration, and the agent + human-approval pattern.
What Are Agents in FlowForge?¶
An agent in FlowForge is a workflow node that runs an iterative LLM loop:
- Send a prompt (plus context) to an LLM.
- If the LLM requests tool calls, execute them.
- Feed tool results back to the LLM.
- Repeat until the LLM produces a final response or
maxIterationsis reached.
Agents are nodes, not a separate primitive. They compose with the rest of the workflow system -- you can chain agents with data nodes, control flow, and other agents.
Using the Built-in Agent Node¶
The simplest way to add an agent to a workflow:
import { agentNode } from '@flowforgejs/nodes';
import { workflow } from '@flowforgejs/sdk';
const wf = workflow('customer-support')
.trigger({ type: 'event', event: 'ticket.created' })
.node('triage', agentNode, {
config: {
model: 'gpt-4o',
systemPrompt: `You are a support triage agent. Classify tickets by priority
and suggest an initial response. Use the available tools to look up
customer history and knowledge base articles.`,
maxIterations: 10,
temperature: 0.3,
},
input: (ctx) => ({
prompt: `Triage this support ticket: ${JSON.stringify(ctx.event.data)}`,
tools: [
{
name: 'search-kb',
description: 'Search the knowledge base for relevant articles',
parameters: { query: 'string' },
},
{
name: 'get-customer',
description: 'Look up customer information by email',
parameters: { email: 'string' },
},
],
}),
})
.build();
defineAgentNode() for Custom Agents¶
For agents with typed tools and structured output, use defineAgentNode():
import { z } from 'zod';
import { defineAgentNode } from '@flowforgejs/sdk';
const researchAgent = defineAgentNode({
name: 'custom/research-agent',
version: '1.0.0',
description: 'Research a topic using web search and page reading',
model: 'gpt-4o',
systemPrompt: `You are a thorough research assistant. Search for information,
read relevant pages, and produce a comprehensive summary with sources.`,
maxIterations: 20,
temperature: 0.2,
outputSchema: z.object({
summary: z.string(),
keyFindings: z.array(z.string()),
sources: z.array(
z.object({
url: z.string(),
title: z.string(),
relevance: z.number(),
}),
),
}),
tools: {
search: {
description: 'Search the web for a query',
inputSchema: z.object({ query: z.string() }),
handler: async (ctx, input) => {
// Use ctx.integrate or ctx.pull for actual search
return { results: [] };
},
},
readPage: {
description: 'Extract text content from a URL',
inputSchema: z.object({ url: z.string().url() }),
handler: async (ctx, input) => {
return { content: '', title: '' };
},
},
saveNote: {
description: 'Save a research note for later reference',
inputSchema: z.object({ topic: z.string(), note: z.string() }),
handler: async (ctx, input) => {
ctx.logger.info({ topic: (input as { topic: string }).topic }, 'Note saved');
return { saved: true };
},
},
},
});
How defineAgentNode() Works¶
Under the hood, defineAgentNode() creates a standard NodeDefinition with a handler that:
- Converts the
toolsrecord into Vercel AI SDK tool definitions. - Enters a loop: calls
ctx.ai.generateText()with the tools and prompt. - On each iteration, if the LLM makes tool calls, executes them via the tool handlers.
- When the LLM stops calling tools, attempts to parse the final text into the
outputSchemausingctx.ai.generateObject(). - If
maxIterationsis exceeded, produces a best-effort structured output.
Converting Nodes to Tools¶
Any existing NodeDefinition can be exposed as an agent tool. This is the core composability feature of FlowForge.
nodeAsAgentTool()¶
Converts a single node:
import { nodeAsAgentTool } from '@flowforgejs/engine';
import { postgresNode } from '@flowforgejs/nodes';
const dbTool = nodeAsAgentTool(postgresNode);
// dbTool.description = "Execute queries and mutations against a PostgreSQL database"
// dbTool.inputSchema = postgresNode.inputSchema
// dbTool.handler calls postgresNode.handler with the agent's context
nodesToAgentTools()¶
Converts multiple nodes at once:
import { nodesToAgentTools } from '@flowforgejs/engine';
import { httpNode, postgresNode, redisNode } from '@flowforgejs/nodes';
import { defineAgentNode } from '@flowforgejs/sdk';
const tools = nodesToAgentTools({
'http-request': httpNode,
'query-database': postgresNode,
'cache-lookup': redisNode,
});
const dataAgent = defineAgentNode({
name: 'custom/data-agent',
version: '1.0.0',
description: 'An agent that can query databases, call APIs, and manage caches',
model: 'gpt-4o',
systemPrompt: 'You are a data operations agent.',
tools,
outputSchema: z.object({ result: z.unknown() }),
});
The converted tools preserve:
- description -- used by the LLM to decide when to call the tool.
- inputSchema -- used by the LLM to generate valid parameters.
- handler -- the actual node execution logic.
MCP Integration for External Tools¶
Use the mcp-client node to connect agents to external MCP servers, providing access to tools outside the FlowForge ecosystem:
import { mcpClientNode, agentNode } from '@flowforgejs/nodes';
const wf = workflow('mcp-agent')
.trigger({ type: 'manual' })
// First, discover available tools
.node('discover-tools', mcpClientNode, {
config: {
transport: 'stdio',
command: 'npx',
args: ['-y', '@modelcontextprotocol/server-filesystem', '/workspace'],
},
input: () => ({ action: 'listTools' as const }),
})
// Then use the agent with those tools
.node('agent', agentNode, {
config: {
model: 'gpt-4o',
systemPrompt: 'You can read and write files in /workspace.',
maxIterations: 15,
},
input: (ctx) => ({
prompt: 'Organize the files in /workspace by type',
tools: (
ctx.steps as {
'discover-tools': { tools: Array<{ name: string; description: string }> };
}
)['discover-tools'].tools.map((t) => ({
name: t.name,
description: t.description,
parameters: {},
})),
}),
})
.build();
Agent + Human-Approval Pattern¶
For agents that perform high-stakes actions, combine them with the human-approval node:
import { agentNode, humanApprovalNode, slackNode } from '@flowforgejs/nodes';
const wf = workflow('safe-deploy-agent')
.trigger({ type: 'event', event: 'deploy.requested' })
// Agent plans the deployment
.node('plan', agentNode, {
config: {
model: 'gpt-4o',
systemPrompt: 'Plan the deployment. List what will change and any risks.',
maxIterations: 10,
},
input: (ctx) => ({
prompt: `Plan deployment for: ${JSON.stringify(ctx.event.data)}`,
}),
})
// Human reviews and approves
.node('approve', humanApprovalNode, {
config: {
defaultTimeout: 1_800_000,
autoApprove: process.env.NODE_ENV === 'development',
},
input: (ctx) => ({
action: 'Execute deployment plan',
details: (ctx.steps as { plan: unknown }).plan,
urgency: 'high' as const,
}),
})
// Only proceed if approved
.if('check-approval', {
condition: (ctx) => (ctx.steps as { approve: { approved: boolean } }).approve.approved,
then: [
[
'execute',
agentNode,
{
config: { model: 'gpt-4o', systemPrompt: 'Execute the deployment.', maxIterations: 20 },
input: (ctx) => ({
prompt: `Execute this plan: ${JSON.stringify((ctx.steps as { plan: unknown }).plan)}`,
}),
},
],
[
'notify-success',
slackNode,
{
config: { connectionId: 'slack-bot' },
input: () => ({
action: 'sendMessage' as const,
channel: '#deploys',
text: 'Deployment completed successfully.',
}),
},
],
],
else: [
[
'notify-rejected',
slackNode,
{
config: { connectionId: 'slack-bot' },
input: () => ({
action: 'sendMessage' as const,
channel: '#deploys',
text: 'Deployment was rejected by reviewer.',
}),
},
],
],
})
.build();
Auto-approve in dev, auto-reject in CI
Set autoApprove: true during development so agents run uninterrupted. Set autoReject: true in CI to ensure agents never block automated pipelines.
Example: Research Agent with Web Search + Filesystem¶
A complete example combining multiple tool types:
import { z } from 'zod';
import { defineAgentNode } from '@flowforgejs/sdk';
import { nodesToAgentTools } from '@flowforgejs/engine';
import { httpNode } from '@flowforgejs/nodes';
const researchAgent = defineAgentNode({
name: 'custom/full-research-agent',
version: '1.0.0',
description: 'Research agent with web search and file output',
model: 'gpt-4o',
systemPrompt: `You are a research assistant. Use web-search to find information,
then use save-report to write your findings to a file. Be thorough and cite sources.`,
maxIterations: 25,
temperature: 0.1,
outputSchema: z.object({
topic: z.string(),
summary: z.string(),
sources: z.array(z.string()),
reportPath: z.string(),
}),
tools: {
'web-search': {
description: 'Search the web and return results',
inputSchema: z.object({
query: z.string().describe('Search query'),
maxResults: z.number().default(5),
}),
handler: async (ctx, input) => {
const { query } = input as { query: string };
const result = await ctx.pull('http', {
method: 'GET',
url: `https://api.search.example/search?q=${encodeURIComponent(query)}`,
});
return result;
},
},
'read-url': {
description: 'Fetch and extract text from a URL',
inputSchema: z.object({ url: z.string().url() }),
handler: async (ctx, input) => {
const { url } = input as { url: string };
const result = await ctx.pull('http', { method: 'GET', url });
return result;
},
},
'save-report': {
description: 'Save the research report to a file',
inputSchema: z.object({
filename: z.string(),
content: z.string(),
}),
handler: async (ctx, input) => {
const { filename, content } = input as { filename: string; content: string };
ctx.logger.info({ filename }, 'Saving research report');
await ctx.push('filesystem', { path: `/reports/${filename}`, content });
return { saved: true, path: `/reports/${filename}` };
},
},
},
});
Agent timeouts
Agent nodes default to a 120-second timeout. For research-heavy agents that make many tool calls, increase this in the node config or at the workflow level.