Smart Task Manager with HITL Approvals
Build an AI task manager with human-in-the-loop approval flows using CopilotKit.
Introduction
30 Second Summary
When you delegate work to a new team member, you probably don't want them deleting important files or rearranging priorities without checking in first. The same principle applies to AI agents.
In this project, you will build a Smart Task Manager where an AI agent helps you manage your to-do list through natural conversation. The agent handles routine tasks instantly, but pauses and asks for your approval before doing anything destructive.
What You'll Build
Picture telling your AI agent to delete all completed tasks, and instead of them vanishing instantly, a polished Material UI approval card slides in showing exactly which tasks will go, why the agent thinks they should, and two buttons: Approve or Reject.
By the end of this project, you'll have:
- A chat-powered task manager where you talk to an AI agent to create, categorize, and manage tasks on a live Next.js dashboard.
- A human-in-the-loop approval flow where the agent pauses before destructive actions and presents you with a detailed approval card showing what it wants to do and why.
- An action audit log that records every agent proposal and your approval or rejection, giving you a complete accountability trail.
- Secret Mission: Add confidence signals that flag when the agent is uncertain about its task categorization, and let you correct it.
Are there any prerequisites?
You should have basic familiarity with React and JavaScript. You will also need Node.js 20+ installed, an OpenAI API key, and a code editor like VS Code or Cursor.
Before We Start
Before we dive in, let's lock in what you're about to build and why it matters. Understanding the goal upfront helps you connect every step to the bigger picture.
Set Up the Project and Install Dependencies
Your Smart Task Manager combines several technologies that need to work together. Next.js provides the web framework. CopilotKit connects your app to an AI agent. Material UI gives you pre-built components for a polished interface.
Before you can build any agent interactions, you need all of these wired up and running. This step gets your project scaffolded, configured, and verified.
In this step, get ready to:
- Scaffold a Next.js project and install CopilotKit, MUI, and Zod dependencies.
- Create the CopilotKit runtime API route and configure app providers.
- Verify the development server runs without errors.
Scaffold a Next.js project and install dependencies
You need a Next.js project with TypeScript and the App Router as the foundation for your task manager.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Terminal and press Enter to open it.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your Next.js project by running this command:
npx create-next-app@latest smart-task-manager
What prompts will I see?
The CLI first confirms the project name, then asks whether you want the recommended Next.js defaults.
Select Yes, use recommended defaults. This enables TypeScript, ESLint, Tailwind CSS, and the App Router, which is exactly the setup you need.
- Once the scaffold finishes, move into the project folder by running:
cd smart-task-manager
- Install the CopilotKit, MUI, and Zod packages by running this command:
npm install @copilotkit/react-core @copilotkit/react-ui @copilotkit/runtime @mui/material @emotion/react @emotion/styled @mui/icons-material @mui/material-nextjs @emotion/cache zod
What are all these packages?
- @copilotkit/react-core, @copilotkit/react-ui, and @copilotkit/runtime power the AI agent chat and tool system.
- @mui/material, @emotion/react, and @emotion/styled are the core Material UI library and its styling engine.
- @mui/icons-material provides the icon set you will use in the audit log.
- @mui/material-nextjs and @emotion/cache handle proper styling with Next.js server-side rendering.
- zod defines the schema for CopilotKit tool parameters.
- Confirm the install succeeded. You should see a message ending with added X packages in the terminal output.
- Open the smart-task-manager folder in your code editor. In your editor, click File in the top menu bar, then Open Folder, and select the smart-task-manager folder on your Desktop.
Install failed or timed out?
- Make sure you ran the npm install command from inside the smart-task-manager folder, not the Desktop.
- If you see permission errors on macOS, try prefixing the command with sudo.
- Help me troubleshoot the npm install
Configure the environment and CopilotKit runtime
Your app needs an OpenAI API key to power the AI agent. It also needs a server-side route that CopilotKit calls to reach the language model.
- In your editor's file explorer, create a new file called .env in the root of the smart-task-manager folder (at the same level as package.json).
- Add your OpenAI API key to .env by pasting this line:
OPENAI_API_KEY=your-api-key-here
- Replace your-api-key-here with your actual OpenAI API key.
- Save .env.
Keep your key secret
Never commit .env to version control. The Next.js scaffold already includes .env in .gitignore, so it is excluded by default.
- In the app folder, create a new folder called api.
- Inside api, create another folder called copilotkit.
- Inside copilotkit, create a file called route.ts.
- Set up the CopilotKit runtime by pasting this code into app/api/copilotkit/route.ts:
import {
CopilotRuntime,
copilotRuntimeNextJSAppRouterEndpoint,
} from "@copilotkit/runtime";
import { BuiltInAgent } from "@copilotkit/runtime/v2";
import { NextRequest } from "next/server";
// Create the AI agent using OpenAI's GPT-4o model
const builtInAgent = new BuiltInAgent({
model: "openai:gpt-4o",
});
// Set up the CopilotKit runtime with this agent as the default
const runtime = new CopilotRuntime({
agents: { default: builtInAgent },
});
// Handle incoming POST requests from the CopilotKit frontend
export const POST = async (req: NextRequest) => {
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
runtime,
endpoint: "/api/copilotkit",
});
return handleRequest(req);
};
What does this code do?
- BuiltInAgent configures a CopilotKit agent powered by the openai:gpt-4o model. The provider:model format tells CopilotKit which LLM provider and model to use.
- CopilotRuntime registers the agent as the default handler for all chat requests from the frontend.
- copilotRuntimeNextJSAppRouterEndpoint wraps the runtime into a Next.js App Router route handler. Chat messages arrive as POST requests to /api/copilotkit and get routed to the agent.
- Save app/api/copilotkit/route.ts.
✔️ Awesome, I've got everything!
Great. Double check you have saved the file.
ⓧ I'd like to double check the full code
import {
CopilotRuntime,
copilotRuntimeNextJSAppRouterEndpoint,
} from "@copilotkit/runtime";
import { BuiltInAgent } from "@copilotkit/runtime/v2";
import { NextRequest } from "next/server";
const builtInAgent = new BuiltInAgent({
model: "openai:gpt-4o",
});
const runtime = new CopilotRuntime({
agents: { default: builtInAgent },
});
export const POST = async (req: NextRequest) => {
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
runtime,
endpoint: "/api/copilotkit",
});
return handleRequest(req);
};
Let's do a quick check that everything compiles before wiring up the providers.
- Start the development server by running this command in your terminal:
npm run dev
- Open http://localhost:3000 in your browser.
- You should see the default Next.js starter page. This confirms your project and the new API route compile without errors.
- Go back to your terminal and press Ctrl+C to stop the dev server.
Seeing errors in the terminal?
- Check that your .env file is in the root of smart-task-manager, not inside the app folder.
- Verify your route.ts is at the path app/api/copilotkit/route.ts. Missing folders will cause a compile error.
- Help me debug the CopilotKit route
Now you will wire up the app's layout with the CopilotKit provider and Material UI theme.
- Inside the app folder, create a new folder called context.
- Inside context, create a file called TaskContext.tsx.
- Add a placeholder provider to app/context/TaskContext.tsx by pasting this code:
"use client";
import React, { ReactNode } from "react";
// Placeholder provider - we will add task state in the next step
export function TaskProvider({ children }: { children: ReactNode }) {
return <>{children}</>;
}
Why create an empty provider?
The layout file you are about to write imports TaskProvider from this file. Without it, the app would fail to compile. You will fill in the real task state and logic in the next step.
- Save app/context/TaskContext.tsx.
- Confirm you can see TaskContext.tsx inside the context folder in your editor's file explorer.
- Open app/layout.tsx in your editor. This file already exists from the scaffold.
- Select all the existing content and delete it.
- Paste the following imports and theme setup into app/layout.tsx:
"use client";
import { CopilotKit } from "@copilotkit/react-core/v2";
import "@copilotkit/react-core/v2/styles.css";
import { AppRouterCacheProvider } from "@mui/material-nextjs/v15-appRouter";
import { ThemeProvider, createTheme, CssBaseline } from "@mui/material";
import { TaskProvider } from "./context/TaskContext";
const theme = createTheme({
palette: {
mode: "light",
primary: { main: "#1976d2" },
secondary: { main: "#9c27b0" },
},
});
What does this code do?
- "use client" marks this as a client component. CopilotKit's provider needs to run in the browser.
- CopilotKit is the provider that gives every page access to the AI agent.
- AppRouterCacheProvider from @mui/material-nextjs ensures MUI's styling works correctly with Next.js server-side rendering.
- createTheme defines a light theme with a blue primary and purple secondary color.
- Below the theme, add the RootLayout component:
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body>
<AppRouterCacheProvider>
<ThemeProvider theme={theme}>
<CssBaseline />
<CopilotKit runtimeUrl="/api/copilotkit">
<TaskProvider>{children}</TaskProvider>
</CopilotKit>
</ThemeProvider>
</AppRouterCacheProvider>
</body>
</html>
);
}
What does this code do?
- The providers are nested in a specific order. AppRouterCacheProvider wraps everything for MUI styling. ThemeProvider applies the color theme. CssBaseline resets browser defaults.
- CopilotKit points to /api/copilotkit so the frontend knows where to send chat messages.
- TaskProvider wraps the children so every page will have access to the task state you build in the next step.
- Save app/layout.tsx.
✔️ Awesome, I've got everything!
Great. Double check you have saved both app/context/TaskContext.tsx and app/layout.tsx.
ⓧ I'd like to double check the full code
The full app/layout.tsx is built from two chunks. Scroll up and confirm both are present in order:
- Chunk 1: the "use client" directive, all imports, and the theme constant.
- Chunk 2: the RootLayout function with all nested providers.
Verify the setup runs
Everything is wired up. Time to confirm the full provider stack compiles and the page loads.
- Before you run the server, predict: do you think the page will look the same as before, or different? The layout no longer imports the default Next.js styles.
- Start the development server by running:
npm run dev
- Open http://localhost:3000 in your browser.
The page loads with the default Next.js content, but the styling looks different. MUI's CssBaseline has replaced the default stylesheet. The important thing is that the page loads with no errors in the terminal.
Seeing a compile error?
- If you see an error about TaskProvider, make sure you created app/context/TaskContext.tsx with the placeholder provider code from above.
- If you see an import error for @copilotkit/react-core/v2, confirm that the npm install command from earlier completed successfully.
- Help me fix the layout compile error
Your project is scaffolded, configured, and running. Next up, you will build the task dashboard UI and connect the AI chat sidebar.
Build the Task Dashboard and Connect the Agent Chat
Your project scaffold is ready with CopilotKit and Material UI wired in. Now it is time to build the part your users will actually see.
Before the agent can manage tasks, you need two things: a task list to manage and a chat interface to talk through. This step creates both.
In this step, get ready to:
- Create a React context that holds your task data and management functions.
- Build a Material UI task dashboard that displays tasks grouped by priority.
- Add a CopilotChat sidebar for agent conversations.
Create the task state and data model
Every component in the app needs access to the same task list. React context provides a shared store that any component can read from and write to without passing props through every layer.
- In your editor's Explorer sidebar, right-click the app folder and select New File.
- Type context/TaskContext.tsx as the filename and press Enter. This creates both the folder and the file.
- Define the task and audit data shapes by pasting this code into the empty file:
"use client";
import React, { createContext, useContext, useState, ReactNode } from "react";
// Data shapes for tasks and audit entries
export interface Task {
id: string;
title: string;
category: string;
priority: "High" | "Medium" | "Low";
completed: boolean;
categoryConfidence?: number;
}
export interface AuditEntry {
id: string;
timestamp: Date;
actionType: string;
description: string;
status: "approved" | "rejected" | "auto";
itemsAffected: string[];
}
What do these interfaces define?
- Task describes each task on the board: an ID, title, category label, priority level, and whether it is completed.
- AuditEntry records every action the agent proposes and whether you approved, rejected, or let it auto-execute. You will use this later for the audit log.
- categoryConfidence is optional for now. The Secret Mission will use it to flag uncertain categorizations.
- Add the context type definition, the context itself, and the start of the provider function below what you just pasted:
// All operations the context exposes
interface TaskContextType {
tasks: Task[];
auditLog: AuditEntry[];
addTask: (task: Omit<Task, "id">) => void;
deleteTasks: (ids: string[]) => void;
toggleTask: (id: string) => void;
updateTaskCategory: (id: string, category: string) => void;
addAuditEntry: (entry: Omit<AuditEntry, "id">) => void;
}
const TaskContext = createContext<TaskContextType | undefined>(undefined);
// Provider component that holds all task state
export function TaskProvider({ children }: { children: ReactNode }) {
// Seed the dashboard with sample tasks
const [tasks, setTasks] = useState<Task[]>([
{ id: "1", title: "Review pull request #42", category: "Code Review", priority: "High", completed: false },
{ id: "2", title: "Update project README", category: "Documentation", priority: "Low", completed: true },
{ id: "3", title: "Fix authentication timeout", category: "Bug Fix", priority: "High", completed: false },
{ id: "4", title: "Design onboarding flow", category: "Design", priority: "Medium", completed: false },
]);
const [auditLog, setAuditLog] = useState<AuditEntry[]>([]);
What does this code set up?
- TaskContextType lists every operation that components can call: adding tasks, deleting them, toggling completion, updating categories, and logging audit entries.
- createContext creates the React context itself. It starts as undefined until the provider mounts.
- The four seed tasks pre-populate the dashboard so you can see data immediately.
- Finish the file by adding the helper functions, the provider return, and the custom hook below:
// Task mutation functions
const addTask = (task: Omit<Task, "id">) => {
const newTask = { ...task, id: Date.now().toString() };
setTasks((prev) => [...prev, newTask]);
};
const deleteTasks = (ids: string[]) => {
setTasks((prev) => prev.filter((t) => !ids.includes(t.id)));
};
const toggleTask = (id: string) => {
setTasks((prev) => prev.map((t) => (t.id === id ? { ...t, completed: !t.completed } : t)));
};
const updateTaskCategory = (id: string, category: string) => {
setTasks((prev) => prev.map((t) => t.id === id ? { ...t, category, categoryConfidence: 100 } : t));
};
const addAuditEntry = (entry: Omit<AuditEntry, "id">) => {
setAuditLog((prev) => [{ ...entry, id: Date.now().toString() }, ...prev]);
};
return (
<TaskContext.Provider value={{ tasks, auditLog, addTask, deleteTasks, toggleTask, updateTaskCategory, addAuditEntry }}>
{children}
</TaskContext.Provider>
);
}
// Custom hook for consuming task context
export function useTaskContext() {
const context = useContext(TaskContext);
if (!context) throw new Error("useTaskContext must be used within TaskProvider");
return context;
}
What do these functions do?
- addTask generates a unique ID using Date.now() and appends the new task to the list.
- deleteTasks filters out any tasks whose IDs are in the provided array.
- toggleTask flips a task's completed status when the learner checks the checkbox.
- useTaskContext is a custom hook that gives any component access to the shared task state.
- Save TaskContext.tsx.
- Check the terminal where your dev server is running. You should see a clean compilation with no import errors.
Why does the terminal clear up?
Your app/layout.tsx already imports TaskProvider from ./context/TaskContext. Before you created this file, that import had nothing to resolve. Now it does.
Seeing errors in the terminal?
- Confirm the file is saved at exactly app/context/TaskContext.tsx. A typo in the folder or file name breaks the import in layout.tsx.
- Make sure you pasted all three code sections in order. A missing closing brace or a missing export causes compilation errors.
- help me fix import errors for TaskContext
✔️ Awesome, I've got everything!
Great. Double check you have saved the file before moving on.
ⓧ I'd like to double check the full code
Since TaskContext.tsx is a long file, scroll up and confirm all three sections are present in order:
- The "use client" directive, import line, and the three interfaces (Task, AuditEntry, TaskContextType).
- The createContext line and the TaskProvider function opening with four seed tasks and auditLog state.
- The five helper functions (addTask, deleteTasks, toggleTask, updateTaskCategory, addAuditEntry), the return statement, and the useTaskContext hook.
Build the task dashboard UI
MUI provides prebuilt components like Card, Chip, and Checkbox that give the dashboard a polished look without writing custom CSS.
- Right-click the app folder in the Explorer sidebar, select New File, and type components/TaskDashboard.tsx.
- Set up the component with imports and configuration by pasting this code:
"use client";
import React, { useState } from "react";
import {
Card, CardContent, Typography, List, ListItem, ListItemText,
ListItemIcon, Checkbox, Chip, Box, Tabs, Tab, Divider,
} from "@mui/material";
import { useTaskContext } from "../context/TaskContext";
// Dashboard component displaying tasks grouped by priority
export default function TaskDashboard() {
const { tasks, toggleTask } = useTaskContext();
const [tabIndex, setTabIndex] = useState(0);
// Map priority levels to MUI color variants
const priorities: Array<"High" | "Medium" | "Low"> = ["High", "Medium", "Low"];
const priorityColors: Record<string, "error" | "warning" | "success"> = {
High: "error", Medium: "warning", Low: "success",
};
return (
<Card sx={{ height: "100vh", display: "flex", flexDirection: "column" }}>
<Box sx={{ borderBottom: 1, borderColor: "divider" }}>
<Tabs value={tabIndex} onChange={(_, v) => setTabIndex(v)}>
<Tab label="Tasks" />
</Tabs>
</Box>
<CardContent sx={{ flex: 1, overflow: "auto" }}>
<Typography variant="h5" gutterBottom>Smart Task Manager</Typography>
What does this set up?
- useTaskContext() pulls the shared task list and the toggleTask function from the context you just created.
- priorities and priorityColors map each priority level to an MUI color variant so High shows red, Medium shows amber, and Low shows green.
- The Tabs component has a single tab for now. You will add an Audit Log tab in a later step.
- Add the task rendering logic below, inside the same CardContent:
{/* Loop through each priority group and render matching tasks */}
{priorities.map((priority) => {
const filtered = tasks.filter((t) => t.priority === priority);
if (filtered.length === 0) return null;
return (
<Box key={priority} sx={{ mb: 2 }}>
<Chip label={`${priority} Priority`} color={priorityColors[priority]} size="small" sx={{ mb: 1 }} />
<List dense>
{filtered.map((task) => (
<ListItem key={task.id} sx={{ pl: 0 }}>
<ListItemIcon sx={{ minWidth: 36 }}>
<Checkbox checked={task.completed} onChange={() => toggleTask(task.id)} size="small" />
</ListItemIcon>
<ListItemText
primary={task.title}
sx={{ textDecoration: task.completed ? "line-through" : "none", color: task.completed ? "text.disabled" : "text.primary" }}
/>
<Chip label={task.category} size="small" variant="outlined" />
</ListItem>
))}
</List>
<Divider />
</Box>
);
})}
{tasks.length === 0 && <Typography color="text.secondary" sx={{ mt: 2 }}>No tasks yet. Ask the agent to add some!</Typography>}
</CardContent>
</Card>
);
}
What does this rendering code do?
- priorities.map loops through High, Medium, and Low. For each priority, it filters the task list and renders a group.
- Each task gets a Checkbox for toggling completion, the task title with strikethrough styling when completed, and a Chip showing its category.
- The empty-state message at the bottom only appears when all tasks have been deleted.
- Save TaskDashboard.tsx.
The dashboard component exists now, but the main page still shows the default Next.js starter. You need to render your new component.
- Open app/page.tsx and replace its entire contents with this code:
"use client";
import React from "react";
import TaskDashboard from "./components/TaskDashboard";
// Render the task dashboard as the main page
export default function Home() {
return <TaskDashboard />;
}
- Save page.tsx and refresh your browser at http://localhost:3000.
You should see four tasks grouped under colored priority chips. Two tasks appear under High Priority (in red), one under Medium (amber), and one under Low (green). The "Update project README" task should have a line through its title because it is already marked as completed.
- Click the checkbox next to "Review pull request #42" to toggle it. The title should get a strikethrough style.
Don't see any tasks?
- Check the browser console (F12) for errors. A missing import or a typo in the context path shows up there.
- Confirm that page.tsx imports from ./components/TaskDashboard and that TaskDashboard.tsx imports from ../context/TaskContext. Both paths must match the file locations exactly.
- help me debug my task dashboard not rendering
✔️ Awesome, I've got everything!
Great. Confirm both files are saved before moving on.
ⓧ I'd like to double check the full code
Since TaskDashboard.tsx is a long file, scroll up and confirm both sections are present in order:
- The imports, useTaskContext() call, priority configuration, and the opening of the Card return statement with Tabs and a "Smart Task Manager" heading.
- The priorities.map loop rendering each priority group with Checkbox, ListItemText, category Chip, and the empty-state message at the bottom.
Add the CopilotChat sidebar
The dashboard displays tasks, but there is no way to talk to the AI agent yet. The CopilotChat component from CopilotKit renders a chat interface that connects directly to the runtime you set up in Step 1.
- Open app/page.tsx and replace its entire contents with the following code:
"use client";
import React from "react";
import { Box } from "@mui/material";
import { CopilotChat } from "@copilotkit/react-core/v2";
import TaskDashboard from "./components/TaskDashboard";
// Split-pane layout: task dashboard left, agent chat right
export default function Home() {
return (
<Box sx={{ display: "flex", height: "100vh" }}>
<Box sx={{ flex: 1, overflow: "auto" }}>
<TaskDashboard />
</Box>
<Box sx={{ width: 420, borderLeft: "1px solid", borderColor: "divider", display: "flex", flexDirection: "column" }}>
<CopilotChat
labels={{ title: "Task Agent", initial: "I'm your task assistant. Tell me what to work on!" }}
className="h-full"
/>
</Box>
</Box>
);
}
What changed in the layout?
- The outer Box uses display: "flex" to create a side-by-side split. The task dashboard fills the remaining space on the left, and the chat panel takes a fixed 420px on the right.
- CopilotChat accepts a labels prop. The title sets the panel header and initial sets the welcome message the agent shows before you type anything.
- Save page.tsx and refresh your browser.
You should now see the task dashboard on the left and a chat sidebar on the right. The chat panel header reads "Task Agent" and the welcome message says "I'm your task assistant. Tell me what to work on!"
Before you send a message, what do you think the agent will respond with? It does not have any tools yet, so think about what it can and cannot do.
- Type Hello in the chat input and press Enter.
The agent responds with a greeting. It can hold a conversation, but it has no ability to touch your task list yet. That is exactly what you will fix in the next step.
Chat sidebar not showing?
- Confirm you imported CopilotChat from @copilotkit/react-core/v2. Importing from a different path silently renders nothing.
- Check that your .env file has a valid OPENAI_API_KEY value. If the key is missing or invalid, the agent cannot respond.
- If the chat appears but the agent never responds, open the browser console and look for network errors on the /api/copilotkit route.
- help me debug my CopilotChat sidebar
✔️ Awesome, I've got everything!
Great. Make sure all files are saved before continuing.
ⓧ I'd like to double check the full code
Here is the final page.tsx for this step:
"use client";
import React from "react";
import { Box } from "@mui/material";
import { CopilotChat } from "@copilotkit/react-core/v2";
import TaskDashboard from "./components/TaskDashboard";
export default function Home() {
return (
<Box sx={{ display: "flex", height: "100vh" }}>
<Box sx={{ flex: 1, overflow: "auto" }}>
<TaskDashboard />
</Box>
<Box sx={{ width: 420, borderLeft: "1px solid", borderColor: "divider", display: "flex", flexDirection: "column" }}>
<CopilotChat
labels={{ title: "Task Agent", initial: "I'm your task assistant. Tell me what to work on!" }}
className="h-full"
/>
</Box>
</Box>
);
}
You have a task dashboard and a working chat agent sitting side by side. The agent can talk, but it cannot touch the task list. Next up, you will give the agent real power to add and delete tasks and feel what happens when that power has no guardrails.
Give the Agent Power to Modify Tasks (No Guardrails)
You have a working task dashboard with an AI chat sidebar. The agent responds to messages, but it has no way to actually change your task list.
In this step, you will give the agent real power using CopilotKit's useFrontendTool hook. The agent will be able to add and delete tasks instantly. No approvals, no confirmation dialogs. You will feel the problem this creates.
In this step, get ready to:
- Register frontend tools that let the agent add and delete tasks.
- Experience the problem with unguarded agent actions.
Register frontend tools for adding and deleting tasks
CopilotKit's useFrontendTool hook registers a tool that the agent can call from the chat. You define a name, a Zod schema describing the parameters, and a handler function that runs in the browser.
- Create a new file at app/components/AgentTools.tsx and paste the following code:
"use client";
import React from "react";
import { z } from "zod";
import { useFrontendTool } from "@copilotkit/react-core/v2";
import { useTaskContext } from "../context/TaskContext";
export default function AgentTools() {
const { addTask, deleteTasks } = useTaskContext();
// Register a tool that lets the agent create new tasks
useFrontendTool(
{
name: "addTask",
description: "Add a new task to the task list. Always provide a category and priority.",
parameters: z.object({
title: z.string().describe("The task title"),
category: z.string().describe("Task category: Bug Fix, Feature, Code Review, Documentation, Design, DevOps, Testing, or Other"),
priority: z.enum(["High", "Medium", "Low"]).describe("Task priority level"),
}),
handler: async ({ title, category, priority }) => {
addTask({ title, category, priority, completed: false });
return `Task "${title}" added successfully.`;
},
},
[addTask]
);
return null;
}
What does this code do?
The AgentTools component renders nothing visible. Its job is to register tools with the agent via React hooks.
- useFrontendTool tells CopilotKit "the agent can call a function named addTask." The Zod schema describes what parameters the agent must provide.
- The handler runs in the browser when the agent calls the tool. It passes the values straight to the addTask function from your TaskContext.
- The return string tells the agent what happened, so it can confirm the action to the user in chat.
The component exists but is not rendered in your app yet. You need to add it to page.tsx so the hooks actually run.
- In app/page.tsx, find the import lines at the top of the file. Add this import below the existing ones:
import AgentTools from "./components/AgentTools";
- Scroll down to the JSX. Find the closing </Box> tag that wraps the CopilotChat sidebar. Add <AgentTools /> right after it, just before the final </Box>:
</Box>
<AgentTools />
</Box>
- Save both app/components/AgentTools.tsx and app/page.tsx.
- Switch to the browser and type this message in the chat sidebar: "Add a task called Fix login bug with high priority".
- You should see a new task appear on the dashboard under the High Priority section.
Task not appearing on the dashboard?
- Make sure you saved both files. The AgentTools component must be imported AND rendered in page.tsx.
- Check the browser console for errors. A missing import or a typo in the Zod schema will prevent the tool from registering.
- If the agent says it cannot add tasks, make sure <AgentTools /> is inside the outer <Box> in page.tsx, not outside it.
Help me debug why the agent can't add tasks to my dashboard.
The agent can now create tasks. Next, give it the power to delete them too.
- Return to app/components/AgentTools.tsx. Find the line that says return null; and paste the following code directly above it:
// Register a tool that lets the agent delete tasks
useFrontendTool(
{
name: "deleteTasks",
description: "Delete one or more tasks by their IDs.",
parameters: z.object({
taskIds: z.array(z.string()).describe("IDs of tasks to delete"),
}),
handler: async ({ taskIds }) => {
deleteTasks(taskIds);
return `Deleted ${taskIds.length} task(s).`;
},
},
[deleteTasks]
);
What does the deleteTasks tool do?
This tool takes an array of task IDs and passes them directly to the deleteTasks function from TaskContext. There is no confirmation step. There is no approval card. The tasks are gone the instant the agent calls the handler.
- Save app/components/AgentTools.tsx.
✔️ Awesome, I've got everything!
Great. Double check you have saved both AgentTools.tsx and page.tsx.
ⓧ I'd like to double check the full code
Here is the full AgentTools.tsx file. Compare yours against it:
"use client";
import React from "react";
import { z } from "zod";
import { useFrontendTool } from "@copilotkit/react-core/v2";
import { useTaskContext } from "../context/TaskContext";
export default function AgentTools() {
const { addTask, deleteTasks } = useTaskContext();
useFrontendTool(
{
name: "addTask",
description: "Add a new task to the task list. Always provide a category and priority.",
parameters: z.object({
title: z.string().describe("The task title"),
category: z.string().describe("Task category: Bug Fix, Feature, Code Review, Documentation, Design, DevOps, Testing, or Other"),
priority: z.enum(["High", "Medium", "Low"]).describe("Task priority level"),
}),
handler: async ({ title, category, priority }) => {
addTask({ title, category, priority, completed: false });
return `Task "${title}" added successfully.`;
},
},
[addTask]
);
And the second half:
useFrontendTool(
{
name: "deleteTasks",
description: "Delete one or more tasks by their IDs.",
parameters: z.object({
taskIds: z.array(z.string()).describe("IDs of tasks to delete"),
}),
handler: async ({ taskIds }) => {
deleteTasks(taskIds);
return `Deleted ${taskIds.length} task(s).`;
},
},
[deleteTasks]
);
return null;
}
And here is the full page.tsx:
"use client";
import React from "react";
import { Box } from "@mui/material";
import { CopilotChat } from "@copilotkit/react-core/v2";
import TaskDashboard from "./components/TaskDashboard";
import AgentTools from "./components/AgentTools";
export default function Home() {
return (
<Box sx={{ display: "flex", height: "100vh" }}>
<Box sx={{ flex: 1, overflow: "auto" }}>
<TaskDashboard />
</Box>
<Box
sx={{
width: 420,
borderLeft: "1px solid",
borderColor: "divider",
display: "flex",
flexDirection: "column",
}}
>
<CopilotChat
labels={{ title: "Task Agent", initial: "I'm your task assistant. Tell me what to work on!" }}
className="h-full"
/>
</Box>
<AgentTools />
</Box>
);
}
Test the unguarded agent
Your agent can now add and delete tasks. Time to see what happens when you give an AI agent unchecked delete power.
- Look at your task dashboard. Notice the Update project README task under Low Priority. It has a strikethrough because it is already marked as completed.
Before you type anything, think about this: what do you expect will happen when you ask the agent to delete all completed tasks? Will it ask for confirmation?
- In the chat sidebar, type: "Delete all completed tasks".
The completed task vanishes from the dashboard immediately. No warning dialog. No approval step. No undo. The agent deleted it the instant you asked.
Why is this a problem?
In a real application, an agent with unchecked delete power is dangerous. Imagine this in a production system where "Delete all completed tasks" wipes out records your team still needs for reporting.
This is the core tension of agentic AI. The agent is helpful, but giving it full autonomy over destructive actions is risky. The fix is a pattern called human-in-the-loop: the agent proposes, the human disposes.
You have seen the problem firsthand. An AI agent that acts without asking is a liability. In the next step, you will add a human-in-the-loop approval flow so the agent must pause and ask before deleting anything.
Add Human-in-the-Loop Approval for Destructive Actions
In the last step, you gave the agent the power to modify your task list. That power came with a catch. Tasks vanished the instant the agent decided to delete them, with no warning and no way to stop it.
That is the core problem of unguarded AI autonomy. In this step, you will fix it by replacing the delete tool with CopilotKit's useHumanInTheLoop hook. Instead of acting immediately, the agent will pause, show you exactly what it wants to do, and wait for your approval.
In this step, get ready to:
- Replace the deleteTasks tool with CopilotKit's useHumanInTheLoop hook.
- Build a Material UI approval card that renders inline in the chat.
- Test the full approve and reject flow.
Replace the delete tool with useHumanInTheLoop
CopilotKit's useHumanInTheLoop hook works like useFrontendTool with a built-in pause. When the agent calls the tool, instead of running a handler function immediately, it renders a custom React component in the chat and waits for the user to respond.
What is human-in-the-loop?
Human-in-the-loop (HITL) is a pattern where an AI agent pauses before taking a consequential action and asks a human to approve or reject it. The agent proposes, the human disposes.
The hook manages three states via a ToolCallStatus enum: InProgress (arguments streaming in), Executing (waiting for user response), and Complete (user has responded). You will build a render function that shows different UI for each state.
The first change is updating the imports and context in AgentTools.tsx. You need MUI components for the approval card, plus the useHumanInTheLoop hook and ToolCallStatus enum from CopilotKit.
- In AgentTools.tsx, find the import section at the top of the file:
import React from "react";
import { z } from "zod";
import { useFrontendTool } from "@copilotkit/react-core/v2";
import { useTaskContext } from "../context/TaskContext";
- Replace those four import lines with the following:
import React from "react";
import {
Card, CardContent, Typography, List, ListItem, ListItemText,
Button, Alert, Box, Chip, Skeleton,
} from "@mui/material";
import { z } from "zod";
import { useFrontendTool, useHumanInTheLoop, ToolCallStatus } from "@copilotkit/react-core/v2";
import { useTaskContext } from "../context/TaskContext";
- Next, find the context destructuring line inside the AgentTools function:
const { addTask, deleteTasks } = useTaskContext();
- Replace it with this expanded version that also pulls in tasks and addAuditEntry:
const { addTask, deleteTasks, tasks, addAuditEntry } = useTaskContext();
Now you will remove the old unguarded deleteTasks tool and replace it with the useHumanInTheLoop version. This first chunk sets up the hook with its Zod schema and handles the InProgress state.
- Find the second useFrontendTool block (the one with name: "deleteTasks") and delete it entirely.
- In its place, paste the following useHumanInTheLoop hook:
useHumanInTheLoop(
{
name: "deleteTasks",
description:
"Delete one or more tasks. This is a destructive action that requires human approval.",
parameters: z.object({
taskIds: z.array(z.string()).describe("IDs of tasks to delete"),
taskNames: z
.array(z.string())
.describe("Names of the tasks to delete for display"),
reason: z
.string()
.describe("Why these tasks should be deleted"),
}),
render: ({ args, status, respond, result }) => {
// InProgress: show a loading skeleton while the agent prepares
if (status === ToolCallStatus.InProgress) {
return (
<Card sx={{ m: 1 }}>
<CardContent>
<Skeleton variant="text" width="60%" />
<Skeleton variant="rectangular" height={60} sx={{ mt: 1 }} />
</CardContent>
</Card>
);
}
return null;
},
},
[deleteTasks, tasks, addAuditEntry]
);
What does this code do?
Unlike useFrontendTool, there is no handler function. Instead, the hook provides a render function that receives the agent's proposed arguments and a respond callback. The agent stays paused until your code calls respond().
The Zod schema now includes taskNames and reason alongside taskIds. These give the approval card enough information to show the user exactly what the agent wants to delete and why.
- Save AgentTools.tsx.
- Return to your browser at http://localhost:3000. The page should still load with no errors in the terminal.
Seeing a compilation error?
Check that you kept the addTask useFrontendTool block untouched. Only the second useFrontendTool (for deleteTasks) should be removed and replaced.
Make sure the closing ); of the useHumanInTheLoop call is present. The hook takes two arguments: the config object and the dependency array.
Help me fix a compilation error in AgentTools.tsx after adding useHumanInTheLoop
Handle all three HITL states
The InProgress skeleton is just the loading state. The real interaction happens in the Executing state, where the respond callback becomes available and you render the approval card with Approve and Reject buttons.
- In AgentTools.tsx, find the return null; line inside the render function of useHumanInTheLoop.
- Paste the following code above that return null; line:
// Executing: show the approval card with action buttons
if (status === ToolCallStatus.Executing && respond) {
return (
<Card sx={{ m: 1, border: "1px solid", borderColor: "warning.main" }}>
<CardContent>
<Alert severity="warning" sx={{ mb: 2 }}>
The agent wants to delete tasks
</Alert>
<Typography variant="subtitle2" gutterBottom>
Reason: {args.reason}
</Typography>
<Typography variant="body2" color="text.secondary" gutterBottom>
The following tasks will be permanently removed:
</Typography>
<List dense>
{args.taskNames?.map((name: string, i: number) => (
<ListItem key={i} sx={{ py: 0 }}>
<ListItemText primary={name} />
</ListItem>
))}
</List>
<Box sx={{ display: "flex", gap: 1, mt: 2 }}>
<Button
variant="contained"
color="error"
size="small"
onClick={() => {
deleteTasks(args.taskIds);
respond({ approved: true });
}}
>
Approve Delete
</Button>
</Box>
</CardContent>
</Card>
);
}
What does the Executing state do?
This is the core of the approval flow. The MUI Card shows an Alert warning, the agent's reason for deleting, and a list of the tasks it wants to remove.
Clicking Approve Delete calls deleteTasks() to actually remove the tasks, then respond({ approved: true }) to tell the agent the human approved. You will add the Reject button and audit logging shortly.
- Save AgentTools.tsx and refresh your browser.
- In the chat, type something like "Delete the Update project README task" and press Enter.
- You should see a yellow-bordered approval card appear in the chat with an Approve Delete button.
Not seeing the approval card?
The agent might respond conversationally instead of calling the tool. Try being more direct: "Delete all completed tasks" or "Remove the README task from my list."
Also confirm the Executing block was pasted above the return null; line, not below it.
Help me get the HITL approval card to appear in the chat
The card works for approving, but there is no way to reject. The approval card also needs audit logging and a Complete state to show the outcome after the user decides. Time to add the Reject button, audit entries, and the final status chip.
- In the Executing block you just pasted, find the <Box> that contains the Approve Delete button.
- Replace that entire <Box> section (from <Box sx= through its closing </Box>) with the following version that adds audit logging and the Reject button:
<Box sx={{ display: "flex", gap: 1, mt: 2 }}>
<Button
variant="contained"
color="error"
size="small"
onClick={() => {
deleteTasks(args.taskIds);
addAuditEntry({
timestamp: new Date(),
actionType: "Delete Tasks",
description: `Approved deletion of: ${args.taskNames?.join(", ")}`,
status: "approved",
itemsAffected: args.taskNames || [],
});
respond({ approved: true });
}}
>
Approve Delete
</Button>
<Button
variant="outlined"
size="small"
onClick={() => {
addAuditEntry({
timestamp: new Date(),
actionType: "Delete Tasks",
description: `Rejected deletion of: ${args.taskNames?.join(", ")}`,
status: "rejected",
itemsAffected: args.taskNames || [],
});
respond({ approved: false, reason: "User rejected" });
}}
>
Reject
</Button>
</Box>
What changed here?
Both buttons now call addAuditEntry() before calling respond(). This records every decision in the audit log that you will build in the next step.
The Reject button does not call deleteTasks(). It only records the rejection and tells the agent the human said no.
Finally, add the Complete state so the card shows a result chip after the user decides.
- Find the return null; line at the end of the render function (after the Executing block).
- Paste the following code above that return null; line:
// Complete: show the result as a status chip
if (status === ToolCallStatus.Complete && result) {
const parsed = JSON.parse(result);
return (
<Box sx={{ m: 1 }}>
<Chip
label={parsed.approved ? "Deletion approved" : "Deletion rejected"}
color={parsed.approved ? "success" : "error"}
size="small"
/>
</Box>
);
}
What does the Complete state show?
After the user clicks Approve or Reject, the status advances to Complete. The result string contains the JSON your respond() call passed back. A green chip means approved. A red chip means rejected.
- Save AgentTools.tsx.
I've got everything!
Great. Double check you have saved AgentTools.tsx and move on to testing.
I'd like to double check the full code
Scroll up through your AgentTools.tsx and confirm these sections appear in order:
- The updated imports with MUI components, useHumanInTheLoop, and ToolCallStatus.
- The expanded destructuring with tasks and addAuditEntry.
- The original useFrontendTool for addTask (unchanged).
- The useHumanInTheLoop for deleteTasks with all three states: InProgress (Skeleton), Executing (approval card with both buttons), Complete (result Chip).
- The return null; at the end of the component.
Test the approval flow end-to-end
The approval card is wired up with all three states. Time to feel the difference between the unguarded agent from Step 3 and the guarded version you just built.
- Refresh your browser at http://localhost:3000.
Before you test, think about this. When you type "Delete the login bug task" in the chat, do you think the task will disappear immediately like before, or will something different happen?
- Type "Delete the Fix authentication timeout task" in the chat and press Enter.
Instead of deleting immediately, a styled approval card appears in the chat. It shows which tasks the agent wants to remove, why, and gives you Approve Delete and Reject buttons. The agent is paused, waiting for your decision.
- Click Approve Delete.
- Confirm the task disappears from the dashboard and a green "Deletion approved" chip replaces the card.
Now test the reject path.
- Type "Delete the Review pull request task" in the chat.
- When the approval card appears, click Reject.
- Confirm the task stays on the dashboard and a red "Deletion rejected" chip appears.
Compare this to Step 3
In Step 3, the agent deleted tasks the moment you asked. There was no card, no confirmation, no way to say no. The HITL pattern gives you a gate between the agent's intent and its action.
This is the foundational UX pattern for trustworthy AI agents. The agent proposes, the human disposes.
Tasks deleting without showing the card?
If tasks still vanish immediately, the old useFrontendTool for deleteTasks may still be in your file. Search for handler: async ({ taskIds }) and delete the entire old block if you find it.
Why are tasks still deleting without showing an approval card?
Your agent now asks before it acts. Approvals and rejections are working, and each decision is being recorded in the audit log context behind the scenes. In the next step, you will build the Audit Log UI so you can see that trail of decisions.
Build the Action Audit Log
Your approval flow is working. The agent stops before deleting tasks, and you get to approve or reject. But once you click that button, the decision vanishes from the screen.
Without a record of what the agent proposed and how you responded, there is no accountability. In this step, you will build an Action Audit Log that captures every agent proposal and every human decision in a visible, color-coded timeline.
In this step, get ready to:
- Update the addTask tool so every agent action gets recorded in the audit log.
- Build an Audit Log tab in the dashboard that displays a chronological, color-coded list of every agent proposal and human decision.
Update the addTask tool to log actions
The delete tool already logs audit entries when you approve or reject. The addTask tool does not. Every agent action should be recorded, not just destructive ones.
- In app/components/AgentTools.tsx, find the useFrontendTool call for addTask.
- Replace the entire useFrontendTool block (from useFrontendTool( to its closing );) with this updated version:
useFrontendTool(
{
name: "addTask",
description:
"Add a new task to the task list. Always provide a category and priority.",
parameters: z.object({
title: z.string().describe("The task title"),
category: z
.string()
.describe(
"Task category: Bug Fix, Feature, Code Review, Documentation, Design, DevOps, Testing, or Other"
),
priority: z
.enum(["High", "Medium", "Low"])
.describe("Task priority level"),
// Ask the agent to self-report its confidence in the category
confidence: z
.number()
.min(0)
.max(100)
.describe(
"How confident you are in the category assignment, from 0 to 100"
),
}),
handler: async ({ title, category, priority, confidence }) => {
// Add the task with the confidence score attached
addTask({
title,
category,
priority,
completed: false,
categoryConfidence: confidence,
});
// Log this action in the audit trail
addAuditEntry({
timestamp: new Date(),
actionType: "Add Task",
description: `Added "${title}" [${category}, ${priority}] (confidence: ${confidence}%)`,
status: "auto",
itemsAffected: [title],
});
return `Task "${title}" added successfully with ${priority} priority in ${category} category (confidence: ${confidence}%).`;
},
},
[addTask, addAuditEntry]
);
What changed in this tool?
You added a confidence parameter that asks the agent to report how certain it is about its category assignment (0 to 100). This score gets stored on the task via categoryConfidence.
The handler now calls addAuditEntry with a status of "auto". Adding a task is not destructive, so the agent does it automatically. The audit log records the action anyway so you have a complete trail.
- Save app/components/AgentTools.tsx.
- Switch to your browser and type Add a task called 'Write unit tests' with medium priority in the chat.
- The task should appear on the dashboard. The agent's response should mention the confidence score.
Task not appearing?
Check that you saved the file. The dev server auto-reloads, but only after a save.
Make sure the closing [addTask, addAuditEntry] dependency array includes both functions. A missing dependency can cause stale references.
Help me debug my addTask tool in AgentTools.tsx
Add the Audit Log tab to the dashboard
The audit entries are being created behind the scenes. Now you need a place to display them. You will add a second tab to the TaskDashboard that renders a color-coded list of every logged action.
- In app/components/TaskDashboard.tsx, find the import block at the top of the file.
- Replace the two import statements (the @mui/material import and the useTaskContext import) with this expanded version:
import {
Card,
CardContent,
Typography,
List,
ListItem,
ListItemText,
ListItemIcon,
Checkbox,
Chip,
Box,
Tabs,
Tab,
Divider,
Select,
MenuItem,
FormControl,
} from "@mui/material";
// Icons for color-coded audit log entries
import CheckCircleIcon from "@mui/icons-material/CheckCircle";
import CancelIcon from "@mui/icons-material/Cancel";
import AutorenewIcon from "@mui/icons-material/Autorenew";
import { useTaskContext } from "../context/TaskContext";
What are these new imports?
- Select, MenuItem, and FormControl are Material UI components that prepare the dashboard for category corrections.
- CheckCircleIcon (green), CancelIcon (red), and AutorenewIcon (blue) give each audit log entry a visual status indicator at a glance.
- Next, find the line inside the component that destructures useTaskContext:
const { tasks, toggleTask } = useTaskContext();
- Replace it with this expanded version that pulls in the audit log and category update functions:
const { tasks, auditLog, toggleTask, updateTaskCategory, addAuditEntry } =
useTaskContext();
- Right below the priorityColors object (after its closing };), add this helper function that logs category corrections:
// Logs a category correction to the audit trail
const handleCategoryChange = (taskId: string, newCategory: string) => {
const task = tasks.find((t) => t.id === taskId);
if (task) {
updateTaskCategory(taskId, newCategory);
addAuditEntry({
timestamp: new Date(),
actionType: "Category Correction",
description: `Changed "${task.title}" category from "${task.category}" to "${newCategory}"`,
status: "auto",
itemsAffected: [task.title],
});
}
};
What does handleCategoryChange do?
This function updates a task's category and writes a "Category Correction" entry to the audit log. It is not wired into the UI yet. You will connect it in the Secret Mission.
- Now find the <Tabs> section inside the return statement. It currently has one <Tab>:
<Tabs value={tabIndex} onChange={(_, v) => setTabIndex(v)}>
<Tab label="Tasks" />
</Tabs>
- Add a second <Tab> for the audit log so the block looks like this:
<Tabs value={tabIndex} onChange={(_, v) => setTabIndex(v)}>
<Tab label="Tasks" />
<Tab label={`Audit Log (${auditLog.length})`} />
</Tabs>
Why show the count in the tab label?
The template string `Audit Log (${auditLog.length})` shows a live count of logged actions right on the tab. The learner can see the number tick up each time the agent acts.
- Save app/components/TaskDashboard.tsx and check your browser.
- You should see a second tab labeled Audit Log (0) next to the Tasks tab. Clicking it shows a blank area for now.
Don't see the second tab?
Make sure the new <Tab> sits inside the <Tabs> wrapper, not outside it. Both <Tab> elements must be direct children of <Tabs>.
Help me fix the Tabs layout in my TaskDashboard
The tab appears, but clicking it does not change what is displayed below. You need to wrap the existing task content so it only shows when the first tab is selected, and add the audit log view for the second tab.
- Inside the <CardContent> block, wrap everything (from the <Typography variant="h5"> heading down to the "No tasks yet" fallback) in a conditional block:
{tabIndex === 0 && (
<>
{/* ...all your existing task rendering code stays here unchanged... */}
</>
)}
What does this wrapper do?
The {tabIndex === 0 && (...)} conditional ensures the task list only renders when the Tasks tab is active. The <>...</> fragment wraps the existing JSX without adding extra DOM elements.
- Right after the closing )} of the tabIndex === 0 block (and still inside <CardContent>), add the audit log tab content:
{tabIndex === 1 && (
<>
<Typography variant="h5" gutterBottom>
Action Audit Log
</Typography>
{auditLog.length === 0 ? (
<Typography color="text.secondary">
No actions recorded yet. Interact with the agent to see the log.
</Typography>
) : (
<List dense>
{auditLog.map((entry) => (
<ListItem key={entry.id} sx={{ pl: 0, alignItems: "flex-start" }}>
<ListItemIcon sx={{ minWidth: 36, mt: 0.5 }}>
{entry.status === "approved" && (
<CheckCircleIcon color="success" fontSize="small" />
)}
{entry.status === "rejected" && (
<CancelIcon color="error" fontSize="small" />
)}
{entry.status === "auto" && (
<AutorenewIcon color="info" fontSize="small" />
)}
</ListItemIcon>
What do the three icons represent?
- CheckCircleIcon (green) marks actions the user explicitly approved.
- CancelIcon (red) marks actions the user rejected.
- AutorenewIcon (blue) marks actions the agent performed automatically, like adding tasks.
- Directly below the closing </ListItemIcon> you just added, paste the rest of the audit log entry rendering:
<ListItemText
primary={
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
<Typography variant="body2">{entry.description}</Typography>
<Chip
label={entry.status}
size="small"
color={
entry.status === "approved"
? "success"
: entry.status === "rejected"
? "error"
: "info"
}
/>
</Box>
}
secondary={
<Typography variant="caption" color="text.secondary">
{entry.timestamp.toLocaleTimeString()} - {entry.actionType}
</Typography>
}
/>
</ListItem>
))}
</List>
)}
</>
)}
What does each entry display?
Each audit log item shows three pieces of information. The primary line has the action description (like "Approved deletion of: Fix login bug") plus a color-coded Chip showing the status.
The secondary line shows the timestamp and action type, so you can trace exactly when each decision happened.
- Save app/components/TaskDashboard.tsx.
Before you test, think about what you expect to see. You added a task earlier in this step. Do you think the Audit Log tab will show that entry, or will it be empty?
- Refresh your browser. The page reloads with fresh state, so the audit log starts empty.
- In the chat, type Add a task called 'Deploy to staging' with high priority and send it.
- Click the Audit Log (1) tab. You should see one entry with a blue AutorenewIcon and an auto chip.
- Now ask the agent to delete that task. Approve the deletion in the approval card.
- Switch back to the Audit Log tab. You should see two entries: the deletion with a green approved chip at the top, and the original add below it.
Audit log tab is blank after interacting?
Make sure your useTaskContext destructuring includes auditLog. If the variable is undefined, the map will not render anything.
Also check that the conditional reads tabIndex === 1 (not tabIndex === 0). Both tabs showing the same content means the conditions are identical.
Help me debug my Audit Log tab rendering
✔️ Awesome, I've got everything!
Make sure you have saved both AgentTools.tsx and TaskDashboard.tsx.
ⓧ I'd like to double check the full code
You edited two files in this step. Scroll up and confirm each change is in place, in order:
- AgentTools.tsx: The useFrontendTool block for addTask includes the confidence parameter, calls addAuditEntry in the handler, and has [addTask, addAuditEntry] as the dependency array.
- TaskDashboard.tsx: Imports include Select, MenuItem, FormControl, and the three icon imports.
- TaskDashboard.tsx: The destructuring includes auditLog, updateTaskCategory, and addAuditEntry.
- TaskDashboard.tsx: The handleCategoryChange function exists below priorityColors.
- TaskDashboard.tsx: Two <Tab> elements inside <Tabs>. Task content wrapped in {tabIndex === 0 && (...)}. Audit log content in {tabIndex === 1 && (...)}.
Your task manager now has a complete accountability trail. Every action the agent proposes and every decision you make is recorded and visible. Next up, take on the Secret Mission to add confidence signals that flag uncertain categorizations.
Secret mission
Add Confidence Signals to Task Categorization
Your agent categorizes every task it adds, but how certain is it really? In this mission, you will teach the agent to self-report a confidence score for its category picks. Low-confidence assignments get flagged with an amber chip and an editable dropdown so you can correct the agent on the spot.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally, so there are no ongoing costs. Decide whether to keep your project files, pause the dev server, or delete everything entirely.
What about the OpenAI API charges?
The only charges from this project were a few cents of OpenAI API usage during your chat sessions with the agent. Those are already consumed and do not recur when the dev server is stopped.
Resources you used:
- The smart-task-manager project folder on your Desktop (includes all source code, node_modules, and the .env file containing your OpenAI API key).
Keep everything running
No action needed. Choose this if you're still actively building or experimenting with the agent.
- Your project files remain in the smart-task-manager folder on your Desktop.
- Your OpenAI API key is stored in the .env file inside the project folder. Avoid committing this file to version control.
Pause - I'll come back to this later
Shut down the dev server to free up your terminal. You can resume anytime.
- Press Ctrl+C in the terminal where the dev server is running to stop it.
- To resume later, navigate to the smart-task-manager folder and start the dev server again by running:
cd ~/Desktop/smart-task-manager
npm run dev
Delete - I don't want to use this again
Remove all project files and start fresh. This cannot be undone.
- Stop the dev server by pressing Ctrl+C in the terminal if it is still running.
- Delete the smart-task-manager folder from your Desktop by running the command for your operating system.
macOS
rm -rf ~/Desktop/smart-task-manager
Windows
Remove-Item ~/Desktop/smart-task-manager -Recurse -Force
- Confirm the folder is gone by checking your Desktop. You should no longer see the smart-task-manager folder.
Nice Work!
Nice Work!
You just built a Smart Task Manager where an AI agent proposes and you dispose. You now have a concrete, working implementation of the most important UX pattern in agentic AI: human-in-the-loop approval flows.
You've learned how to:
- Build a Next.js task management app with Material UI and connect an AI agent via CopilotKit that adds, categorizes, and manages tasks through natural conversation.
- Implement the Human-in-the-Loop approval pattern using CopilotKit's useHumanInTheLoop hook with a custom MUI approval card that pauses the agent before destructive actions.
- Build an Action Audit Log that records every agent proposal and human decision for full accountability.
- Secret Mission: Add Confidence Signals that flag uncertain categorizations and let the user correct them.
Ready to quiz yourself?