Give Your AI Agent a Real Calendar
You already own the agent loop. This shows how to hand it two tools — check availability and book — so it turns 'sort out a call with Jordan next week' into a confirmed meeting on a real calendar, with no UI clicking.
Most "AI scheduling" stops at suggesting a time and waiting for a human to click confirm. This walkthrough goes the whole way: an agent that checks real availability and creates a confirmed booking itself, by calling the Astrocal REST API as tools inside your own agent loop.
We'll use the Vercel AI SDK — the most widely used TypeScript AI toolkit — because its tool-calling model is clean and provider-agnostic. But the pattern is the pattern: two functions the model can call, wired to two API endpoints. Whatever framework you're on (Mastra, the OpenAI Agents SDK, LangChain, or a hand-rolled loop) maps onto it directly, and there's a portable version at the end.
What you'll build
An agent that can take a request like "Book a 30-minute intro call with jordan@example.com sometime Tuesday afternoon" and actually make it happen: read your real availability, pick a free slot, create the booking, and hand back the confirmation.
The flow is four moving parts:
Your prompt ──▶ LLM ──▶ your tools (AI SDK) ──▶ Astrocal REST API ──▶ real calendar
▲ │
└────────── slots / booking ───────────┘Two tools cover the core loop — check_availability and create_booking. Cancel and reschedule are the same shape against /v1/bookings/{id}/cancel and /v1/bookings/{id}/reschedule once you've got these working.
MCP client or your own agent?
Astrocal ships an MCP server, and if your agent runs inside an MCP client — Claude Desktop, Cursor, Windsurf — that's the fastest path: point the client at the server and you're done, no tool code required.
This guide is for the other case: you own the agent loop. It's your application, your choice of model, your orchestration. There's no MCP client in the middle, so you wire Astrocal's REST endpoints up as tools yourself. More code, but full control — and it's the same REST API the MCP server calls under the hood.
Prerequisites
- Node.js 18+ (for the global
fetch). - An Astrocal API key — create one free in the dashboard under Settings. No credit card.
- An event type — the thing people book (e.g. "30-min intro call"). Create one in the dashboard, then grab its id. You can also list them:
curl https://api.astrocal.dev/v1/event-types \
-H "Authorization: Bearer $ASTROCAL_API_KEY"
# → { "data": [ { "id": "3f7c…", "title": "30-min intro call", … } ], … }- Packages:
npm install ai @ai-sdk/anthropic zod. The@ai-sdk/anthropicprovider needsANTHROPIC_API_KEYset; swap it for@ai-sdk/openaior any other provider if you prefer.
Set both keys in your environment:
export ASTROCAL_API_KEY="sk_live_…" # your Astrocal API key
export ANTHROPIC_API_KEY="sk-ant-…" # for the model providerStep 1: A thin Astrocal client
Every call is authenticated with Authorization: Bearer <your key> against https://api.astrocal.dev. One small wrapper keeps the tool code clean and surfaces API errors (like a slot that just got taken) rather than swallowing them:
const API_BASE = "https://api.astrocal.dev";
export async function astrocal(path: string, init?: RequestInit) {
const res = await fetch(`${API_BASE}${path}`, {
...init,
headers: {
Authorization: `Bearer ${process.env.ASTROCAL_API_KEY}`,
"Content-Type": "application/json",
...init?.headers,
},
});
if (!res.ok) {
// Astrocal returns { error: { code, message } } — bubble it up so the
// model can react (e.g. 409 = slot already booked, pick another).
throw new Error(`Astrocal ${res.status}: ${await res.text()}`);
}
return res.status === 204 ? null : res.json();
}Step 2: Define the tools
This is the heart of it. Each tool is a description, a Zod schema for its inputs, and an execute function that calls the API. The Zod schema doubles as the tool spec the model sees — so clear field descriptions here directly improve how well the model fills them in.
import { tool } from "ai";
import { z } from "zod";
import { astrocal } from "./astrocal";
export const checkAvailability = tool({
description:
"List open booking slots for an event type within a date range. " +
"Call this before booking to find a real, free time.",
inputSchema: z.object({
eventTypeId: z.string().describe("The event type UUID to check."),
start: z.string().describe("Start of the window, ISO 8601, e.g. 2026-07-21T00:00:00Z."),
end: z.string().describe("End of the window, ISO 8601."),
timezone: z.string().describe("IANA timezone for the slots, e.g. Europe/London."),
}),
execute: async ({ eventTypeId, start, end, timezone }) => {
const params = new URLSearchParams({
event_type_id: eventTypeId,
start,
end,
timezone,
});
const data = await astrocal(`/v1/availability?${params}`);
// Return just the bookable slots, trimmed to what the model needs.
return data.slots
.filter((s: { available?: boolean }) => s.available !== false)
.map((s: { start_time: string; end_time: string }) => ({
start: s.start_time,
end: s.end_time,
}));
},
});
export const createBooking = tool({
description:
"Book a confirmed meeting in an open slot. Only call with a slot that " +
"check_availability just returned.",
inputSchema: z.object({
eventTypeId: z.string(),
start: z.string().describe("Slot start, ISO 8601, exactly as check_availability returned."),
inviteeName: z.string(),
inviteeEmail: z.string().email(),
inviteeTimezone: z.string().describe("IANA timezone, e.g. Europe/London."),
}),
execute: async ({ eventTypeId, start, inviteeName, inviteeEmail, inviteeTimezone }) => {
const booking = await astrocal("/v1/bookings", {
method: "POST",
body: JSON.stringify({
event_type_id: eventTypeId,
start_time: start,
invitee_name: inviteeName,
invitee_email: inviteeEmail,
invitee_timezone: inviteeTimezone,
}),
});
return { id: booking.id, status: booking.status, meetingUrl: booking.meeting_url };
},
});Astrocal handles the hard parts server-side: it re-checks the slot is still free at booking time (returning 409 if it was taken in the meantime), sends the confirmation email and calendar invite, and generates the meeting link. Your agent just picks a time.
Step 3: Wire them into the agent
Give the model both tools and let it run a multi-step loop: it calls check_availability, reads the slots, then calls create_booking with one of them — all in a single generateText call. stopWhen: stepCountIs(5) lets it chain those calls (and recover from a 409) instead of stopping after the first tool result.
import { generateText, stepCountIs } from "ai";
import { anthropic } from "@ai-sdk/anthropic";
import { checkAvailability, createBooking } from "./tools";
const EVENT_TYPE_ID = "3f7c…"; // your "30-min intro call" event type
const result = await generateText({
model: anthropic("claude-sonnet-5"), // swap for claude-opus-4-8, or any provider
tools: {
check_availability: checkAvailability,
create_booking: createBooking,
},
stopWhen: stepCountIs(5),
system:
`You are a scheduling assistant. To book a meeting, first call ` +
`check_availability to find a real open slot, then call create_booking ` +
`with one of those slots. The event type id is ${EVENT_TYPE_ID}. ` +
`Today is ${new Date().toISOString()}. Default timezone is Europe/London ` +
`unless the user says otherwise.`,
prompt:
"Book a 30-minute intro call with Jordan (jordan@example.com) " +
"sometime Tuesday afternoon.",
});
console.log(result.text);The model is provider-agnostic here — anthropic("claude-sonnet-5") is a sensible default for tool-calling agents; swap in claude-opus-4-8 for harder reasoning, or a different provider's package entirely. The tools don't change.
Step 4: Run it
$ npx tsx agent.ts
Done — I booked Jordan a 30-minute intro call for Tuesday 22 July at 2:00 PM
(Europe/London). A confirmation email and calendar invite are on the way, and
the meeting link is https://meet.google.com/….Behind that one line, the agent made two API calls: a GET /v1/availability to find Tuesday's open slots, and a POST /v1/bookings to claim 2:00 PM. The booking is now real — it's on the connected calendar, the invitee has an invite, and it'll show up in your dashboard and fire any webhooks you've configured.
Step 5: Do it safely
A few things to get right before this touches production calendars:
- Test without side effects. Astrocal's sandbox mode lets the agent create test bookings that don't send real emails or touch real calendars — point it at a sandbox key while you iterate. See the sandbox docs.
- Handle the
409. If a slot is booked between the availability check and the create call, the API returns409and our client throws. WithstopWhen: stepCountIs(5), the model sees the error and can re-check availability and try another slot on its own. - Always pass an explicit IANA timezone. "Tuesday afternoon" is meaningless without one. Stamp the user's timezone into the system prompt and thread it through both tools, as above.
- Scope the API key. The agent can do anything the key can. Use a key with only the access it needs, and keep it server-side — never ship it to a browser.
Portable to any stack
Nothing above is Vercel-specific — the tools are two functions over two endpoints. Here are the same two tools as raw JSON tool schemas, the shape almost every framework and model provider accepts (OpenAI function calling, the OpenAI/Anthropic Agents SDKs, LangChain, and others):
[
{
"type": "function",
"function": {
"name": "check_availability",
"description": "List open booking slots for an event type within a date range.",
"parameters": {
"type": "object",
"properties": {
"eventTypeId": { "type": "string" },
"start": { "type": "string", "description": "ISO 8601" },
"end": { "type": "string", "description": "ISO 8601" },
"timezone": { "type": "string", "description": "IANA, e.g. Europe/London" }
},
"required": ["eventTypeId", "start", "end", "timezone"]
}
}
},
{
"type": "function",
"function": {
"name": "create_booking",
"description": "Book a confirmed meeting in an open slot.",
"parameters": {
"type": "object",
"properties": {
"eventTypeId": { "type": "string" },
"start": { "type": "string", "description": "ISO 8601 slot start" },
"inviteeName": { "type": "string" },
"inviteeEmail": { "type": "string" },
"inviteeTimezone": { "type": "string", "description": "IANA" }
},
"required": ["eventTypeId", "start", "inviteeName", "inviteeEmail", "inviteeTimezone"]
}
}
}
]Wire each tool's handler to the same two fetch calls from Step 1, and the agent behaves identically regardless of framework.
Common questions
Do I need MCP for this? No. MCP is the shortest path if your agent runs in an MCP client. If you own the agent loop, the REST API + tool calling shown here is the direct route — and it's the same API the MCP server uses.
Which models work? Any model with tool/function calling. The example uses claude-sonnet-5; claude-opus-4-8 and models from other providers work the same way — only the provider import changes.
How does auth and rate limiting work? Every request uses Authorization: Bearer <key>. Rate limits scale with your plan; the API returns standard 429s with retry headers. See the API reference.
How are timezones handled? You pass an explicit IANA timezone (e.g. Europe/London) to both the availability query and the booking; Astrocal resolves slots and confirmations against it. Never rely on an implicit default.
Can the agent cancel or reschedule too? Yes — add tools over POST /v1/bookings/{id}/cancel and POST /v1/bookings/{id}/reschedule, following the exact same pattern.
Is there a free tier? Yes. Create an API key free in the dashboard — no card required — and start building.
Ready to give your agent a calendar? Grab a free API key, skim the API reference, and if your agent lives in an MCP client, the MCP quickstart is the two-minute version of this.