How to Add Tools
Give your AI chatbot the ability to call APIs
A tool is a function your AI chatbot can call. When a user asks a question, the AI decides if it needs a tool, calls it with the right parameters, gets the result, and uses that to answer.
Example: user asks "Who accessed Server Room B last night?" → AI calls your searchAccessLogs tool → tool fetches data from the Arena API → AI reads the result and tells the user.
SETUP
Environment variables
Before you start: copy .env.example to .env.local and fill in these values:
# Generate a random secret: openssl rand -base64 32 AUTH_SECRET=your-random-secret-here # The Arena URL — used by your tools to call the challenge APIs ARENA_BASE_URL=https://arena-murex.vercel.app # OpenRouter API key — log in at https://tinyfish-hackerschool.vercel.app to get yours OPENROUTER_API_KEY=your-openrouter-key-here
AUTH_SECRET— runopenssl rand -base64 32in your terminal to generate oneARENA_BASE_URL— this is where the Arena API lives. All your tools call this.OPENROUTER_API_KEY— log in at tinyfish-hackerschool.vercel.app to get your key
Part 1: Create a Tool
TOOL STRUCTURE
Every tool file looks like this
Create a new file in lib/ai/tools/. Every tool has the same structure — 2 imports and 3 parts:
import { tool } from "ai";
import { z } from "zod";
export const yourToolName = tool({
description: "...", // YOU write this — tells the AI when to use the tool
inputSchema: z.object({ // Copy from the reference below
// ...
}),
execute: async (input) => { // Copy from the reference below
// ...
},
});- descriptionYou write this. This is how the AI decides when to use your tool. Be specific — tell the AI what the tool does and what kind of questions it helps answer.
- inputSchemaDefines what parameters the tool accepts. Copy this from the tool reference sections below.
- executeThe function that runs when the AI calls the tool. Copy this from the tool reference sections below.
Part 2: Wire It Up
After creating your tool file, you need to connect it to the chatbot. There are 3 files to update. Follow the comments in each file — they say WORKSHOP.
Register the type
lib/types.ts
Add 3 lines. Look at how getWeather is already done as an example:
// 1. Import your tool (at the top, next to the getWeather import):
import type { yourToolName } from "./ai/tools/your-tool-name";
// 2. Create a type alias (below the existing weatherTool type):
type yourToolType = InferUITool<typeof yourToolName>;
// 3. Add it to ChatTools (inside the existing type):
export type ChatTools = {
getWeather: weatherTool;
yourToolName: yourToolType; // ← add this line
};Import and add to the chat route
app/(chat)/api/chat/route.ts
Two changes in this file:
// 1. Import your tool (near the top, find the WORKSHOP comment):
import { yourToolName } from "@/lib/ai/tools/your-tool-name";
// 2. In the streamText() call, uncomment the tools block and add yours:
tools: {
yourToolName: yourToolName,
// add more tools here as you build them...
},
// 3. Also uncomment this line (right below tools) to let the AI
// call multiple tools in a row:
stopWhen: stepCountIs(5),Update the system prompt
lib/ai/prompts.ts
Edit the regularPrompt string to tell the AI what tools it has. This helps the AI know when to call them. Example:
export const regularPrompt = `You are a NEXUS Corp security investigator. You have the following tools: - searchAccessLogs: Search facility access logs by employee, location, and time range When asked about who accessed a location or when someone entered a facility, use the searchAccessLogs tool to look it up.`;
This is just a text string — write whatever makes sense. The more specific you are about when to use each tool, the better the AI will be at using them.
Add the tool to the chat UI
components/message.tsx — show tool calls in the conversation
When your AI calls a tool, the chat needs to know how to display it. Open components/message.tsx and find the comment that says WORKSHOP: Add your tool UI rendering here. This is where you tell the chatbot what to show when a tool runs.
Use this Claude Code prompt to do it for you:
I just added new tools to my chatbot. Now I need to render them in the chat UI. In components/message.tsx, there's a comment that says "WORKSHOP: Add your tool UI rendering here" (around line 276). For each tool I've registered in the tools object in route.ts, add a rendering block there. Use the existing getWeather tool rendering (the "tool-getWeather" block above the workshop comment) as a reference for the pattern. For my new tools, keep it simple — just show the default Tool/ToolHeader/ToolInput/ToolOutput components (no need for custom UI like the Weather component). Make sure to handle the "output-available" state to show results.
Run this prompt every time you add new tools. It adds the rendering code so tool calls show up in the chat with their parameters and results.
Restart your dev server (pnpm dev) and try chatting. Repeat these steps each time you add a new tool.
Part 3: Tool Reference
Copy the inputSchema and execute for each tool you need. You write the description yourself.
Round 1 — Find the Insider
1 tool needed
searchAccessLogs
lib/ai/tools/search-access-logs.ts — calls /api/logs
You write the description — copy the inputSchema and execute below:
inputSchema: z.object({
employeeId: z
.string()
.describe("Filter by employee ID, e.g. EMP-047")
.optional(),
location: z
.string()
.describe("Filter by location name, e.g. 'Server Room B'")
.optional(),
startTime: z
.string()
.describe("Filter logs after this ISO timestamp, e.g. 2045-03-15T00:00:00Z")
.optional(),
endTime: z
.string()
.describe("Filter logs before this ISO timestamp, e.g. 2045-03-16T00:00:00Z")
.optional(),
}),
execute: async (input) => {
const params = new URLSearchParams();
if (input.employeeId) params.set("employeeId", input.employeeId);
if (input.location) params.set("location", input.location);
if (input.startTime) params.set("startTime", input.startTime);
if (input.endTime) params.set("endTime", input.endTime);
const response = await fetch(
`${process.env.ARENA_BASE_URL}/api/logs?${params.toString()}`
);
const data = await response.json();
return data;
},Round 2 — Trace Communications
1 new tool
searchCommunications
lib/ai/tools/search-communications.ts — calls /api/communications
You write the description — copy the inputSchema and execute below:
inputSchema: z.object({
senderId: z
.string()
.describe("Filter by participant employee ID (matches sender or recipient), e.g. EMP-047")
.optional(),
recipientId: z
.string()
.describe("Filter by the other participant employee ID")
.optional(),
channel: z
.string()
.describe("Filter by channel: 'email', 'internal-chat', or 'encrypted'")
.optional(),
search: z
.string()
.describe("Full-text search in subject and body")
.optional(),
startDate: z
.string()
.describe("Filter messages after this ISO date")
.optional(),
endDate: z
.string()
.describe("Filter messages before this ISO date")
.optional(),
page: z
.number()
.describe("Page number (default: 1)")
.optional(),
pageSize: z
.number()
.describe("Results per page (default: 20)")
.optional(),
}),
execute: async (input) => {
const params = new URLSearchParams();
if (input.senderId) params.set("senderId", input.senderId);
if (input.recipientId) params.set("recipientId", input.recipientId);
if (input.channel) params.set("channel", input.channel);
if (input.search) params.set("search", input.search);
if (input.startDate) params.set("startDate", input.startDate);
if (input.endDate) params.set("endDate", input.endDate);
if (input.page) params.set("page", input.page.toString());
if (input.pageSize) params.set("pageSize", input.pageSize.toString());
const response = await fetch(
`${process.env.ARENA_BASE_URL}/api/communications?${params.toString()}`
);
const data = await response.json();
return data;
},Round 3 — Map the Network
1 new tool + reuses searchCommunications
getEmployee
lib/ai/tools/get-employee.ts — calls /api/employees/{id}
You write the description — copy the inputSchema and execute below:
inputSchema: z.object({
employeeId: z
.string()
.describe("The employee ID, e.g. EMP-023"),
}),
execute: async (input) => {
const response = await fetch(
`${process.env.ARENA_BASE_URL}/api/employees/${input.employeeId}`
);
const data = await response.json();
return data;
},Round 4 — Locate the Threat
3 new tools
getFacilityPolicies
lib/ai/tools/get-facility-policies.ts — calls /api/facilities/policies
You write the description — copy the inputSchema and execute below:
inputSchema: z.object({
category: z
.string()
.describe("Filter by category: 'access', 'security', 'facilities', or 'emergency'")
.optional(),
}),
execute: async (input) => {
const params = new URLSearchParams();
if (input.category) params.set("category", input.category);
const response = await fetch(
`${process.env.ARENA_BASE_URL}/api/facilities/policies?${params.toString()}`
);
const data = await response.json();
return data;
},searchBookings
lib/ai/tools/search-bookings.ts — calls /api/facilities/bookings
You write the description — copy the inputSchema and execute below:
inputSchema: z.object({
roomId: z
.string()
.describe("Filter by room ID, e.g. F-12")
.optional(),
bookedBy: z
.string()
.describe("Filter by employee ID who made the booking, e.g. EMP-023")
.optional(),
startDate: z
.string()
.describe("Filter bookings starting after this date")
.optional(),
endDate: z
.string()
.describe("Filter bookings ending before this date")
.optional(),
page: z
.number()
.describe("Page number (default: 1)")
.optional(),
}),
execute: async (input) => {
const params = new URLSearchParams();
if (input.roomId) params.set("roomId", input.roomId);
if (input.bookedBy) params.set("bookedBy", input.bookedBy);
if (input.startDate) params.set("startDate", input.startDate);
if (input.endDate) params.set("endDate", input.endDate);
if (input.page) params.set("page", input.page.toString());
const response = await fetch(
`${process.env.ARENA_BASE_URL}/api/facilities/bookings?${params.toString()}`
);
const data = await response.json();
return data;
},getRoomDetails
lib/ai/tools/get-room-details.ts — calls /api/facilities/rooms/{id}
You write the description — copy the inputSchema and execute below:
inputSchema: z.object({
roomId: z
.string()
.describe("The room ID, e.g. F-12"),
}),
execute: async (input) => {
const response = await fetch(
`${process.env.ARENA_BASE_URL}/api/facilities/rooms/${input.roomId}`
);
const data = await response.json();
return data;
},Round 5 — Secure the Server
3 new tools + reuses getEmployee and getRoomDetails
getServerDetails
lib/ai/tools/get-server-details.ts — calls /api/systems/{id}
You write the description — copy the inputSchema and execute below:
inputSchema: z.object({
serverId: z
.string()
.describe("The server ID, e.g. NX-7042"),
}),
execute: async (input) => {
const response = await fetch(
`${process.env.ARENA_BASE_URL}/api/systems/${input.serverId}`
);
const data = await response.json();
return data;
},listSecurityPatches
lib/ai/tools/list-security-patches.ts — calls /api/systems/patches
You write the description — copy the inputSchema and execute below:
inputSchema: z.object({
severity: z
.string()
.describe("Filter by severity: 'critical', 'high', 'medium', or 'low'")
.optional(),
systemType: z
.string()
.describe("Filter by target system type: 'compute', 'storage', 'network', or 'backup'")
.optional(),
targetFirmware: z
.string()
.describe("Filter by target firmware version, e.g. '3.2.1'")
.optional(),
}),
execute: async (input) => {
const params = new URLSearchParams();
if (input.severity) params.set("severity", input.severity);
if (input.systemType) params.set("systemType", input.systemType);
if (input.targetFirmware) params.set("targetFirmware", input.targetFirmware);
const response = await fetch(
`${process.env.ARENA_BASE_URL}/api/systems/patches?${params.toString()}`
);
const data = await response.json();
return data;
},applySecurityPatch
lib/ai/tools/apply-security-patch.ts — calls POST /api/systems/control
You write the description — copy the inputSchema and execute below:
inputSchema: z.object({
serverId: z
.string()
.describe("The server ID to patch, e.g. NX-7042"),
patchId: z
.string()
.describe("The patch ID to apply, e.g. PATCH-2045-0312"),
authCode: z
.string()
.describe("Authorization code in format [Badge Number]-[Room Code], e.g. BDG-1234-F-12"),
justification: z
.string()
.describe("Written justification for applying the patch"),
}),
execute: async (input) => {
const response = await fetch(
`${process.env.ARENA_BASE_URL}/api/systems/control`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
serverId: input.serverId,
patchId: input.patchId,
authCode: input.authCode,
justification: input.justification,
}),
}
);
const data = await response.json();
return data;
},TIPS
- [1]Add tools as you go. You don't need all 9 tools at once. Add the tools for your current round, wire them up, test, then move on.
- [2]Descriptions matter a lot. The AI uses your description to decide when to call the tool. If the AI isn't using your tool, try rewriting the description to be more specific.
- [3]The system prompt matters too. Experiment with it. Tell the AI what role it plays, what tools it has, and when to use them. A good system prompt makes the AI much more effective.
- [4]The AI calls tools automatically. You don't write any code to "call" the tool. The AI decides when to use it based on the user's question, the tool's description, and the system prompt.
- [5]Check the Challenges page for API details. Each challenge lists the endpoints, parameters, and example responses if you want to understand what the tools do under the hood.