Skip to content

feat(typescript/02-custom-tools): turn-by-turn calendar assistant with clash handling and configurable settings - #318

Open
schinchli wants to merge 1 commit into
strands-agents:mainfrom
schinchli:feat/ts-02-interactive-calendar-pr
Open

schinchli wants to merge 1 commit into
strands-agents:mainfrom
schinchli:feat/ts-02-interactive-calendar-pr

Conversation

@schinchli

@schinchli schinchli commented Sep 30, 2026 •

Copy link
Copy Markdown
Contributor

Summary

This turns typescript/01-learn/02-custom-tools from three fixed requests into a calendar assistant you can talk to turn by turn. It adds a guided demo that hands over to you, a clash-aware calendar that never double-books, and settings for everything shown in the header (model, region, calendar file, time zone, a fixed "now" for demos, default length and working hours).

It stays a custom-tools tutorial. Every new behaviour is still a plain tool() backed by SQLite, and the README now teaches one more idea: tools that return decisions, not just data.

Depends on #317. The first commit here (fix(typescript/02-custom-tools): install the SDK from npm…) is the same 02 fix as in #317; without it this sample doesn't install. Once #317 merges I'll rebase, and this PR will contain only the four commits below. Please review those four.

Refs #314

Walkthrough

These are from a real session on Amazon Bedrock (Claude Haiku 4.5), with the date fixed to Tuesday 6 October 2026. The same seven screenshots are in the sample's README.

Stage Screenshot
1. --setup asks for each setting; an invalid date is rejected and asked again setup
2. Ready: the header shows the settings, and the sample requests are printed to copy ready
3. Book with create_appointment book
4. Move with update_appointment move
5. Clash: 3pm is taken, nothing is saved, and the agent lists free times and asks clash
6. The user answers, and the agent books the chosen time answer
7. /calendar reads the database directly; /config shows each setting and its source calendar and config

How this uses Strands

Everything the assistant does as an agent runs through @strands-agents/sdk (1.19.0). The sample supplies the tools, the rules and the terminal interface. It makes no direct Bedrock or AWS SDK calls, and never runs a tool itself: its only call per turn is agent.invoke().

Strands API Used for
Agent The agent loop and the conversation memory
BedrockModel({ modelId, region }) The model, from the configured model and region
tool() with Zod inputSchema All five tools: create, list, update, delete, check availability
agent.invoke() Each turn
agent.messages Carrying the conversation over when /set rebuilds the agent with a new model
Built-in printer The streamed replies and the 🔧 Tool #1 … ✓ Tool completed lines

The trace below is Strands' lifecycle hooks on the sample's own AppointmentTools, for a booking and then a clashing booking (Bedrock, Claude Haiku 4.5, us-east-1). In each turn, Strands calls the model, runs the tool the model picked with the model's arguments, and sends the result back. On the clash, the tool's conflict result goes back to the model, which asks instead of booking, and SQLite ends with one row.

=== You: Book 'Agent fun' tomorrow 3pm in NYC
 1. Strands: invocation starts
 2. Strands → Bedrock: model call
 3. Bedrock → Strands: stopReason=toolUse
 4. Strands runs tool create_appointment {"title":"Agent fun","date":"2026-10-07 15:00","location":"NYC"}
 5. tool result → Strands: "Appointment created successfully with ID: 52819925-f16c-4736-b11a-309055320c32"
 6. Strands → Bedrock: model call
 7. Bedrock → Strands: stopReason=endTurn
 8. Strands: invocation ends

