Skip to content
AI Full-Stack Wiki

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.

Difficulty: Intermediate22 min readUpdated
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, and create_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.
Meeting scheduler tool loopThe tool loop sends the conversation and three tools to Claude. Read-only tool calls run in parallel; creating an event waits for the user's approval and re-checks availability before writing to the calendar. The loop stops after six steps at most.UserTool loopmax 6 stepsClaudestrict toolsRead toolsin parallelApprovalcreate_eventCalendar

Hover or tap a component to see what it does.

Meeting scheduler: a bounded tool loop runs read-only calendar tools in parallel and pauses for the user's approval before creating an event.
Show 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.

src/calendar.ts
// 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#

src/tools.ts
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: true guarantees 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.
  • runCreate re-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#

src/assistant.ts
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_use blocks (and any thinking) are replayed unchanged.
  • Every tool_use gets exactly one tool_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: true lets 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_STEPS bounds cost and time whatever the model does.

5. The CLI#

src/cli.ts
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 start

Try this sequence and watch which tools run:

  1. "What's on my calendar on 2026-10-20?" One list_events call; the answer mentions the standup and the design review.
  2. "When can I meet Grace for 30 minutes on 2026-10-20 or 2026-10-21?" Two find_free_slots calls in the same turn, run in parallel, answered together.
  3. 'Book "Roadmap chat" with Grace on 2026-10-20 at 11:00', then y. The event is created.
  4. 'Book "Retro" with Grace on 2026-10-20 at 13:00', then y. You approved, but the design review occupies that time: runCreate refuses, the error goes back to the model, and it should propose another time.
  5. '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#

test/tools.test.ts
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 calls

Common 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_error results so the model can recover.
  • Results split across several messages, or missing for some calls. One tool_result per tool_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

  1. Add a cancel_event tool behind the same approval gate, scoped so a user can only cancel events they attend.
  2. Support time zones: store a zone per user and convert in the tools, keeping the model's view in the user's local time.
  3. Replace the loop for read-only questions with the SDK's tool runner and keep the hand-written loop only when create_event is involved.
  4. Log every tool call with its input, duration, and outcome, and count how many steps typical requests take.

Check your understanding

The model asks for find_free_slots on two dates in one turn. How should the loop return the results?
The user approved an event, but the slot was taken a moment earlier. What happens?
Why is the user's email not a parameter of any tool?

Key takeaways

  • Separate read tools, which can run freely and in parallel, from write tools, which wait for approval.
  • Use strict: true for 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_result per tool_use in one message, and store assistant turns exactly as returned.

Next steps#

Finished this article? Track it to see your progress on the roadmap.