ReAct Agent
In this tutorial, we will build a simple Reasoning + Action Agent and augment it with some tools. This will be a full stack agent with a Chat UI. We will demonstrate writing the agent nodes manually as well with prebuilt agent helpers.
In both cases, we are using helpers for canonical shapes and common tasks like converting to provider shapes, building tool results, etc. It is important to stress that this is not required; it is merely for convenience.
For a deeper dive into all the type definitions, check out Learn/Defining Nodes
What We Are Building
A ReAct agent built for web search with a chat UI that can display retrieved images. We will use Tavily search API for the search functionality.
This is the searchAgent that ships with the template. You can clone it from https://github.com/cascaide-ts/cascaide-nextjs-starter.git .
Learning Outcomes
- What is a ReAct agent and how do you represent it as a graph?
- How to define the necessary nodes and configurations/workflow graph objects?
- How to set up a tool node for parallel tool calling (though in this example we only set up the search tool, its easily extensible and executes parallelly by default).
Prerequisites
We assume that
- you have set up a basic app as per the quickstart guide
- you have read the thinking about AI orchestration and basic concepts guides from Learn section
Flow of Implementation
- Step 1: Identify the nodes required
- Step 2: Define the nodes
- Step 3: Set up the graphs and configurations
- Step 4: Provide the configurations to
workflowHandlerandWorkflowProvider
This concludes building a basic ReAct agent.
What is a ReAct Agent?
ReAct Agents combine language models with tools to create systems that can reason about tasks, execute tools to take actions, and iteratively work towards solutions.
So they primarily have two parts
- A function that calls an LLM
- A function that executes tools
We will call them searchAgentNode and searchToolNode respectively.
Identifying the Required Nodes
On the UI side, we need a chat UI that can initiate graph execution and read the messages as they flow in. It should also be able to display the images from URLs.
Let’s call this Chat, a UI node that runs on the client. Each chat session will be a cascade activated over and over again.
On the server side, we need two nodes. One that calls the llm (searchAgentNode) and one that executes tools (searchToolNode).
Therefore, the flow will be
- the app starts with
ChatUI node mounted by default - When user sends a message,
ChatspawnssearchAgentNodewith acascadeId searchAgentNodeoutputs a tool call and spawnstoolNodesearchToolNodeoutputs the tool results and spawnssearchAgentNodesearchAgentNodegives the answer and the cascade terminates- all this while
Chatis observing the messages - for the next turn, we simply activate the
searchAgentNodeagain with the samecascadeId
So, we need three nodes
Chatnode, runs on clientsearchAgentNode, runs on serversearchToolNode, runs on server
Chat node itself sits outside of the cascades, it only observes and initiates cascade executions.
Define the Nodes
Chat
For Chat, we will use the prebuilt Chat UI. It is already in the template, check out UI/UX section for more information.
You can switch between agents by spawning the right node. Here, the node we spawn will be searchAgentNode.
This is the part of Chat.tsx that handles agent spawning. You switch agents by changing which node you spawn.
In Lite mode, you would send the full updatedHistory.
const handleSendMessage = useCallback(async (message: string) => {
if (!message.trim() || isProcessing || !userId) return;
const newUserMessage: CanonicalMessage = {
role: 'user',
content: message.trim(),
};
setPendingUserMessage(newUserMessage);
setInput('');
const updatedHistory = [...conversationMessages, newUserMessage];
const spawns: Spawns = {
[selectedAgent]: { //`searchAgentNode` in this case
cascadeId: chatId,
history: [newUserMessage],
userId,
},
};Agent Nodes
We will now define searchAgentNode and searchToolNode, first using the prebuilt helpers and then manually.
Prebuilt Agent Helpers
import { tavily } from '@tavily/core';
import { createReactAgent } from '@cascaide-ts/helpers';
const tvly = tavily({ apiKey: process.env.TAVILY_API_KEY });
const SYSTEM_PROMPT = `
You are an expert technical AI assistant equipped with web search capabilities.
You have access to the following tools:
1. search_tool
Use for general web searches, finding documentation, or retrieving facts.
Pass a clear, concise query.
`.trim();
export const nodes = {
...createReactAgent('search', {
provider: 'openai',
model: 'gpt-5.4-nano',
systemPrompt: SYSTEM_PROMPT,
isStreaming: true,
env: 'server',
tools: [
{
name: 'search_tool',
description: 'Searches the web using the input query.',
parameters: {
type: 'object' as const,
properties: {
query: { type: 'string', description: 'The natural language query' },
},
required: ['query'],
},
execute: async (args: Record<string, any>) => {
const { query } = args;
const res = await tvly.search(query, {
searchDepth: 'basic',
maxResults: 5,
topic: 'general',
});
return res.results;
},
},
],
}).nodes,
};This will give you nodes in the format your server side graph expects, and you can just spread it in. We will show how to do this in the last section that discusses registering the nodes.
Manually Writing Nodes
searchAgentNode
type SearchAgentPrepOut = {
history: any[]; //Todo : type should be openaiMessage[], as we convert before passing
cascadeId: string;
};
export const searchAgentNode: StreamingServerNodeDefinition<SearchAgentPrepOut> = {
name: 'searchAgentNode',
isUINode: false,
env: 'server',
isStreaming: true,
async prep(cascadeContext: WorkflowContext, initialContext: any): Promise<SearchAgentPrepOut> {
const cascadeId = initialContext.cascadeId;
const dataArray = cascadeContext[cascadeId];
const canonicalHistory = dataArray.flatMap((item: any) => item.history || []);
/*
This history is in canonical shape (unless another canonical shape is forced
by using a custom mapper, which are not doing in this example).
So, we will convert to provider specific shape, using openai here.
*/
const history = toProviderHistory('openai', canonicalHistory);
return { history, cascadeId };
},
async exec(prepOutput: SearchAgentPrepOut, controller?: CascadeController): Promise<StreamConfig> {
const { history } = prepOutput;
/*
We are not using the controller in this example. Every `exec` gets one, and it allows you
to control the graph execution from within nodes (delegation, recursion, pausing the node for some condition, etc.)
See: Recursive ReAct Agent for concrete examples
*/
const systemPrompt = `
You are an expert technical AI assistant equipped with web search capabilities.
You have access to the following tools:
1. search_tool
Use for general web searches, finding documentation, or retrieving facts.
Pass a clear, concise query.
`.trim();
/*
We will construct the tools here. We will write the tool definitions in `ToolParam` shape and
convert it into provider specific shape using `buildTools` helper.
*/
const searchToolParams: ToolParam[] = [
{
name: 'search_tool',
description: 'Searches the web using the input query.',
parameters: {
type: 'object' as const,
properties: {
query: { type: 'string', description: 'The natural language query' },
},
required: ['query'],
},
},
];
const openaiTools = buildTools('openai', searchToolParams);
const {stream, provider} = await callLLM('openai', 'gpt-5.4-nano', systemPrompt, history, geminiTools, true);
return { stream, provider };
},
async post(execOutput: StreamingExecOutput): Promise<PostResult> {
const { assistantMessage, uiAssistantMessage, history, cascadeId, userId } = execOutput;
const pendingToolCalls = assistantMessage.tool_calls ?? [] as CanonicalToolCall[]
/*
if you had a filter set up in `StreamConfig`, the two `assistantMessage`s would differ.
By passing an optional `uiUpdate`, you can censor what the client side sees even when hydrating
the cascade in new user sessions. Otherwise, the client would be censored only during the stream,
and upon hydrating the cascade later, the full sensitive `Update` will hit the client.
*/
const isDifferent = uiAssistantMessage !== undefined &&
JSON.stringify(assistantMessage) !== JSON.stringify(uiAssistantMessage);
return {
updates: {
[cascadeId as string]: {
history: [assistantMessage as CanonicalMessage],
status: pendingToolCalls.length > 0 ? 'calling_tool' : 'complete',
lastUpdate: Date.now(),
//you can add more fields if you want, it will be written to the cascade state
// what's mandatory is history and status
},
} as Updates,
...(isDifferent ? {
uiUpdates: {
[cascadeId as string]: { history: [uiAssistantMessage as CanonicalMessage], status: pendingToolCalls.length > 0 ? 'calling_tool' : 'complete' },
},
} : {}),
spawns: pendingToolCalls.length > 0
? { // we spawn the hardcoded searchToolNode if there are tool calls
['searchToolNode']: {
history: [assistantMessage],
toolCallsToExecute: pendingToolCalls,
cascadeId,
},
} as Spawns
: undefined,
/*
Some behaviours worth noting when spawning nodes
- no cascadeId => this spawn is not part of a cascade, ephemeral, no persistence.
Useful when you need one of fire and forget nodes, that do not need to be persisted.
They are ghosts -> cannot write to state, but could spawn new nodes.
This is mostly used to spawn observer UI nodes to watch cascades and/or initiate new ones.
- no userId => safe to omit. the new node will inherit from the parent.
Then why is it available in `initialContext` and `post`?
userIds enter the nodes because you might need it for retrieving memories, or other scoped operations.
*/
};
}
};
In the prep step we will select the history from the cascade context by flatmapping context[cascadeId] array. We then convert it into provider specific shape.
const cascadeId = initialContext.cascadeId;
const dataArray = cascadeContext[cascadeId];
const canonicalHistory = dataArray.flatMap((item: any) => item.history || []);
const history = toProviderHistory('openai', canonicalHistory);
initialContext contains the data you spawn the node with via the addActiveNode hook. However, history will automatically be added to the context, so we can just select the full history directly from the WorkflowContext.
In the exec step, we will use this selected history to make the llm call. Since this is a streaming node, we need to return a StreamConfig object.
If it is a streaming node, what’s passed into the post step as execOutput is
type StreamingExecOutput = {
assistantMessage: any;
uiAssistantMessage?: any;
cascadeId?: string;
history: any[];
userId?: string;
}If it is not a streaming node, execOutput is whatever you manually pass from the prep step.
In the post step, we use this data to update the state and spawn the next nodes. To decide whether a searchToolNode
should be spawned, we check the assistantMessage for tool calls.
searchToolNode
type SearchToolPrepOut = {
toolCallsToExecute : CanonicalToolCall[];
cascadeId: string;
}
type SearchToolExecOut = {
results: CanonicalMessage[];
cascadeId: string;
}
import { tavily } from "@tavily/core";
const tvly = tavily({ apiKey: process.env.TAVILY_API_KEY });
export const searchToolNode: ServerNodeDefinition<SearchToolPrepOut, SearchToolExecOut> = {
name: 'searchToolNode',
isUINode: false,
env: 'server',
isStreaming: false,
async prep(cascadeContext: WorkflowContext, initialContext: any): Promise<SearchToolPrepOut> {
const { toolCallsToExecute, cascadeId } = initialContext
/*
We simply pass what the `exec` step requires, not much preparation needed.
*/
return { toolCallsToExecute: toolCallsToExecute ?? [], cascadeId};
},
async exec(prepOutput: SearchToolPrepOut, controller?: CascadeController ) : Promise<SearchToolExecOut> {
const {toolCallsToExecute, cascadeId} = prepOutput;
const results = await executeToolCalls(toolCallsToExecute, {
search_tool: async ({ query }) => { return await tvly.search(query, {
searchDepth: "basic",
maxResults: 5,
topic: "general"
});}
}, 'openai');
const toolResultMessages = results.map(({ toolResult }) => toolResult);
return {results: toolResultMessages, cascadeId}
},
async post(execOutput: SearchToolExecOut) : Promise<PostResult> {
const {results, cascadeId} = execOutput;
return {
updates: {
[cascadeId as string]: {
history: results,
status: 'complete',
//you can add more fields if you want, it will be written to the cascade state
// what's mandatory is history and status
},
} as Updates,
spawns :{
['searchAgentNode'] : {
history: results,
cascadeId,
},
} as Spawns
}
}
}We will also define a simple helper function for actually executing the tools.
export type ManualToolExecute = (args: Record<string, any>) => Promise<unknown>;
export type ManualToolExecuteMap = Record<string, ManualToolExecute>;
export async function executeToolCalls(
calls: CanonicalToolCall[],
executeMap: ManualToolExecuteMap,
provider: LLMProvider,
): Promise<{ toolCall:CanonicalToolCall; toolResult: CanonicalMessage }[]> {
return Promise.all(
calls.map(async (toolCall) => {
const executeFn = executeMap[toolCall.name];
if (!executeFn) {
return {
toolCall,
toolResult: buildErrorToolResultMessage(provider, toolCall, `Unknown tool: ${toolCall.name}`),
};
}
try {
const rawResult = await executeFn(toolCall.args);
return {
toolCall,
toolResult: buildToolResultMessage(provider, toolCall, rawResult),
};
} catch (err: any) {
return {
toolCall,
toolResult: buildErrorToolResultMessage(provider, toolCall, err.message),
};
}
})
);
}
In the prep step we simply isolate the toolCallsToExecute.
In the exec step, we run the tools using the helper we defined in parallel.
Finally, in the post step, we update the state with the tool results and spawn the searchAgentNode back up to continue.
With this our nodes are defined. We will move onto writing the graphs and configurations.
Defining Graphs and Configurations
Client Workflow Graph
Just the node meta data. Check out Defining Graphs and Configurations in Learn section.
import { ClientWorkflowGraph } from "@cascaide-ts/core";
export const clientWorkflowGraph: ClientWorkflowGraph = {
chat: { name: 'chat', isUINode: true, env: 'client' },
searchAgentNode: { name: 'searchAgentNode', isUINode: false, env: 'server' },
searchToolNode: { name: 'searchToolNode', isUINode: false, env: 'server' },
}Client Workflow Configuration
In addition to the graph, we set up the uiComponent registry.
import Chat from "@/components/cascaide-ui/chat/chat"
import { ClientWorkflowConfig } from '@cascaide-ts/react';
import { clientWorkflowGraph } from "./graph";
export const clientWorkflowConfig: ClientWorkflowConfig = {
clientWorkflowGraph: clientWorkflowGraph,
uiComponentRegistry: {
chat: Chat,
},
};Server Workflow Graph
We declare the node meta data and the prep, exec, post functions only for the server nodes.
import { searchAgentNode, searchToolNode } from '@/bubbles/searchAgent';
// or if using prebuilt helpers import { nodes as searchNodes } from '@/bubbles/searchAgent`;
export const serverWorkflowGraph: ServerWorkflowGraph = {
searchAgentNode,
searchToolNode,
// if using the prebuilt helpers spread the nodes in
// ...searchNodes
};Server Workflow Configuration
import { WorkflowHandlerConfig } from '@cascaide-ts/server-next'
import { PostgresPersistor } from '@cascaide-ts/postgres-js';
import { sql } from '@/lib/connection'; // or pg-lite
import { serverWorkflowGraph } from './graph';
const workflowpersistor = new PostgresPersistor(sql);
const MAX_EXECUTION_TIME = 100000;
const SAFE_BUFFER = 6000;
export const serverWorkflowConfig: WorkflowHandlerConfig = {
workflowGraph: serverWorkflowGraph,
persistor: workflowpersistor, // The user passes their PostgresPersistor here, undefined in Lite mode
maxExecutionTime: MAX_EXECUTION_TIME,
safeBuffer: SAFE_BUFFER
}For more info on the persistor, read QuickStart/Persistent Database.
For more info on the MAX_EXECUTION_TIME and SAFE_BUFFER read Deployment/NextJS.
Setting Up WorkflowHandler and WorkflowProvider
Action Endpoint
import { serverWorkflowConfig } from '@/graphs/server/config'
import { createWorkflowHandler } from '@cascaide-ts/server-next'
export const POST = createWorkflowHandler(serverWorkflowConfig);WorkflowProvider
'use client'
import { clientWorkflowConfig } from '@/graphs/client/config';
import { WorkflowProvider, WorkflowRenderer } from '@cascaide-ts/react';
export default function HomePage() {
return (
<WorkflowProvider
initialNodeId="chat_init"
initialNodeName="chat"
config={clientWorkflowConfig}
actionRelayEndpoint='/api/workflow/action'
persistenceEndpoint='/api/workflow/persistence' //not required in lite mode
>
<WorkflowRenderer />
</WorkflowProvider>
);
}We are assuming you have already set up the hydration and persistence endpoints as per the quickstart guide.
Now your Cascaide application is set up. Run it using npm run dev and open localhost 3000. Do not forget to set your OpenAI API key and Database URL (if not in Lite mode or not using the inbuilt pg-lite layer) for the persistent database in your environment variables.
When you open the app, Chat will be mounted by default. When you send a message, searchAgentNode will be spawned with your message.