Tutorial: Build a Meeting Scheduler with Claude Tool Use
Build a scheduling assistant with strict tools, a bounded tool loop, parallel read-only calls, and a human approval gate before any event is created.
On this page· What you will build
Why this matters
Tool use shows the loop: the model asks for a tool, your code runs it, the result goes back. Scheduling is a perfect place to practice it, because it has both kinds of tools every real product has: reads that are safe to run as often as the model likes (what is on my calendar, when is everyone free) and a write that changes the world (send an invitation to four people). This tutorial builds a terminal assistant that runs reads in parallel, never writes without asking, and re-checks the calendar at the moment of writing, so a stale suggestion cannot double-book anyone.
What you will build#
you › When can I meet Grace for 30 minutes on 2026-10-20 or 2026-10-21?
you › Book "Roadmap chat" with Grace on 2026-10-20 at 11:00
Create "Roadmap chat" at 2026-10-20T11:00 UTC for 30 min with grace@example.com?
confirm (y/n) › y- An in-memory calendar with working hours and free-slot search.
- Three strict tools,
list_events,find_free_slots, andcreate_event, with Zod validation behind them. - A hand-written tool loop with a step limit, parallel reads, and an approval gate for writes.
- A chat CLI that keeps the conversation across requests.
Hover or tap a component to see what it does.
Show text descriptionHide text description
Reads are free to run; writes wait for a person, and are re-checked at the moment they happen.
- User
- Tool loop: Sends the conversation and tools, runs the requested tools, and returns one tool_result per tool_use.
- Claude: strict tools
- Read tools: list_events and find_free_slots, scoped to the signed-in user.
- Approval: The user confirms each event; a decline goes back to the model as a normal result.
- Calendar: Availability is re-checked at creation time, so a stale proposal cannot double-book.
You need Node.js 22.18 or later and an Anthropic API key.
1. Set up the project#
mkdir scheduler && cd scheduler
npm init -y
npm pkg set type=module
npm install @anthropic-ai/sdk zod
npm install -D typescript @types/node
mkdir src test
npm pkg set scripts.start="node --env-file=.env src/cli.ts" scripts.test="node --test" scripts.typecheck="tsc"Copy the tsconfig.json from Foundations Tutorial 1 and add your key to .env.
2. The calendar#
A stand-in for a calendar API. Times are UTC within a day, which keeps the example free of time-zone arithmetic.
// An in-memory calendar standing in for a calendar API. Times are UTC, "HH:MM" within a day.
export interface CalendarEvent {
id: string;
title: string;
date: string; // YYYY-MM-DD
start: string; // HH:MM
durationMinutes: number;
attendees: string[]; // emails, including the organizer
}
const WORK_START = 9 * 60;
const WORK_END = 17 * 60;
const events: CalendarEvent[] = [
{ id: "evt_1", title: "Standup", date: "2026-10-20", start: "09:00", durationMinutes: 30, attendees: ["ada@example.com", "grace@example.com", "alan@example.com"] },
{ id: "evt_2", title: "Design review", date: "2026-10-20", start: "13:00", durationMinutes: 90, attendees: ["ada@example.com", "grace@example.com"] },
{ id: "evt_3", title: "Hiring sync", date: "2026-10-20", start: "10:00", durationMinutes: 60, attendees: ["alan@example.com"] },
{ id: "evt_4", title: "1:1 with Linus", date: "2026-10-21", start: "11:00", durationMinutes: 30, attendees: ["ada@example.com", "linus@example.com"] },
];
const toMinutes = (hhmm: string) => Number(hhmm.slice(0, 2)) * 60 + Number(hhmm.slice(3, 5));
const toHHMM = (m: number) => `${String(Math.floor(m / 60)).padStart(2, "0")}:${String(m % 60).padStart(2, "0")}`;
export function listEvents(person: string, date: string): CalendarEvent[] {
return events.filter((e) => e.date === date && e.attendees.includes(person)).sort((a, b) => a.start.localeCompare(b.start));
}
/** Start times within working hours when every attendee is free for `durationMinutes`. */
export function freeSlots(date: string, durationMinutes: number, attendees: string[], step = 30): string[] {
const busy = events
.filter((e) => e.date === date && e.attendees.some((a) => attendees.includes(a)))
.map((e) => [toMinutes(e.start), toMinutes(e.start) + e.durationMinutes] as const);
const slots: string[] = [];
for (let start = WORK_START; start + durationMinutes <= WORK_END; start += step) {
const end = start + durationMinutes;
if (busy.every(([s, e]) => end <= s || start >= e)) slots.push(toHHMM(start));
}
return slots;
}
export function createEvent(input: Omit<CalendarEvent, "id">): CalendarEvent {
const event = { id: `evt_${events.length + 1}`, ...input };
events.push(event);
return event;
}The availability arithmetic lives in your code, not in the model. The model decides what to ask for; deterministic code answers.
3. The tools#
import type Anthropic from "@anthropic-ai/sdk";
import { z } from "zod";
import { createEvent, freeSlots, listEvents } from "./calendar.ts";
const email = { type: "string", description: "Email address, such as grace@example.com" } as const;
const date = { type: "string", format: "date", description: "Day in YYYY-MM-DD" } as const;
export const tools: Anthropic.Tool[] = [
{
name: "list_events",
description: "List the current user's events on one day, with start time (UTC), duration, and attendees.",
strict: true,
input_schema: { type: "object", properties: { date }, required: ["date"], additionalProperties: false },
},
{
name: "find_free_slots",
description:
"Find start times on one day, within working hours 09:00-17:00 UTC, when the current user and every listed attendee are free. " +
"Call it before proposing any meeting time.",
strict: true,
input_schema: {
type: "object",
properties: {
date,
duration_minutes: { type: "integer", description: "Meeting length in minutes, such as 30" },
attendees: { type: "array", items: email, description: "Other attendees; the current user is added automatically" },
},
required: ["date", "duration_minutes", "attendees"],
additionalProperties: false,
},
},
{
name: "create_event",
description:
"Create a calendar event and invite the attendees. Only call it for a time find_free_slots returned. " +
"The user is asked to confirm before the event is created.",
strict: true,
input_schema: {
type: "object",
properties: {
title: { type: "string" },
start: { type: "string", description: "Start in YYYY-MM-DDTHH:MM, UTC" },
duration_minutes: { type: "integer" },
attendees: { type: "array", items: email },
},
required: ["title", "start", "duration_minutes", "attendees"],
additionalProperties: false,
},
},
];
// strict: true guarantees the shape; Zod still checks the values the schema cannot express.
const Day = z.iso.date();
const Emails = z.array(z.email()).max(20);
const ListInput = z.object({ date: Day });
const SlotsInput = z.object({ date: Day, duration_minutes: z.number().int().min(15).max(480), attendees: Emails });
export const CreateInput = z.object({
title: z.string().trim().min(1).max(120),
start: z.string().regex(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/, "start must look like 2026-10-20T14:00"),
duration_minutes: z.number().int().min(15).max(480),
attendees: Emails,
});
export type CreateInput = z.infer<typeof CreateInput>;
const parse = <T>(schema: z.ZodType<T>, input: unknown): T => {
const result = schema.safeParse(input);
if (!result.success) throw new Error(result.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; "));
return result.data;
};
/** Run a read-only tool for `user`. Errors are thrown with a message the model can act on. */
export function runReadTool(name: string, input: unknown, user: string): string {
switch (name) {
case "list_events": {
const { date } = parse(ListInput, input);
return JSON.stringify(listEvents(user, date));
}
case "find_free_slots": {
const { date, duration_minutes, attendees } = parse(SlotsInput, input);
return JSON.stringify({ date, slots: freeSlots(date, duration_minutes, [user, ...attendees]) });
}
default:
throw new Error(`Unknown tool: ${name}`);
}
}
/** Create the event once a person approved it. The time is re-checked: the calendar may have changed. */
export function runCreate(input: CreateInput, user: string): string {
const [date, start] = input.start.split("T") as [string, string];
const attendees = [...new Set([user, ...input.attendees])];
if (!freeSlots(date, input.duration_minutes, attendees, 15).includes(start)) {
throw new Error(`${input.start} is no longer free for everyone; call find_free_slots again`);
}
return JSON.stringify(createEvent({ title: input.title, date, start, durationMinutes: input.duration_minutes, attendees }));
}How the tools are designed matters as much as how they run:
- Descriptions say when to use each tool ("Call it before proposing any meeting time") and state the units (UTC, minutes). The model reads them like documentation.
strict: trueguarantees the input matches the schema exactly. It does not check meaning (a 5-minute meeting is valid JSON), so Zod checks the values, and its error messages go back to the model in words it can act on.- The user is never a parameter. Every tool runs as the signed-in user passed in by your code, so the model cannot ask for someone else's calendar.
runCreatere-checks availability at write time. The slot the model saw may have been taken since; the write is the moment of truth.
4. The loop#
import Anthropic from "@anthropic-ai/sdk";
import { CreateInput, runCreate, runReadTool, tools } from "./tools.ts";
const client = new Anthropic();
const MAX_STEPS = 6; // a hard limit, so a confused model cannot loop forever
export type Approve = (proposal: CreateInput) => Promise<boolean>;
const system = (user: string, today: string) =>
`You are a scheduling assistant for ${user}. Today is ${today}. All times are UTC. ` +
"Use the tools to check calendars; never guess availability. Before booking, call find_free_slots and use one of its times.";
/**
* Run one user request through the tool loop. `messages` is the whole conversation and is
* extended in place, so the next request remembers this one.
*/
export async function respond(messages: Anthropic.MessageParam[], user: string, today: string, approve: Approve): Promise<string> {
for (let step = 0; step < MAX_STEPS; step++) {
const message = await client.messages.create({
model: "claude-opus-5-5",
max_tokens: 4096,
output_config: { effort: "low" },
system: system(user, today),
tools,
messages,
});
messages.push({ role: "assistant", content: message.content }); // exactly as returned
if (message.stop_reason !== "tool_use") {
return message.content.flatMap((b) => (b.type === "text" ? [b.text] : [])).join("");
}
const calls = message.content.filter((b): b is Anthropic.ToolUseBlock => b.type === "tool_use");
const results: Anthropic.ToolResultBlockParam[] = [];
// Read-only calls run in parallel. A write waits for a person, one at a time.
const reads = calls.filter((c) => c.name !== "create_event");
results.push(
...(await Promise.all(
reads.map(async (call): Promise<Anthropic.ToolResultBlockParam> => {
try {
return { type: "tool_result", tool_use_id: call.id, content: runReadTool(call.name, call.input, user) };
} catch (error) {
return { type: "tool_result", tool_use_id: call.id, content: String(error), is_error: true };
}
}),
)),
);
for (const call of calls.filter((c) => c.name === "create_event")) {
const parsed = CreateInput.safeParse(call.input);
if (!parsed.success) {
results.push({ type: "tool_result", tool_use_id: call.id, content: `Invalid event: ${parsed.error.issues[0]?.message}`, is_error: true });
} else if (!(await approve(parsed.data))) {
// Not an error: the model should tell the user, not retry.
results.push({ type: "tool_result", tool_use_id: call.id, content: "The user declined. Do not create this event; ask what they would like instead." });
} else {
try {
results.push({ type: "tool_result", tool_use_id: call.id, content: runCreate(parsed.data, user) });
} catch (error) {
results.push({ type: "tool_result", tool_use_id: call.id, content: String(error), is_error: true });
}
}
}
// Every tool_use gets exactly one tool_result, all in one user message.
messages.push({ role: "user", content: results });
}
return `I stopped after ${MAX_STEPS} steps without finishing. Please rephrase or narrow the request.`;
}The rules of the loop, each one a line above:
- The assistant turn is stored exactly as returned, so its
tool_useblocks (and any thinking) are replayed unchanged. - Every
tool_usegets exactly onetool_result, and all results go back in one user message. Splitting them across messages teaches the model to stop calling tools in parallel. - Errors are results, not exceptions.
is_error: truelets the model read what went wrong and try again or explain. - A decline is not an error. It is the user's answer; the result says so plainly and tells the model what to do next.
MAX_STEPSbounds cost and time whatever the model does.
5. The CLI#
import { createInterface } from "node:readline/promises";
import type Anthropic from "@anthropic-ai/sdk";
import { respond } from "./assistant.ts";
const USER = "ada@example.com"; // from your session in a real app
const TODAY = "2026-10-19";
const rl = createInterface({ input: process.stdin, output: process.stdout });
const messages: Anthropic.MessageParam[] = [];
console.log(`Scheduling for ${USER}. Ask about your calendar, or "exit".`);
while (true) {
let input: string;
try {
input = (await rl.question("\nyou › ")).trim();
} catch {
break; // input closed
}
if (!input || input === "exit") break;
messages.push({ role: "user", content: input });
const reply = await respond(messages, USER, TODAY, async (p) => {
console.log(`\n Create "${p.title}" at ${p.start} UTC for ${p.duration_minutes} min with ${p.attendees.join(", ") || "nobody else"}?`);
return /^y(es)?$/i.test((await rl.question(" confirm (y/n) › ")).trim());
});
console.log(`\nassistant › ${reply}`);
}
rl.close();The approval function is just a parameter of respond. Here it asks in the terminal; in a web app it would pause the run and wait for a button click, which is exactly what the human-in-the-loop article builds.
6. Talk to it#
npm startTry this sequence and watch which tools run:
- "What's on my calendar on 2026-10-20?" One
list_eventscall; the answer mentions the standup and the design review. - "When can I meet Grace for 30 minutes on 2026-10-20 or 2026-10-21?" Two
find_free_slotscalls in the same turn, run in parallel, answered together. - 'Book "Roadmap chat" with Grace on 2026-10-20 at 11:00', then y. The event is created.
- 'Book "Retro" with Grace on 2026-10-20 at 13:00', then y. You approved, but the design review occupies that time:
runCreaterefuses, the error goes back to the model, and it should propose another time. - 'Book "Lunch" with Alan on 2026-10-21 at 12:00', then n. Nothing is created; the model asks what you would like instead.
The tool calls, approvals, the refused double-booking, and the decline were all exercised end to end against a mock of the API. The exact wording of the real model's answers will differ.
7. Test the tools#
import assert from "node:assert/strict";
import { test } from "node:test";
import { freeSlots, listEvents } from "../src/calendar.ts";
import { runCreate, runReadTool } from "../src/tools.ts";
test("free slots skip everyone's busy time and stay within working hours", () => {
// Ada: standup 09:00-09:30, design review 13:00-14:30. Alan: standup, hiring sync 10:00-11:00.
const slots = freeSlots("2026-10-20", 60, ["ada@example.com", "alan@example.com"]);
assert.deepEqual(slots, ["11:00", "11:30", "12:00", "14:30", "15:00", "15:30", "16:00"]);
});
test("events are listed only for the person asking", () => {
assert.deepEqual(listEvents("linus@example.com", "2026-10-20"), []);
assert.equal(listEvents("ada@example.com", "2026-10-20").length, 2);
});
test("tool inputs are validated beyond the schema, with errors the model can act on", () => {
assert.throws(() => runReadTool("find_free_slots", { date: "2026-10-20", duration_minutes: 5, attendees: [] }, "ada@example.com"), /duration_minutes/);
assert.throws(() => runReadTool("list_events", { date: "next tuesday" }, "ada@example.com"), /date/);
assert.throws(() => runReadTool("delete_everything", {}, "ada@example.com"), /Unknown tool/);
});
test("creating re-checks availability, so a stale proposal cannot double-book", () => {
const input = { title: "Planning", start: "2026-10-22T10:00", duration_minutes: 30, attendees: ["grace@example.com"] };
assert.match(runCreate(input, "ada@example.com"), /"title":"Planning"/);
assert.throws(() => runCreate(input, "ada@example.com"), /no longer free/);
});npm test # 4 passing, no API callsCommon mistakes#
- Letting the model pass the user's identity. Scope every tool to the authenticated user in code.
- Writes without a gate. Anything that sends, pays, deletes, or invites should wait for a person, at least until you have evidence it can be trusted.
- Trusting the model's earlier read at write time. Re-check at the moment of the write; state changes.
- Throwing tool errors out of the loop. Return them as
is_errorresults so the model can recover. - Results split across several messages, or missing for some calls. One
tool_resultpertool_use, all in one user message. - No step limit. A confused model can call tools in circles. Cap the loop and say so when you stop.
Try it
Extend the scheduler
- Add a
cancel_eventtool behind the same approval gate, scoped so a user can only cancel events they attend. - Support time zones: store a zone per user and convert in the tools, keeping the model's view in the user's local time.
- Replace the loop for read-only questions with the SDK's tool runner and keep the hand-written loop only when
create_eventis involved. - Log every tool call with its input, duration, and outcome, and count how many steps typical requests take.
Check your understanding
Key takeaways
- Separate read tools, which can run freely and in parallel, from write tools, which wait for approval.
- Use
strict: truefor the shape and Zod for the values, and return errors as results the model can read. - Re-check state at the moment of a write, and scope every tool to the authenticated user.
- Bound the loop, keep one
tool_resultpertool_usein one message, and store assistant turns exactly as returned.
Next steps#
Finished this article? Track it to see your progress on the roadmap.
Related articles
- Tool Use (Function Calling) with Claude in TypeScriptLet the model call your functions: define tools, run the tool-use loop by hand, handle errors and parallel calls, then use the SDK tool runner with Zod-typed tools.
- Human-in-the-Loop Agents: Approvals, Pauses, and Resumable RunsPause an agent before risky actions, persist the run, ask a person to approve, edit, or reject, and resume with the right tool results, in TypeScript and Next.js.