=== You: Also book 'Design review' tomorrow at 3pm in the office
 1. Strands: invocation starts
 2. Strands → Bedrock: model call
 3. Bedrock → Strands: stopReason=toolUse
 4. Strands runs tool create_appointment {"date":"2026-10-07 15:00","location":"the office","title":"Design review"}
 5. tool result → Strands: {"status": "conflict", "message": "The calendar is busy at that time. Nothing was saved. …
 6. Strands → Bedrock: model call
 7. Bedrock → Strands: stopReason=endTurn
 8. Strands: invocation ends
=== Agent (final text): There's a conflict! You already have "Agent fun" booked at 3:00 PM tomorrow … - 1:30 PM (13:30) - 2:00 PM (14:00) - 4:00 PM (16:00)

Conversation held by Strands: 8 messages
SQLite rows: ["2026-10-07 15:00 Agent fun"]
Trace script (run from typescript/01-learn/02-custom-tools with npx tsx trace.mts)
import { Agent, BedrockModel, BeforeInvocationEvent, AfterInvocationEvent, BeforeModelCallEvent,
  AfterModelCallEvent, BeforeToolCallEvent, AfterToolCallEvent } from "@strands-agents/sdk";
import { AppointmentDatabase } from "./src/database/AppointmentDatabase.ts";
import { AppointmentTools } from "./src/tools/AppointmentTools.ts";

const db = new AppointmentDatabase("trace.db");
const agent = new Agent({
  model: new BedrockModel({ modelId: "us.anthropic.claude-haiku-4-5-20251001-v1:0", region: "us-east-1" }),
  systemPrompt: "You manage my calendar. Today is Tuesday, 2026-10-06. Always use the tools to check the calendar. On status conflict, offer the suggested_start_times and ask me.",
  tools: new AppointmentTools(db).getAllTools(),
  printer: false,
});

let n = 0;
const log = (s: string) => console.log(String(++n).padStart(2) + ". " + s);
agent.addHook(BeforeInvocationEvent, () => log("Strands: invocation starts"));
agent.addHook(BeforeModelCallEvent, () => log("Strands → Bedrock: model call"));
agent.addHook(AfterModelCallEvent, (e: any) => log("Bedrock → Strands: stopReason=" + (e.stopData?.stopReason ?? "?")));
agent.addHook(BeforeToolCallEvent, (e) => log("Strands runs tool " + e.toolUse.name + " " + JSON.stringify(e.toolUse.input)));
agent.addHook(AfterToolCallEvent, (e: any) => log("tool result → Strands: " + JSON.stringify(e.result.content[0]?.text).slice(0, 150)));
agent.addHook(AfterInvocationEvent, () => log("Strands: invocation ends"));

for (const q of ["Book 'Agent fun' tomorrow 3pm in NYC", "Also book 'Design review' tomorrow at 3pm in the office"]) {
  console.log("\n=== You: " + q); n = 0;
  const r = await agent.invoke(q);
  console.log("=== Agent (final text): " + r.toString().replace(/\s+/g, " ").slice(0, 200));
}
console.log("\nConversation held by Strands: " + agent.messages.length + " messages");
console.log("SQLite rows: " + JSON.stringify(db.listAppointments().map((a) => a.date + " " + a.title)));
db.close();

What changed and why

1. Turn-by-turn chat with a guided demo (index.ts)

The sample used to fire three hard-coded requests and exit, so a learner could watch but never try anything. Now:

  • npx tsx src/index.ts runs the first two requests. The third is designed to clash, the agent asks what to do, and the learner takes over in a normal chat.
  • --chat starts the chat at the first turn and prints the three sample requests to copy and paste.
  • --auto runs the three requests without input, for quick checks.
  • In the chat: /calendar prints the SQLite table directly, so learners can confirm the tools really wrote to the database. /help lists the commands. exit quits and prints the final calendar.

Input is read with for await (const line of rl), so piped input and Ctrl+D end cleanly.

2. A calendar that never double-books (AppointmentDatabase.ts, AppointmentTools.ts)

Before, booking 3pm twice created two 3pm meetings. Now:

  • Appointments have a length. A new duration_minutes column defaults to 60 minutes and is configurable. Databases created by the old sample gain the column automatically, and their rows are kept.
  • create_appointment and update_appointment check for overlaps first. On a clash they save nothing and return:
    { "status": "conflict",
      "message": "The calendar is busy at that time. Nothing was saved. Offer the user the suggested_start_times and ask how to proceed.",
      "conflicts": [{ "title": "Agent fun", "date": "2026-10-07 15:00", "duration_minutes": 60 }],
      "suggested_start_times": ["13:30", "14:00", "16:00"] }
  • The rule lives in the tool, not only in the prompt, so the agent can't double-book even if it tries. This is the new concept the README teaches: a tool result can tell the agent that it needs the user.
  • New tools:
    • check_availability lists the free start times on a day.
    • delete_appointment takes a confirmed flag: with confirmed: false it returns needs_confirmation and deletes nothing.

3. Every setting is configurable (config.ts, new)

Every value in the header can now be changed:

  • model, region and calendar file;
  • the time zone used for "today";
  • a fixed "now", for rehearsing a demo on a set date;
  • the default length and working hours.

Each value comes from, in priority order, a flag (--model), an environment variable (MODEL_ID), assistant.config.json (git-ignored), or the default. All values are validated before the assistant starts.

  • --setup asks for each setting, shows the current value, re-asks when a value is invalid, and saves the answers.
  • In the chat: /config shows every value and where it came from, /set <setting> <value> changes one without losing the conversation, and /save stores it.

4. Prompt changes, driven by testing

Found while testing Change
"Tomorrow at 3pm" never booked, because the model didn't know the date The prompt includes today's weekday, date, time and time zone
The agent asked for a length and description before booking "Gym at 7am" 60 minutes is used by default, and the description is optional
On a clash, the agent once offered 2:30pm, which overlaps a 3–4pm meeting Clash results include the three nearest free times, and the agent may only propose times a tool returned
Once, the agent skipped the tools, decided from memory that 3pm was busy, and invented free times The prompt now requires checking the calendar with a tool before proposing any time
The agent deleted an appointment without asking A confirmed flag on delete_appointment plus a prompt rule. In later runs it asked first every time, but see the note below: this one is prompt-dependent.

How this was verified

On Node 26 from a clean install, against Amazon Bedrock:

Check Result
tsc --noEmit Passes
Calendar logic, 12 cases: overlap, back-to-back meetings, other day, ignore self, invalid dates, free-slot bounds, delete, upgrading an old database All pass
Clash scenario on the final code, run 9 times, with a script that fails the run if the agent proposes a time without calling a tool, or a time that isn't free 9 of 9 pass. Earlier failing runs are what led to the fixes above.
Guided demo with the answer "4pm works" Agent fun at 15:00 in DC, and Design review at 16:00, both in SQLite
Setting sources: config file, environment variable and flag set together /config shows each value from the right source
--region us-west-2 CloudWatch shows the Bedrock calls in us-west-2
/set model global.anthropic.claude-sonnet-4-6 mid-chat Sonnet 4.6 answers about what was booked earlier with Haiku
Fixed now 2026-12-24 09:00, 30-minute default, working hours 08–17 "Tomorrow" becomes 25 December, a 30-minute booking is stored, and a 10:15 request clashes with it and is offered 10:30
Cancel flow, run 3 times Asks first every time, and deletes only after "yes"
Invalid region, time zone, date, length or working hours A clear message for each, exit 1
Invalid AWS credentials or model ID Exit 1

Notes for reviewers

  • What's enforced in code: no double-booking, and nothing saved on a clash. The tool checks overlaps whatever the model does.
  • What relies on the model: asking before cancelling. A trace showed the agent looking the appointment up with list_appointments, asking, and then calling delete_appointment with confirmed: true in one step. It worked, but a model that set the flag wrongly would still get the deletion. A hard guarantee would need the app to ask the user directly (for example with human-in-the-loop interventions); I kept that out of scope for a tutorial, and the README says so.
  • Scope: only 02-custom-tools changes. The original three requests are unchanged apart from adding the clash as the third, and the class-based tool pattern the README teaches is unchanged.
  • Size: most of the diff is config.ts and the README. If you'd prefer something smaller, the settings layer (commit 3) could be dropped and the chat and clash handling would still stand alone.
@schinchli

Copy link
Copy Markdown
Contributor Author

@mvangara10 @yonib05 this is a feature follow-up to #317 for 02-custom-tools, and I'd appreciate a review when you have time.

Learners can now chat with the assistant turn by turn. It never double-books: on a clash, the tool refuses and the agent asks the user which time to use instead. Every setting (model, region, time zone, and so on) is configurable. The description has seven screenshots of a real session and a table of how each part was tested.

It depends on #317 (the first commit is the same 02 fix), and I'll rebase once that merges. If it's larger than you'd like for a tutorial, I'm happy to split out or drop the settings layer.

@manoj-selvakumar5

Copy link
Copy Markdown
Collaborator

@schinchli — Thanks for the contribution! Since this is a full app, it doesn't really fit in 01-learn, which we keep beginner-sized. Python splits it the same way: the lesson stays small and the full calendar app lives in python/04-industry-use-cases/productivity/personal-assistant.

Could you move this to typescript/04-industry-use-cases/productivity/calendar-assistant/, add a category README and an entry in typescript/README.md, and leave 01-learn/02-custom-tools as it is on main?

@github-actions

Copy link
Copy Markdown

Latest scan for commit: f662b3b | Updated: 2026-10-14 22:10:20 UTC

✅ Security Scan Report (PR Files Only)

Scanned Files

  • typescript/01-learn/02-custom-tools/images/walkthrough/01-setup.png
  • typescript/01-learn/02-custom-tools/images/walkthrough/02-ready.png
  • typescript/01-learn/02-custom-tools/images/walkthrough/03-book.png
  • typescript/01-learn/02-custom-tools/images/walkthrough/04-move.png
  • typescript/01-learn/02-custom-tools/images/walkthrough/05-clash.png
  • typescript/01-learn/02-custom-tools/images/walkthrough/06-answer.png
  • typescript/01-learn/02-custom-tools/images/walkthrough/07-calendar-config.png
  • typescript/01-learn/02-custom-tools/src/config.ts
  • typescript/01-learn/02-custom-tools/.gitignore
  • typescript/01-learn/02-custom-tools/README.md
  • ... and 4 more files

Security Scan Results

Critical High Medium Low Info
0 0 0 0 0

Threshold: High

No security issues detected in your changes. Great job!

This scan only covers files changed in this PR.

Moves the full calendar app out of 01-learn/02-custom-tools (left as on main)
into typescript/04-industry-use-cases/productivity/calendar-assistant, mirroring
python/04-industry-use-cases/productivity/personal-assistant. Adds the category
README, a typescript/README.md entry and regenerates samples.json.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@schinchli
schinchli force-pushed the feat/ts-02-interactive-calendar-pr branch from f662b3b to 1c41402 Compare October 31, 2026 03:34
@schinchli

Copy link
Copy Markdown
Contributor Author

Thanks @manoj-selvakumar5, done. The full app now lives in typescript/04-industry-use-cases/productivity/calendar-assistant/, with a category README, an entry in typescript/README.md, and a regenerated samples.json. 01-learn/02-custom-tools is unchanged from main. #317 is already merged, so I rebased onto main and squashed to a single commit (force-pushed). npm install, tsc and the manifest tests pass; I could not run the live Bedrock session this time, but the screenshots are from the earlier real run.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

2 participants