---
title: "Calendar: Developer Guide"
description: "Quick start, the full action inventory, data model, routes, and how to extend the Calendar template."
---

# Developer Guide

This page is for anyone customizing the Calendar template or extending it: the quick start, every action, the data model, the route surface, and how to add to it. See [Calendar](/docs/template-calendar) for what the app does.

## Quick start

This walks through running Calendar from nothing installed to a connected
Google Calendar. The commands are identical whether you're in Terminal on
macOS or PowerShell/Command Prompt on Windows, they're plain Node.js tooling.

<Steps>

### Create the app

```bash
npx --yes @agent-native/core@latest create my-calendar --standalone --template calendar
cd my-calendar
pnpm install
```

This needs [Node.js](https://nodejs.org) 22.22 or later and
[pnpm](https://pnpm.io) installed first. See
[Getting started](/docs/getting-started) if you haven't installed either
yet, or don't have an LLM connection (Builder.io, an Anthropic/OpenAI key,
or local Ollama) set up.

### Start the dev server

```bash
pnpm dev
```

The app opens at `http://localhost:8082`. If it doesn't open automatically,
visit that URL in your browser. Calendar works fully with Google
disconnected. You'll only hit an error if you try an action that needs it
before connecting.

### Connect Google Calendar

In the app, open **Settings**, and on the **General** tab find the **Google
Calendar** card, then click **Connect Google Calendar**. This opens a setup
wizard:

1. **Create a Google Cloud project**, if you don't already have one to use:
   in [Google Cloud Console](https://console.cloud.google.com/), click the
   project dropdown near the top of the page, then **New Project**. Give it
   any name, click **Create**, and wait for the notification that it's
   ready. Make sure it's selected in that same dropdown before continuing.
2. **Enable the API**: with your project selected, go to **APIs & Services
   → Library**, search for "Google Calendar API," open it, and click
   **Enable**.
3. **Configure the consent screen**: under **APIs & Services → OAuth
   consent screen**, set one up ("External" is fine for development).
4. **Create credentials**: under **APIs & Services → Credentials**, create
   an OAuth client ID of type "Web application," and add
   `http://localhost:8082/_agent-native/google/callback` as an authorized
   redirect URI (8082 is Calendar's default dev port).
5. **Add the credentials**: download the credentials JSON and upload it in
   the wizard, or toggle "Paste manually" and enter the Client ID and Client
   Secret directly.

Calendar saves these to its own secret store, not a `.env` file, so this
step is identical on Windows and macOS. Tokens refresh automatically once
connected.

### Connect Zoom (optional)

Unlike Google, Zoom has no Settings form. Create a `.env` file in the
project root (or add to the existing one) with:

```text
ZOOM_CLIENT_ID=your-client-id
ZOOM_CLIENT_SECRET=your-client-secret
```

Get a Client ID and Secret from a [Zoom OAuth app](https://marketplace.zoom.us/)
(Zoom Marketplace → Develop → Build App → OAuth), with
`/_agent-native/zoom/callback` as the redirect URI, then restart `pnpm dev`
so it picks up the new values. Without Zoom connected, booking links can
still offer Google Meet, just not Zoom.

</Steps>

<Callout tone="warning">

On Windows, some editors add a hidden `.txt` extension when you save a file
named `.env`, leaving a `.env.txt` that Calendar never reads. In File
Explorer, turn on "File name extensions" (View tab) and confirm the saved
file is named exactly `.env`, with nothing after it.

</Callout>

<Callout tone="info">

**Events live in Google Calendar, not SQL.** Every event action calls the Google Calendar API directly — there is no local events table. Only booking links, bookings, availability, and connection state are stored locally. Never read or write event data with raw SQL.

</Callout>

## Action inventory

Every operation is a TypeScript file in `templates/calendar/actions/`, auto-mounted at `POST /_agent-native/actions/:name`.

### Events & guests

Event data lives in Google Calendar, never SQL.

| Action                   | What it does                                                                                                    |
| ------------------------ | --------------------------------------------------------------------------------------------------------------- |
| `list-events`            | List events in a date range                                                                                     |
| `search-events`          | Search events by query                                                                                          |
| `get-event`              | Read one event by id                                                                                            |
| `create-event`           | Create an event, including recurrence (RRULE), working locations, and status types (`outOfOffice`, `focusTime`) |
| `update-event`           | Update an event, including recurrence, working locations, and status types                                      |
| `delete-event`           | Delete an event                                                                                                 |
| `manage-event-draft`     | Stage an unsent invite for user review before anything is written or guests are notified                        |
| `rsvp-event`             | Accept, decline, or mark tentative on an invitation                                                             |
| `get-attendee-timezones` | Read per-guest timezone overrides                                                                               |
| `set-attendee-timezone`  | Set a per-guest timezone override so their local time keeps showing correctly                                   |

### Availability & finding time

| Action                | What it does                                                                                                     |
| --------------------- | ---------------------------------------------------------------------------------------------------------------- |
| `get-availability`    | Read the weekly schedule, timezone, buffer, notice, and advance-window settings                                  |
| `update-availability` | Update the weekly schedule, timezone, buffer, notice, and advance-window settings                                |
| `check-availability`  | Find open slots for one date                                                                                     |
| `find-a-time`         | Shared free-slot search across an organizer and named attendees, combining Google free/busy with local conflicts |

### Booking links & bookings

| Action                   | What it does                                           |
| ------------------------ | ------------------------------------------------------ |
| `create-booking-link`    | Create a booking link                                  |
| `update-booking-link`    | Update a booking link                                  |
| `duplicate-booking-link` | Duplicate a booking link, including co-hosts (`hosts`) |
| `list-booking-links`     | List accessible booking links                          |
| `list-bookings`          | List confirmed appointments                            |

### External calendars

Read-only ICS feeds.

| Action                      | What it does                             |
| --------------------------- | ---------------------------------------- |
| `add-external-calendar`     | Subscribe to a read-only ICS/webcal feed |
| `list-external-calendars`   | List subscribed feeds                    |
| `remove-external-calendar`  | Remove a subscribed feed                 |
| `update-external-calendars` | Update subscribed feeds                  |

### People & overlays

| Action                  | What it does                                                      |
| ----------------------- | ----------------------------------------------------------------- |
| `search-people`         | Search Google Contacts and the Workspace directory                |
| `get-overlay-people`    | Read the other people whose calendars are pinned into your view   |
| `update-overlay-people` | Update the other people whose calendars are pinned into your view |

### Zoom

| Action            | What it does                                                                                                                                                                         |
| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `get-zoom-status` | Read the current user's Zoom connection status. Connect and disconnect happen through `/_agent-native/zoom/*` routes, not an action, since OAuth needs the signed-in browser session |

### Google Calendar connection & sync

| Action                    | What it does                                                   |
| ------------------------- | -------------------------------------------------------------- |
| `connect-google-calendar` | Return the OAuth connect URL for the signed-in browser session |
| `sync-google-calendar`    | Force a resync                                                 |

### Settings & preferences

| Action                               | What it does                                                                                         |
| ------------------------------------ | ---------------------------------------------------------------------------------------------------- |
| `get-settings`                       | Read user-level settings                                                                             |
| `update-settings`                    | Update user-level settings                                                                           |
| `update-calendar-visual-preferences` | Update local-only display preferences (event colors, hidden weekends). Never touches Google Calendar |

### Navigation & UI state

| Action        | What it does                             |
| ------------- | ---------------------------------------- |
| `navigate`    | Move the UI to a view, date, or event    |
| `view-screen` | Read the current view back for the agent |

### Provider API escalation

The raw Google Calendar (and connected CRM) API, for when the actions above are too narrow.

| Action                  | What it does                                                          |
| ----------------------- | --------------------------------------------------------------------- |
| `provider-api-catalog`  | List the raw provider APIs available to call directly                 |
| `provider-api-docs`     | Read documentation for a raw provider API endpoint                    |
| `provider-api-request`  | Call the raw Google Calendar (or connected CRM) API directly          |
| `list-staged-datasets`  | List large scans staged for querying instead of returning raw results |
| `query-staged-dataset`  | Query a staged dataset                                                |
| `delete-staged-dataset` | Delete a staged dataset                                               |

## Data model

Defined in `templates/calendar/server/db/schema.ts`. Only non-event data is stored locally — events themselves live in Google Calendar.

<DataModel
  id="doc-block-calendar6"
  title="Calendar data model"
  summary={
    "Only non-event data is stored locally — events live in Google Calendar. Booking links use ownableColumns so the sharing system applies; booking usernames are a separate reservation table because a username is shared across every link a user owns."
  }
  entities={[
    {
      id: "booking_links",
      name: "booking_links",
      note: "Calendly-style link definitions (ownable)",
      fields: [
        { name: "id", type: "id", pk: true },
        {
          name: "slug",
          type: "string",
          note: "unique; public page at /book/{username}/{slug}",
        },
        { name: "title", type: "string" },
        { name: "description", type: "string", nullable: true },
        { name: "duration", type: "int", note: "primary duration in minutes" },
        {
          name: "durations",
          type: "json",
          nullable: true,
          note: "alternative durations",
        },
        {
          name: "hosts",
          type: "json",
          nullable: true,
          note: "required co-hosts besides the owner",
        },
        { name: "customFields", type: "json", nullable: true },
        {
          name: "conferencing",
          type: "json",
          nullable: true,
          note: "none | google_meet | zoom | custom",
        },
        { name: "color", type: "string", nullable: true },
        { name: "isActive", type: "bool", note: "pause without deleting" },
        { name: "ownerEmail", type: "string" },
        { name: "orgId", type: "id", nullable: true },
      ],
    },
    {
      id: "bookings",
      name: "bookings",
      note: "Confirmed appointments from public booking pages",
      fields: [
        { name: "id", type: "id", pk: true },
        { name: "slug", type: "string", fk: "booking_links.slug" },
        { name: "name", type: "string" },
        { name: "email", type: "string" },
        { name: "start", type: "datetime" },
        { name: "end", type: "datetime" },
        { name: "eventTitle", type: "string", nullable: true },
        { name: "notes", type: "string", nullable: true },
        {
          name: "fieldResponses",
          type: "json",
          nullable: true,
          note: "custom field responses",
        },
        {
          name: "meetingLink",
          type: "string",
          nullable: true,
          note: "Zoom, Google Meet, or custom",
        },
        { name: "googleEventId", type: "string", nullable: true },
        {
          name: "calendarAccountId",
          type: "string",
          nullable: true,
          note: "connected account that owns the event",
        },
        {
          name: "cancelToken",
          type: "string",
          note: "powers /booking/manage/{token}",
        },
        { name: "status", type: "enum", note: "confirmed | cancelled" },
        { name: "ownerEmail", type: "string" },
        { name: "orgId", type: "id", nullable: true },
      ],
    },
    {
      id: "booking_slug_redirects",
      name: "booking_slug_redirects",
      note: "Keeps old public URLs working after a link's slug is renamed",
      fields: [
        { name: "oldSlug", type: "string", pk: true },
        {
          name: "newSlug",
          type: "string",
          note: "the link's current slug",
        },
        { name: "createdAt", type: "datetime" },
      ],
    },
    {
      id: "booking_usernames",
      name: "booking_usernames",
      note: "Reserves the {username} segment of /book/{username}/{slug}, one per owner",
      fields: [
        { name: "username", type: "string", pk: true },
        {
          name: "ownerEmail",
          type: "string",
          note: "unique — one booking username per user",
        },
        { name: "createdAt", type: "datetime" },
        { name: "updatedAt", type: "datetime" },
      ],
    },
    {
      id: "booking_username_changes",
      name: "booking_username_changes",
      note: "Audit trail enforcing the 3-changes-per-30-days rate limit",
      fields: [
        { name: "id", type: "id", pk: true },
        { name: "ownerEmail", type: "string" },
        { name: "oldUsername", type: "string", nullable: true },
        { name: "newUsername", type: "string" },
        { name: "createdAt", type: "datetime" },
      ],
    },
    {
      id: "booking_link_shares",
      name: "booking_link_shares",
      note: "Share grants for booking links",
      fields: [
        { name: "id", type: "id", pk: true },
        { name: "linkId", type: "id", fk: "booking_links.id" },
        { name: "principal", type: "string", note: "user or org" },
        { name: "role", type: "enum", note: "viewer | editor | admin" },
      ],
    },
  ]}
  relations={[
    {
      from: "booking_links",
      to: "bookings",
      kind: "1-n",
      label: "has bookings",
    },
    {
      from: "booking_links",
      to: "booking_link_shares",
      kind: "1-n",
      label: "has share grants",
    },
  ]}
/>

Availability rules live in per-user settings under the `calendar-availability` key; overlay people under `calendar-overlay-people`; attendee timezone overrides under `attendee-timezones`; subscribed ICS feeds under `external-calendars`. Google and Zoom OAuth tokens live in the framework `oauth_tokens` table. Ephemeral UI state (current view, date, selected event) lives in `application_state` under the `navigation` key.

## An action walked through

`create-booking-link` shows patterns worth reusing in any action: validating input before it touches the database, enforcing a rule across two tables, safely resolving the signed-in user, and normalizing a flexible input shape once instead of in every reader.

<AnnotatedCode
  id="doc-block-calendar8"
  title="Creating a booking link"
  filename="templates/calendar/actions/create-booking-link.ts"
  language="ts"
  code={
    'import { defineAction } from "@agent-native/core";\nimport {\n  getRequestUserEmail,\n  getRequestOrgId,\n} from "@agent-native/core/server/request-context";\nimport { eq } from "drizzle-orm";\nimport { nanoid } from "nanoid";\nimport { z } from "zod";\n\nimport { getDb, schema } from "../server/db/index.js";\nimport { normalizeBookingDurationInput } from "../server/lib/booking-durations.js";\nimport {\n  rowToBookingLink,\n  serializeBookingHosts,\n} from "../server/lib/booking-link-utils.js";\n\nconst hostsSchema = z\n  .union([\n    z.array(\n      z.union([\n        z.string(),\n        z.object({\n          email: z.string(),\n          displayName: z.string().optional(),\n        }),\n      ]),\n    ),\n    z.string(),\n  ])\n  .optional();\n\nexport default defineAction({\n  description:\n    "Create a new booking link/event type. Use this instead of raw SQL for booking links.",\n  schema: z.object({\n    title: z.string().min(1).describe("Booking link title"),\n    slug: z\n      .string()\n      .min(1)\n      .regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/)\n      .describe("URL slug, lowercase words separated by hyphens"),\n    duration: z.coerce\n      .number()\n      .int()\n      .min(5)\n      .max(24 * 60)\n      .describe("Default duration in minutes"),\n    description: z.string().optional().describe("Description"),\n    durations: z\n      .array(\n        z.coerce\n          .number()\n          .int()\n          .min(5)\n          .max(24 * 60),\n      )\n      .optional()\n      .describe("Optional duration choices, e.g. [30,45,60]"),\n    hosts: hostsSchema.describe(\n      "Required co-hosts besides the owner. Accepts emails, comma-separated emails, or {email, displayName} objects.",\n    ),\n    customFields: z.array(z.any()).optional().describe("Custom form fields"),\n    conferencing: z.any().optional().describe("Conferencing configuration"),\n    color: z.string().optional().describe("Display color"),\n    isActive: z.boolean().optional().describe("Whether the link is active"),\n  }),\n  run: async (args) => {\n    const body = args as Record<string, any>;\n    const durationInput = normalizeBookingDurationInput({\n      duration: body.duration,\n      durations: body.durations,\n    });\n    if ("error" in durationInput) {\n      throw new Error(durationInput.error);\n    }\n    const slug = String(body.slug).trim().toLowerCase();\n    const [existingLink, existingRedirect] = await Promise.all([\n      getDb()\n        .select({ id: schema.bookingLinks.id })\n        .from(schema.bookingLinks)\n        .where(eq(schema.bookingLinks.slug, slug)),\n      getDb()\n        .select({ oldSlug: schema.bookingSlugRedirects.oldSlug })\n        .from(schema.bookingSlugRedirects)\n        .where(eq(schema.bookingSlugRedirects.oldSlug, slug)),\n    ]);\n\n    if (existingLink.length > 0 || existingRedirect.length > 0) {\n      throw new Error("A booking link with this slug already exists");\n    }\n\n    const now = new Date().toISOString();\n    const id = nanoid();\n    const ownerEmail = (() => {\n      const e = getRequestUserEmail();\n      if (!e) throw new Error("no authenticated user");\n      return e;\n    })();\n    await getDb()\n      .insert(schema.bookingLinks)\n      .values({\n        id,\n        slug,\n        title: String(body.title).trim(),\n        description: body.description ? String(body.description).trim() : null,\n        duration: durationInput.duration,\n        durations: durationInput.durations\n          ? JSON.stringify(durationInput.durations)\n          : null,\n        hosts: serializeBookingHosts(body.hosts, ownerEmail),\n        customFields: body.customFields\n          ? JSON.stringify(body.customFields)\n          : null,\n        conferencing: body.conferencing\n          ? JSON.stringify(body.conferencing)\n          : null,\n        color: body.color ? String(body.color).trim() : null,\n        isActive: body.isActive ?? true,\n        ownerEmail,\n        orgId: getRequestOrgId(),\n        createdAt: now,\n        updatedAt: now,\n      });\n\n    const created = await getDb()\n      .select()\n      .from(schema.bookingLinks)\n      .where(eq(schema.bookingLinks.id, id));\n    return rowToBookingLink(created[0]);\n  },\n});'
  }
  annotations={[
    {
      lines: "37-41",
      label: "Slug format is validated up front",
      note: "The regex enforces lowercase, hyphen-separated URL slugs before anything touches the database, so a malformed slug never reaches the uniqueness check below.",
    },
    {
      lines: "59-61",
      label: "Hosts are optional",
      note: "Omitting `hosts` creates a solo link. A non-empty list becomes required co-hosts who must also be free before `find-a-time` offers a slot.",
    },
    {
      lines: "76-90",
      label: "A slug must be free in two tables",
      note: "A new slug has to be unused in both `booking_links` and `booking_slug_redirects` — otherwise a fresh link could silently claim a URL an older, renamed link still forwards visitors from.",
    },
    {
      lines: "94-98",
      label: "The signed-in user is resolved once, and narrowed",
      note: "`getRequestUserEmail()` returns `string | undefined`, since there's technically no signed-in user for an unauthenticated call. Wrapping it in an immediately-invoked function lets the action throw right there if that ever happens, so `ownerEmail` is a plain `string` for the rest of the function instead of something every later line would have to re-check.",
    },
    {
      lines: "110",
      label: "Co-host input is normalized once",
      note: "`serializeBookingHosts` accepts emails, comma-separated strings, or `{email, displayName}` objects and always stores the same JSON shape, so every reader only handles one format.",
    },
  ]}
/>

## Routes reference

<FileTree
  id="doc-block-calendar7"
  title="Calendar template layout"
  entries={[
    {
      path: "actions/",
      note: "every agent-callable action — events, availability, booking, people, Zoom, sharing",
    },
    {
      path: "app/routes/_app._index.tsx",
      note: "the main calendar view (day/week/month)",
    },
    { path: "app/routes/_app.agent.tsx", note: "full-page agent chat tab" },
    {
      path: "app/routes/_app.availability.tsx",
      note: "the availability editor",
    },
    {
      path: "app/routes/_app.booking-links._index.tsx",
      note: "booking link list; _app.booking-links.$id.tsx edits one",
    },
    { path: "app/routes/_app.bookings.tsx", note: "confirmed bookings list" },
    {
      path: "app/routes/_app.extensions.tsx",
      note: "Extension widget routes",
    },
    {
      path: "app/routes/_app.settings.tsx",
      note: "Google/Zoom connect, availability defaults, sharing",
    },
    {
      path: "app/routes/book.$username.$slug.tsx",
      note: "the public booking page (canonical: /book/{username}/{slug})",
    },
    {
      path: "app/routes/booking.manage.$token.tsx",
      note: "the visitor's reschedule/cancel page",
    },
    {
      path: "app/routes/event.tsx",
      note: "standalone inline event-preview page the agent embeds in chat",
    },
    {
      path: "server/db/schema.ts",
      note: "Drizzle tables — booking_links, bookings, booking_usernames, shares",
    },
    { path: "server/lib/zoom.ts", note: "Zoom OAuth + meeting creation" },
    {
      path: "server/lib/google-calendar.ts",
      note: "the Google Calendar API client",
    },
    {
      path: "shared/api.ts",
      note: "shared TypeScript types used by server and client",
    },
  ]}
/>

Two legacy routes stay mounted for links shared before a URL scheme changed: `book.$slug.tsx` (no username segment) and `meet.$username.$slug.tsx`, both of which canonicalize to `/book/:username/:slug`. `_app.team.tsx` redirects to `/settings/organization` — team management lives in Settings, not a separate page.

## Customizing it

Every part of the app is editable source. Start here:

- `templates/calendar/actions/` — add a new file with `defineAction` to expose new capability to both the agent and the frontend.
- `templates/calendar/app/routes/` — the UI, listed above.
- `templates/calendar/server/db/schema.ts` — add columns or tables with Drizzle. Keep the schema PostgreSQL-compatible so the template runs with local PGlite and hosted Postgres.
- `templates/calendar/server/lib/` — provider integrations: `google-calendar.ts`, `zoom.ts`, `people-search.ts`, `ical-fetcher.ts`, `find-time.ts`, `provider-api.ts`.
- `templates/calendar/AGENTS.md` — agent instructions. Update this when you teach the agent new capabilities or conventions.
- `templates/calendar/.agents/skills/` — detailed patterns the agent follows. Relevant skills: `event-management`, `availability-booking`, `real-time-sync`, `storing-data`, `delegate-to-agent`, `frontend-design`.
- `templates/calendar/shared/api.ts` — the shared TypeScript types (`CalendarEvent`, `BookingLink`, `ExternalCalendar`, `ConferencingConfig`, etc.) used by both the server and the client.

If you add a feature, remember to update all four areas: UI, action, skill or AGENTS.md entry, and any application state the agent needs to see. That is what keeps the agent and the UI in parity.

## What's next

- [**Calendar**](/docs/template-calendar) — the overview and getting-started steps
- [**Features**](/docs/template-calendar-features) — the user-facing behavior these actions and this data model power
- [**Talk to the Agent**](/docs/template-calendar-agent) — how the agent chooses between these actions and the raw Google Calendar API
- [**Calendar in a Multi-App Workspace**](/docs/template-calendar-integrations) — how these actions get exposed to other apps and Dispatch
- [**Actions**](/docs/actions-overview) — the action system this template is built on
- [**Sharing**](/docs/sharing) — the share-grant model `booking_link_shares` uses
