Welcome back, future AI-powered frontend wizard! In our previous chapters, you’ve mastered the fundamentals of integrating AI models, handling streaming responses, and even dabbling in prompt engineering. Now, it’s time to elevate your skills and build something truly powerful: an agent-driven UI workflow.
This chapter marks a significant leap from simple AI interactions to orchestrating intelligent agents that can perform multi-step tasks, make decisions, and even use “tools” to achieve a goal, all managed and displayed directly within your React or React Native application. You’ll learn how to build a user interface that not only interacts with an agent but actively participates in its workflow, displaying its thought process, executing its requested actions, and providing a rich, interactive experience. By the end of this project, you’ll have deep confidence in designing and implementing UIs that empower users with intelligent automation.
To get the most out of this chapter, ensure you’re comfortable with:
- React’s core concepts: components, props, state (
useState,useEffect). - Asynchronous JavaScript:
async/await,Promises,fetchAPI. - Handling streaming data (as covered in previous chapters).
- Basic understanding of prompt engineering and how AI models respond.
Let’s build an intelligent assistant together!
Core Concepts: Orchestrating Agents from the Frontend
Before we dive into code, let’s understand what an “agent-driven UI workflow” truly means and how the frontend plays a pivotal role.
What is an Agent-Driven UI Workflow?
Imagine an AI that doesn’t just answer a question, but can actually do things. This is the essence of an agent. An AI agent is a system that can:
- Understand a Goal: Interpret a user’s request.
- Plan: Break down the goal into a sequence of steps.
- Execute: Take actions, often by using “tools” (e.g., calling an API, searching a database, running code).
- Observe: See the results of its actions.
- Reflect & Replan: Adjust its plan based on observations, and iterate until the goal is met or it needs human input.
- Communicate: Explain its progress and final outcome.
An “agent-driven UI workflow” means your frontend application becomes the user’s window into this agent’s process. Instead of just displaying a final answer, the UI will show the agent’s thoughts, the tools it’s using, and the results of those tools, allowing for a much richer, more transparent, and often interactive experience.
The Agent-UI Orchestration Loop
Even if the core agent logic lives on a backend server (which is common for security and computational reasons), your frontend is responsible for orchestrating the interaction. Think of it as a continuous loop:
- User Input: The user provides a task to the UI.
- UI to Agent: The UI sends this task to the agent (via an API call).
- Agent Processing: The agent starts its internal “thought” process, potentially using tools.
- Agent to UI (Streaming): The agent streams its progress back to the UI. This might include:
- Thoughts: “I need to find X, then use tool Y.”
- Tool Calls: “I’m calling the ‘search_database’ tool with query ‘React Native AI’.”
- Tool Results: “The search returned these results…”
- Final Answer: “Here is the summary of your request.”
- Request for User Input: “I need more information about Z.”
- UI Updates: The UI processes these streamed updates, displaying them to the user, potentially rendering interactive elements for tool calls, or prompting for more input.
- Repeat: If the agent needs more input or needs to execute another step, the loop continues.
Let’s visualize this flow:
Figure 16.1: The Agent-UI Orchestration Loop
Tool Calling from the UI Perspective
A crucial aspect of agentic workflows is tool calling. An agent doesn’t directly perform actions like “send an email” or “update a database entry.” Instead, it identifies when a tool is needed, which tool, and what arguments to pass to it. It then communicates this “tool call” request.
From the frontend’s perspective, when the agent sends a tool_call instruction:
- The UI must parse the tool name and its arguments.
- It then needs to execute that tool. This often means making another API call to a different backend service (e.g., an email API, a CRM API) or even performing a client-side action (e.g., opening a modal, navigating to a different route).
- Crucially, the UI must then send the result of that tool execution back to the agent. This allows the agent to continue its workflow, incorporating the tool’s output into its next thought step.
Security Note: Always remember that frontend code is client-side. Never expose sensitive API keys or credentials directly in your frontend for tool calls. If a tool requires authentication, it should typically be handled by a secure backend that the frontend calls, or through secure, short-lived tokens. The agent might request a tool, the frontend calls its own backend, which then securely calls the actual tool’s API.
Managing Agent State, Memory, and Context in React
For a smooth agent-driven UI, your React component needs to keep track of several pieces of information:
- Conversation History (Memory): A list of all messages, agent thoughts, and tool calls/results. This is essential for the agent to maintain context across turns.
- Current Agent Status: Is the agent thinking, calling a tool, waiting for user input, or finished?
- Loading States: For API calls, tool executions.
- Tool Definitions: A mapping of tool names to client-side functions that can execute them.
We’ll use React’s useState and useEffect hooks to manage this dynamic information effectively.
Streaming Agent Responses for Real-time Updates
Just as we learned with basic AI responses, agent workflows benefit immensely from streaming. Instead of waiting for the entire multi-step process to complete, the agent can send its thoughts, tool calls, and partial results in real-time. This keeps the user engaged and provides transparency into the agent’s progress. We’ll simulate this streaming behavior to demonstrate how your UI can react incrementally.
Step-by-Step Implementation: Building an Agentic Task Assistant UI
Let’s build a simple “Smart Note Taker” application. This application will allow a user to input a note, and an agent will categorize it, potentially suggest an action (which we’ll simulate as a “tool”), and then confirm.
Project Setup
First, let’s get our React project set up. We’ll use Vite for a fast and modern development experience.
Create a new React project: Open your terminal and run:
npm create vite@latest agent-note-taker -- --template react cd agent-note-taker npm installThis creates a new React project named
agent-note-takerusing Vite.Clean up
src/App.jsx: Replace the content ofsrc/App.jsxwith a minimal setup:// src/App.jsx import React from 'react'; import './App.css'; // We'll keep the basic styling function App() { return ( <div className="App"> <h1>Smart Note Taker</h1> {/* Our Agent UI will go here */} </div> ); } export default App;And for
src/index.css, you can keep the default or simplify it for a cleaner look. Let’s add some basic styling tosrc/App.cssto center things:/* src/App.css */ #root { max-width: 1280px; margin: 0 auto; padding: 2rem; text-align: center; } .App { display: flex; flex-direction: column; align-items: center; gap: 20px; } h1 { font-size: 2.5em; line-height: 1.1; }Run the app:
npm run devYou should see “Smart Note Taker” in your browser.
Step 1: Basic UI & Input Component
Let’s create the main component that will house our agent interaction. We’ll call it AgentWorkflow.jsx.
Create
src/components/AgentWorkflow.jsx:// src/components/AgentWorkflow.jsx import React, { useState } from 'react'; function AgentWorkflow() { const [input, setInput] = useState(''); const [messages, setMessages] = useState([]); // To store conversation history const [isLoading, setIsLoading] = useState(false); const handleSendMessage = async () => { if (!input.trim()) return; const newUserMessage = { id: Date.now(), sender: 'user', text: input }; setMessages((prevMessages) => [...prevMessages, newUserMessage]); setInput(''); setIsLoading(true); // TODO: Call agent API here console.log("Sending to agent:", input); setIsLoading(false); // For now, turn off loading immediately }; return ( <div className="agent-workflow-container"> <div className="message-list"> {messages.map((msg) => ( <div key={msg.id} className={`message ${msg.sender}`}> <strong>{msg.sender === 'user' ? 'You' : 'Agent'}:</strong> {msg.text} </div> ))} {isLoading && ( <div className="message agent loading"> <strong>Agent:</strong> Thinking... </div> )} </div> <div className="input-area"> <textarea value={input} onChange={(e) => setInput(e.target.value)} placeholder="Enter your note here..." rows="4" disabled={isLoading} /> <button onClick={handleSendMessage} disabled={isLoading}> {isLoading ? 'Processing...' : 'Send Note to Agent'} </button> </div> </div> ); } export default AgentWorkflow;Add basic styling for the workflow component in
src/App.css:/* src/App.css - add these styles */ .agent-workflow-container { width: 100%; max-width: 600px; border: 1px solid #ccc; border-radius: 8px; padding: 20px; background-color: #f9f9f9; box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); } .message-list { height: 300px; overflow-y: auto; border: 1px solid #eee; padding: 10px; margin-bottom: 15px; background-color: #fff; border-radius: 4px; text-align: left; } .message { margin-bottom: 10px; padding: 8px 12px; border-radius: 15px; max-width: 80%; } .message.user { background-color: #e0f7fa; align-self: flex-end; margin-left: auto; text-align: right; } .message.agent { background-color: #f0f0f0; align-self: flex-start; text-align: left; } .message.agent.loading { font-style: italic; color: #666; } .input-area { display: flex; flex-direction: column; gap: 10px; } textarea { width: 100%; padding: 10px; border: 1px solid #ddd; border-radius: 4px; resize: vertical; font-size: 1em; } button { padding: 10px 15px; background-color: #007bff; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 1em; } button:disabled { background-color: #a0c9ff; cursor: not-allowed; }Integrate
AgentWorkflowintosrc/App.jsx:// src/App.jsx import React from 'react'; import AgentWorkflow from './components/AgentWorkflow'; // Import our new component import './App.css'; function App() { return ( <div className="App"> <h1>Smart Note Taker</h1> <AgentWorkflow /> {/* Render the workflow component */} </div> ); } export default App;Now, when you run
npm run dev, you’ll see the basic input and message area. Try typing a message; it will appear, and the “Thinking…” indicator will briefly show.
Step 2: Connecting to a Mock Agent API
Since our focus is on the frontend, we’ll create a mockAgentApi.js file that simulates an agent’s responses. This mock API will return a sequence of messages and tool calls, similar to what a real streaming agent API might send.
Create
src/api/mockAgentApi.js:// src/api/mockAgentApi.js // Simulates an agent API that streams responses including thoughts and tool calls. // A simple mock tool execution function const mockTools = { categorize_note: async (noteContent) => { console.log(`[MOCK TOOL] Categorizing note: "${noteContent.substring(0, 30)}..."`); await new Promise(resolve => setTimeout(resolve, 1000)); // Simulate API delay if (noteContent.toLowerCase().includes('meeting') || noteContent.toLowerCase().includes('agenda')) { return { category: 'Meeting Notes', summary: 'Key points from a meeting.' }; } if (noteContent.toLowerCase().includes('idea') || noteContent.toLowerCase().includes('brainstorm')) { return { category: 'Ideas/Brainstorm', summary: 'Creative thoughts or concepts.' }; } return { category: 'General', summary: 'A general, uncategorized note.' }; }, suggest_follow_up: async (category, summary) => { console.log(`[MOCK TOOL] Suggesting follow-up for category: ${category}`); await new Promise(resolve => setTimeout(resolve, 1500)); // Simulate API delay if (category === 'Meeting Notes') { return { action: 'Schedule follow-up meeting', details: 'Check participants availability.' }; } if (category === 'Ideas/Brainstorm') { return { action: 'Create Trello card', details: 'Add idea to project backlog.' }; } return { action: 'No specific follow-up', details: 'Just archive the note.' }; } }; // This function will simulate streaming responses from an agent. // It returns an async generator that yields chunks of agent output. async function* streamAgentResponse(prompt) { const messages = [ { type: 'thought', content: `Okay, I received your note: "${prompt.substring(0, 50)}..."` }, { type: 'thought', content: 'My first step is to categorize this note to understand its context.' }, { type: 'tool_call', tool_name: 'categorize_note', tool_args: { noteContent: prompt } } ]; for (const message of messages) { yield message; await new Promise(resolve => setTimeout(resolve, 700)); // Simulate network delay } // After tool call, simulate receiving tool result and then more agent thoughts/actions const categoryResult = await mockTools.categorize_note(prompt); yield { type: 'tool_result', tool_name: 'categorize_note', result: categoryResult }; await new Promise(resolve => setTimeout(resolve, 700)); yield { type: 'thought', content: `The note is categorized as "${categoryResult.category}". Now I'll suggest a follow-up action.` }; await new Promise(resolve => setTimeout(resolve, 700)); yield { type: 'tool_call', tool_name: 'suggest_follow_up', tool_args: { category: categoryResult.category, summary: categoryResult.summary } }; await new Promise(resolve => setTimeout(resolve, 700)); const followUpResult = await mockTools.suggest_follow_up(categoryResult.category, categoryResult.summary); yield { type: 'tool_result', tool_name: 'suggest_follow_up', result: followUpResult }; await new Promise(resolve => setTimeout(resolve, 700)); yield { type: 'thought', content: `Based on the category, I recommend the following: "${followUpResult.action}".` }; await new Promise(resolve => setTimeout(resolve, 700)); yield { type: 'final_answer', content: `Your note has been processed. Category: ${categoryResult.category}. Suggested Action: ${followUpResult.action}.` }; } // A map of available tools for the frontend to execute export const availableTools = { categorize_note: mockTools.categorize_note, // In a real app, this would call a backend endpoint suggest_follow_up: mockTools.suggest_follow_up, // Same here }; export default streamAgentResponse;Explanation:
mockTools: This object contains functions that simulate external “tools” an agent might use. Notice they areasyncto mimic real API calls.streamAgentResponse(prompt): This is anasync generatorfunction. It’s the core of our mock agent.- It takes a
prompt(the user’s note). - It yields a sequence of objects, each representing a chunk of the agent’s output. These objects have a
type(thought,tool_call,tool_result,final_answer) andcontentortool_name/tool_args. await new Promise(resolve => setTimeout(resolve, ...)): This simulates network latency and processing time, making the “streaming” feel more realistic.- When a
tool_callis yielded, the mock agent pauses its internal stream, waits for the tool to be “executed” (by callingmockToolsinternally), and then yields thetool_resultbefore continuing. This mimics the agent’s internal loop.
- It takes a
availableTools: We export this object to allow our frontend to “execute” the mock tools when the agent requests them.
Step 3: Integrating the Mock Agent and Handling Streaming
Now, let’s update AgentWorkflow.jsx to interact with our mock agent. We’ll iterate through the streamed responses and update the UI.
Update
src/components/AgentWorkflow.jsx:// src/components/AgentWorkflow.jsx import React, { useState, useRef, useEffect } from 'react'; import streamAgentResponse, { availableTools } from '../api/mockAgentApi'; // Import our mock agent function AgentWorkflow() { const [input, setInput] = useState(''); const [messages, setMessages] = useState([]); const [isLoading, setIsLoading] = useState(false); const messageListRef = useRef(null); // Ref for auto-scrolling // Auto-scroll to bottom of messages useEffect(() => { if (messageListRef.current) { messageListRef.current.scrollTop = messageListRef.current.scrollHeight; } }, [messages]); const handleSendMessage = async () => { if (!input.trim()) return; const userMessageId = Date.now(); const newUserMessage = { id: userMessageId, sender: 'user', text: input }; setMessages((prevMessages) => [...prevMessages, newUserMessage]); setInput(''); setIsLoading(true); const agentStream = streamAgentResponse(input); let currentAgentMessage = { id: userMessageId + 1, sender: 'agent', text: '' }; // Start with empty agent message setMessages((prevMessages) => [...prevMessages, currentAgentMessage]); // Add placeholder try { // Iterate over the async generator (simulated stream) for await (const chunk of agentStream) { // Update the last agent message or add a new one based on chunk type setMessages((prevMessages) => { const lastMessage = prevMessages[prevMessages.length - 1]; let updatedMessages = [...prevMessages]; if (chunk.type === 'thought' || chunk.type === 'final_answer') { // If the last message is an agent message, append to it. Otherwise, add new. if (lastMessage && lastMessage.sender === 'agent' && lastMessage.type !== 'tool_call_display' && lastMessage.type !== 'tool_result_display') { updatedMessages[updatedMessages.length - 1] = { ...lastMessage, text: lastMessage.text + (lastMessage.text ? '\n' : '') + chunk.content, type: chunk.type, // Update type for display }; } else { updatedMessages.push({ id: Date.now(), sender: 'agent', text: chunk.content, type: chunk.type, }); } } else if (chunk.type === 'tool_call') { // When agent requests a tool call, display it and execute const toolCallMessage = { id: Date.now(), sender: 'agent', type: 'tool_call_display', tool_name: chunk.tool_name, tool_args: chunk.tool_args, text: `Agent wants to call tool: ${chunk.tool_name}(${JSON.stringify(chunk.tool_args)})` }; updatedMessages.push(toolCallMessage); // IMPORTANT: Execute the tool and send result back to the agent stream (conceptually) // In our mock, the agent stream already handles waiting for the tool result. // In a real system, you'd send the tool_result back to the agent API. // For this mock, we just display the tool execution and its result. const toolFunction = availableTools[chunk.tool_name]; if (toolFunction) { const toolResult = await toolFunction(...Object.values(chunk.tool_args)); const toolResultMessage = { id: Date.now() + 1, sender: 'agent', type: 'tool_result_display', tool_name: chunk.tool_name, result: toolResult, text: `Tool "${chunk.tool_name}" executed. Result: ${JSON.stringify(toolResult)}` }; updatedMessages.push(toolResultMessage); } else { const errorToolMessage = { id: Date.now() + 1, sender: 'agent', type: 'error', text: `Error: Agent requested unknown tool "${chunk.tool_name}"` }; updatedMessages.push(errorToolMessage); } } else if (chunk.type === 'tool_result') { // We handle tool_result display within the tool_call block above for simplicity with mock. // In a real stream, agent would send this back. We just display it here. const toolResultMessage = { id: Date.now(), sender: 'agent', type: 'tool_result_display', tool_name: chunk.tool_name, result: chunk.result, text: `Agent received result for "${chunk.tool_name}": ${JSON.stringify(chunk.result)}` }; updatedMessages.push(toolResultMessage); } return updatedMessages; }); } } catch (error) { console.error("Error during agent stream:", error); setMessages((prevMessages) => [ ...prevMessages, { id: Date.now(), sender: 'agent', type: 'error', text: `Error: ${error.message}` }, ]); } finally { setIsLoading(false); } }; // Helper to render different message types const renderMessageContent = (msg) => { if (msg.type === 'tool_call_display') { return ( <> 💡 Agent is planning to use tool `<strong>{msg.tool_name}</strong>` with arguments: <br/> <pre>{JSON.stringify(msg.tool_args, null, 2)}</pre> <span className="tool-status">Executing...</span> </> ); } if (msg.type === 'tool_result_display') { return ( <> ✅ Tool `<strong>{msg.tool_name}</strong>` completed. Result: <br/> <pre>{JSON.stringify(msg.result, null, 2)}</pre> </> ); } if (msg.type === 'thought') { return <>💭 {msg.text}</>; } if (msg.type === 'final_answer') { return <>✨ {msg.text}</>; } if (msg.type === 'error') { return <>🚨 {msg.text}</>; } return msg.text; // Default for user messages or generic agent text }; return ( <div className="agent-workflow-container"> <div className="message-list" ref={messageListRef}> {messages.map((msg) => ( <div key={msg.id} className={`message ${msg.sender} ${msg.type || ''}`}> <strong>{msg.sender === 'user' ? 'You' : 'Agent'}:</strong> {renderMessageContent(msg)} </div> ))} {isLoading && ( <div className="message agent loading"> <strong>Agent:</strong> Thinking... </div> )} </div> <div className="input-area"> <textarea value={input} onChange={(e) => setInput(e.target.value)} placeholder="Enter your note here, e.g., 'Meeting agenda for Q1 planning. Need to discuss budget and new hires.'" rows="4" disabled={isLoading} /> <button onClick={handleSendMessage} disabled={isLoading}> {isLoading ? 'Processing...' : 'Send Note to Agent'} </button> </div> </div> ); } export default AgentWorkflow;Key Changes and Explanations:
- Import
streamAgentResponseandavailableTools: We bring in our mock agent and its tools. messageListRefanduseEffectfor Auto-Scrolling: ThisuseRefanduseEffectcombination ensures that the message list automatically scrolls to the bottom as new messages are added, providing a smoother chat-like experience.handleSendMessageLogic:- After adding the user’s message, we immediately add a placeholder agent message.
for await (const chunk of agentStream): This is where the magic happens! We iterate over theasync generatorreturned bystreamAgentResponse. Eachchunkrepresents a piece of the agent’s output.- Dynamic
setMessagesupdates:- For
thoughtandfinal_answertypes, we append the content to the last agent message in themessagesarray. This simulates the agent “typing” its thoughts or building up its final response. - For
tool_callandtool_resulttypes, we add distinct messages to the array, indicating the agent’s actions. - Tool Execution: When a
tool_callchunk is received, we look up the corresponding function inavailableToolsandawaitits execution. The result is then added to the messages for display. In a real-world scenario, thistoolFunctionwould likely make anfetchcall to a backend endpoint that securely executes the actual tool.
- For
renderMessageContentHelper: This new function helps us display different types of agent messages (thoughts, tool calls, tool results, final answers) with distinct formatting and icons, making the agent’s workflow transparent to the user.- Styling in
src/App.css(add these):/* src/App.css - add these styles for tool display */ .message.agent.tool_call_display, .message.agent.tool_result_display { background-color: #e6f7ff; /* Light blue background */ border-left: 4px solid #007bff; /* Blue border */ padding: 10px 15px; } .message pre { background-color: #f0f0f0; padding: 8px; border-radius: 4px; margin-top: 5px; overflow-x: auto; font-size: 0.9em; white-space: pre-wrap; /* Ensure pre-formatted text wraps */ } .message .tool-status { font-style: italic; color: #555; margin-top: 5px; display: block; }
- Import
Now, try running npm run dev and enter a note like “Meeting agenda for Q1 planning. Need to discuss budget and new hires.” or “I have an idea for a new feature: a dark mode toggle!”. You’ll see the agent’s thoughts, tool calls, and results streaming in real-time!
Step 4: Refinement and Error Handling
Our current setup handles basic errors, but we can make it more robust. We’ll also add a way to conceptually “cancel” an ongoing agent process.
Add a
cancellationController: In a real application, you’d send a cancellation signal to your backend agent API. For our mock, we’ll just stop processing the stream on the frontend.// src/components/AgentWorkflow.jsx (add this to the top of the component) import React, { useState, useRef, useEffect } from 'react'; import streamAgentResponse, { availableTools } from '../api/mockAgentApi'; // ... (inside AgentWorkflow function) const abortControllerRef = useRef(null); // To manage cancellation const handleSendMessage = async () => { if (!input.trim()) return; // If an existing request is in progress, cancel it first (optional, but good UX) if (abortControllerRef.current) { abortControllerRef.current.abort(); console.log("Previous agent process cancelled."); setIsLoading(false); // Reset loading state // Optionally, add a message to the UI about cancellation setMessages(prev => [...prev, { id: Date.now(), sender: 'system', type: 'info', text: 'Previous task cancelled.' }]); } abortControllerRef.current = new AbortController(); // Create a new controller for this request const signal = abortControllerRef.current.signal; // ... (rest of handleSendMessage function) try { // ... (inside for await loop) // Add a check for cancellation within the loop if (signal.aborted) { console.log("Agent stream aborted by user."); setMessages(prev => [...prev, { id: Date.now(), sender: 'system', type: 'info', text: 'Task aborted by user.' }]); break; // Exit the loop } // ... (rest of loop logic) } catch (error) { if (signal.aborted) { console.log("Agent stream caught aborted signal."); setMessages(prev => [...prev, { id: Date.now(), sender: 'system', type: 'info', text: 'Task aborted by user.' }]); } else { console.error("Error during agent stream:", error); setMessages((prevMessages) => [ ...prevMessages, { id: Date.now(), sender: 'agent', type: 'error', text: `Error: ${error.message}` }, ]); } } finally { setIsLoading(false); abortControllerRef.current = null; // Clear the controller after completion or cancellation } }; const handleCancel = () => { if (abortControllerRef.current) { abortControllerRef.current.abort(); console.log("Agent process explicitly cancelled by user."); } }; return ( <div className="agent-workflow-container"> {/* ... (message-list) */} <div className="input-area"> <textarea value={input} onChange={(e) => setInput(e.target.value)} placeholder="Enter your note here, e.g., 'Meeting agenda for Q1 planning. Need to discuss budget and new hires.'" rows="4" disabled={isLoading} /> <div className="action-buttons"> <button onClick={handleSendMessage} disabled={isLoading}> {isLoading ? 'Processing...' : 'Send Note to Agent'} </button> {isLoading && ( <button onClick={handleCancel} className="cancel-button"> Cancel </button> )} </div> </div> </div> );Explanation of Cancellation:
AbortController: A standard browser API for aborting asynchronous operations. We create one for each agent request.abortControllerRef: We store the controller in a ref so we can access it later to callabort().handleSendMessage: If a previous request is active, we abort it before starting a new one. This prevents multiple agent calls from running concurrently and ensures a clean state.for awaitloop: Inside the loop, we checksignal.aborted. If true, we break the loop, effectively stopping frontend processing of the stream.handleCancelfunction: A new function to be called by a “Cancel” button.- UI: Added a “Cancel” button that only appears when
isLoadingis true.
Add
action-buttonsstyling tosrc/App.css:/* src/App.css - add these styles for action buttons */ .action-buttons { display: flex; gap: 10px; width: 100%; } .action-buttons button { flex: 1; } .action-buttons .cancel-button { background-color: #dc3545; /* Red for cancel */ } .action-buttons .cancel-button:hover { background-color: #c82333; } .message.system.info { background-color: #e2e3e5; color: #383d41; font-style: italic; text-align: center; margin-left: auto; margin-right: auto; }
Now, you can type a note, click “Send,” and then click “Cancel” while the agent is processing. You’ll see the “Task aborted by user” message.
Mini-Challenge
Challenge: Enhance the suggest_follow_up tool. Instead of just suggesting an action, modify the mock agent to ask the user if they want to “Execute” the suggested action (e.g., “Schedule follow-up meeting”). If the user clicks “Execute,” the UI should then confirm that the action was taken.
Hint:
- In
mockAgentApi.js, after thesuggest_follow_uptool is called, modify the agent’s stream to yield a newtypeof message, e.g.,type: 'user_action_request', which includes the suggested action. - In
AgentWorkflow.jsx, updaterenderMessageContentto recognizeuser_action_request. This should render a button (e.g., “Execute Action”). - When the user clicks this button, the UI should send a new message back to the agent (conceptually, in our mock, it can just update the UI with a confirmation). In a real scenario, this would be a new API call to the agent, indicating the user’s choice. For simplicity, just update the UI state to show the action was “executed.”
What to observe/learn: This challenge pushes you to integrate user interaction mid-workflow, a critical pattern for agentic UIs. You’ll learn how to pause agent output, wait for user input, and then resume or complete the workflow based on that input.
Common Pitfalls & Troubleshooting
- Infinite Loops in Agent Responses:
- Issue: The agent keeps sending messages or calling tools without reaching a
final_answer. - Troubleshooting: This is usually a prompt engineering problem on the agent’s side (e.g., the agent doesn’t have clear termination conditions or gets stuck in a loop of thought/action). On the frontend, implement timeout mechanisms or max message count limits to prevent the UI from getting stuck indefinitely.
- Issue: The agent keeps sending messages or calling tools without reaching a
- Incorrectly Parsing Streamed Data:
- Issue: The UI displays garbled text or crashes when processing agent responses.
- Troubleshooting: Double-check the structure of the
chunkobjects from your agent stream (or mock stream). Ensure yoursetMessageslogic correctly identifies thetypeand extracts thecontent,tool_name,tool_args, orresult. Useconsole.log(chunk)to inspect the raw data.
- State Management Issues with Rapid Updates:
- Issue: Messages appear out of order, or UI updates are janky during streaming.
- Troubleshooting:
- Ensure
setMessagesuses the functional update form (setMessages(prevMessages => [...])) to avoid stale closures, especially when updating based on the previous state. - For complex state, consider
useReducerfor more predictable state transitions. - If performance is an issue with many messages, explore virtualization libraries for long lists.
- Ensure
- Security: Exposing Sensitive Information:
- Issue: Accidentally putting API keys or confidential data directly in client-side tool calls or logs.
- Troubleshooting: Never put secrets in frontend code. All sensitive tool execution should route through a secure backend service. Even tool arguments should be sanitized and validated before sending to a backend.
- Lack of Visual Feedback:
- Issue: The user doesn’t know if the agent is working, waiting, or stuck.
- Troubleshooting: Always provide clear loading indicators, “thinking” messages, and specific UI elements (like our tool call/result displays) to show the agent’s current status and progress.
Summary
Congratulations! You’ve successfully built an agent-driven UI workflow, a cornerstone of advanced AI-powered applications. Let’s recap what you’ve achieved:
- Understood Agentic Workflows: You now grasp the difference between simple AI calls and multi-step agent processes that use tools.
- Mastered Client-Side Orchestration: You learned how the frontend acts as the conductor for an AI agent, managing its state, displaying its progress, and executing its tool requests.
- Implemented Streaming & Dynamic UI Updates: You built a UI that reacts in real-time to agent thoughts, tool calls, and results, providing a transparent and engaging user experience.
- Handled Tool Calling: You integrated the concept of agent-requested tool execution from the frontend, understanding the flow of input, execution, and result reporting.
- Managed Agent State: You utilized React’s
useStateanduseRefto maintain conversation history, agent status, and control asynchronous operations like cancellation. - Improved User Experience: You added auto-scrolling and visual cues to make the agent’s process clear and interactive.
This project lays a robust foundation for building sophisticated AI copilots, smart assistants, and automated task systems directly within your React and React Native applications. You’re now equipped to design UIs that not only consume AI but actively participate in and enhance agentic workflows.
What’s Next?
In the upcoming chapters, we’ll continue to refine our agentic UI patterns, focusing on:
- Real Agent Integration: Connecting to actual backend agent services (e.g., using OpenAI Assistants API or custom agent frameworks).
- Advanced Guardrails and Safety Checks: Implementing more sophisticated frontend validation and UX protections for agent outputs and tool calls.
- Performance Optimization: Ensuring smooth experiences even with complex agent interactions and large message histories.
- Accessibility: Making agent-driven UIs usable for everyone.
Keep experimenting, keep building, and get ready to unlock even more potential in your AI-powered frontend journey!
References
- React Official Documentation: The definitive guide for React concepts and hooks.
- Vite Official Documentation: Learn more about the build tool used for our project setup.
- MDN Web Docs - AbortController: Understand the browser API for cancelling asynchronous operations.
- Hugging Face Transformers.js (for client-side AI concepts): While we didn’t use it directly in this chapter’s project, Transformers.js is excellent for understanding in-browser AI model execution, which agents can leverage client-side.
- OpenAI - Agentic Tool Calling (Conceptual): Provides a good overview of how agents use tools, which informs our frontend’s interaction model.
- https://platform.openai.com/docs/guides/function-calling (Though focused on server-side, the principles of tool definition and execution are relevant.)
This page is AI-assisted and reviewed. It references official documentation and recognized resources where relevant.