Lately I have been building more Google Calendar integrations for AI agents and AI app plugins, such as MCP servers that check freeBusy, list events, or create and update events. Testing them against the live Google Calendar API is painful.
So I built Calendarpit.

Calendarpit's admin UI: every Calendar API and OAuth request your app sends, with status codes and masked credentials.
Live tests need a Google Cloud project, OAuth client credentials, and consent screens with dedicated test accounts. State drifts between runs, and a buggy agent could spam attendees or wipe out real events.
I wanted a local emulator that works with the official Google SDK, can be inspected in a browser, and resets cleanly between runs.
Calendarpit came from the needs of the development team behind Chronock, an all-in-one calendar app that brings Google Calendar, Outlook calendars, scheduling links, and automatic event copying into one place. It stays a general-purpose tool, and none of Chronock's data or logic is in it.
What Calendarpit Is and What It Is Not
Calendarpit is an MIT-licensed open-source local server for development and testing. One process serves a Google Calendar API v3 subset, a local OAuth 2.0 server, and an admin UI on http://127.0.0.1:4173. It needs no Google account, Google Cloud project, Google-issued credentials, or external database.
The server never calls Google, and the only external request is the admin UI loading the Roboto Mono font from Google Fonts with a system-font fallback. The current version is 0.9.4, and GET /__calendarpit/health still reports "stage": "preview". It is not a full clone: it covers calendars, events, recurrence, free/busy, incremental sync, and webhooks, and returns an explicit error for anything else.
Try It in 30 Seconds
It ships as a GHCR image, a Homebrew formula, and standalone executables on GitHub Releases for macOS, Linux glibc, and Windows.
# Run with Docker
docker run --rm -p 127.0.0.1:4173:4173 ghcr.io/iktakahiro/calendarpit:latest
# Or install with Homebrew and start the server
brew install iktakahiro/tap/calendarpit
calendarpit serve
The CLI saves state to .calendarpit/calendarpit.sqlite in the working directory and binds to loopback by default. Keep it that way, because the admin UI has no authentication.
The Docker image stores state in /data/calendarpit.sqlite, so the --rm example above is disposable unless you mount a volume with -v calendarpit-data:/data. For disposable E2E runs with the CLI:
calendarpit serve --database :memory: --port 0
--port 0 picks a free port and prints it on startup. Parallel workers each need :memory: or their own database file.

Startup output from calendarpit serve and sample curl responses.
Point Your Existing Google SDK at It
Calendarpit does not intercept traffic. Instead, your app points five endpoints at the local origin: /oauth/authorize, /oauth/token, /userinfo, /oauth/revoke, and the Calendar API root. With @googleapis/calendar and google-auth-library:
const origin = 'http://127.0.0.1:4173';
const auth = new OAuth2Client({
clientId: 'sample-app',
clientSecret: 'local-sample-secret',
redirectUri: 'http://127.0.0.1:4175/callback',
endpoints: {
oauth2AuthBaseUrl: `${origin}/oauth/authorize`,
oauth2TokenUrl: `${origin}/oauth/token`,
},
});
const api = calendar({ version: 'v3', auth, rootUrl: origin });
This snippet covers authorization, token exchange, and the API root. The docs also show oauth2RevokeUrl, the /userinfo request, and the equivalent setup for Python, Go, Kotlin, PHP, C#, and curl. Apart from endpoint configuration, your tests run through the same SDK code paths as production.
Local OAuth 2.0 with Test Users
Many integration bugs live in OAuth: redirects, PKCE, token refresh, and revocation. Mocking the client leaves that path untested.
Calendarpit implements the OAuth flow locally without passwords. On the consent page, you pick a test user from a dropdown and approve or deny the request.

The local consent page: pick a test user, approve or deny.
It checks exact redirect_uri matches, preserves state, supports S256 PKCE, issues refresh tokens for access_type=offline, and serves /userinfo and /oauth/revoke, without issuing id_tokens or full OIDC. The Connections view shows each grant and lets you revoke it to test reauthorization. You can edit OAuth 2.0 clients under Settings → OAuth clients or in the fixture.

Each connection is an OAuth grant: test user, client, and scopes. Revoke invalidates its tokens.
Fixtures and Deterministic Reset
Every test should start from a known calendar state rather than data from a previous run. Calendarpit seeds its workspace from a JSON fixture via --config ./calendarpit.fixture.json or the built-in default at fixtures/default.json:
{
"users": [
{ "id": "alex", "name": "Alex Morgan", "email": "alex@example.test" },
{ "id": "sam", "name": "Sam Rivera", "email": "sam@example.test" }
],
"calendars": [
{ "id": "alex-work", "userId": "alex", "summary": "Work calendar", "timeZone": "Asia/Tokyo", "primary": true },
{ "id": "alex-personal", "userId": "alex", "summary": "Personal", "timeZone": "Asia/Tokyo", "primary": false },
{ "id": "sam-work", "userId": "sam", "summary": "Work calendar", "timeZone": "America/New_York", "primary": true }
],
"events": [
{
"id": "planning",
"calendarId": "alex-work",
"summary": "Weekly planning",
"start": { "dateTime": "2026-10-19T10:00:00+09:00" },
"end": { "dateTime": "2026-10-19T11:00:00+09:00" }
}
],
"clients": [
{
"id": "sample-app",
"secret": "local-sample-secret",
"redirectUris": ["http://127.0.0.1:4175/callback"]
}
]
}
Between tests, one request resets everything:
curl -X POST http://127.0.0.1:4173/__calendarpit/reset \
-H "Content-Type: application/json" \
-d '{}'
Resetting restores the startup fixture with the same IDs and clears tokens, connections, watch channels, and history, so tests authorize again after each reset. Settings → Test data offers the same controls and can load another fixture to replace the current data. Reset still returns to the startup fixture.

Settings → Test data: storage mode, fixture loading, and workspace reset.
Test Webhooks Without a Tunnel
Testing Google Calendar events.watch and calendarList.watch push notifications against the live API requires a public HTTPS callback. In local development, that usually means running a tunnel and waiting on deliveries you cannot control.
With Calendarpit, you register a channel pointing at a local callback using an access token from the OAuth flow:
curl -X POST http://127.0.0.1:4173/calendar/v3/calendars/primary/events/watch \
-H "Authorization: Bearer <access-token>" \
-H "Content-Type: application/json" \
-d '{
"id": "channel-1",
"type": "web_hook",
"address": "http://127.0.0.1:4175/webhooks/calendar",
"token": "client-verification-token"
}'
Calendarpit sends a sync notification when the channel is created and an exists notification on each change until the channel expires or channels.stop is called. Only loopback hosts are allowed by default, and CALENDARPIT_WATCH_HOSTS adds other hosts such as a Docker Compose service. Each notification gets up to 3 delivery attempts, and tests can call POST /__calendarpit/deliveries/drain instead of sleep() to wait for pending deliveries.

Monitoring active watch channels and inspecting webhook delivery attempts in the admin UI.
How It Is Built: Architecture, Bun, and Storage
Calendarpit is a Turborepo monorepo built on Bun 1.4.3: runtime, package manager, bun:test, bun check for type checking, HTTP server, and the standalone build (Bun.build with compile). Oxlint and Oxfmt handle linting and formatting.

Workspace boundaries and request routing across the Calendarpit monorepo.
-
packages/server: the framework-independent Calendar API, OAuth, and watch logic. -
apps/calendarpit: the React admin UI, TanStack Start adapter, Bun CLI, and packaging. -
apps/sample: a separate Bun app that uses the official@googleapis/calendarandgoogle-auth-libraryas an HTTP integration test. -
apps/docs: the documentation and landing page.
createCalendarpit() exposes an async handler for Web Request objects. It returns a response for API, OAuth, and management routes, or null for UI routes. The TanStack Start entry in apps/calendarpit/src/server.ts tries it first:
fetch: async (request, options) =>
(await runtime.handle(request)) ?? handleStartRequest(request, options)
Server code is grouped by feature with role suffixes: -routes.ts is the HTTP adapter, -operations.ts changes workspace state as one unit of work, and plain rule modules hold validation and calculations. Dependencies point inward, enforced by architecture.test.ts and Oxlint's cycle check. AGENTS.md files at the root and in each workspace document these rules for contributors and coding agents.
I skipped full DDD or onion layers. An emulator's domain model is the Google Calendar API v3 contract, so entity classes, DTO mappers, repository interfaces, and a DI container would only add indirection.
State lives in memory and is checkpointed as a versioned JSON snapshot into one SQLite row via bun:sqlite, with WAL, FULL sync, and a lock file so two instances cannot overwrite the same database. :memory: writes no files.
Time math uses Temporal and the rrule library. The clock and webhook delivery are injected, so Calendarpit's own expiry and retry tests need no sleeps.
The Admin UI and the TanStack Ecosystem
When an E2E run or an agent misbehaves, I want to see the calendar and the exact HTTP calls side by side. That is what the admin UI is for.

The week schedule view in the admin UI showing events across local test calendars.
It runs on React 19, Vite 8 on Bun, Tailwind CSS 4, Gravity UI Icons, and TanStack Start, Router, Query, and Form:
-
TanStack Start and Router: SSR and file-based routes for
/,/users,/calendars,/connections,/watch,/history, and/settings. The selected user, calendar, date, and view live in search params, so a reload or a shared link restores the same view. - TanStack Query: reads the management API and polls every 2 seconds, so events your app or agent writes through the Calendar API appear without a reload.
- TanStack Form: the event, user, calendar, and OAuth client editors, each with one form-level validator.

Creating and editing calendar events in the admin UI using TanStack Form.
The UI has limits too. Dragging events to move or resize them in the calendar grid is not implemented yet, so time changes go through the event editor. The editor covers basic fields, while recurrence, attendees, and conference data are set through the API or fixtures.
Building the Interface with Cloudflare Kumo
For UI components, the admin UI uses @cloudflare/kumo 2.14.0, Cloudflare's MIT-licensed open-source React component library. The community loves shadcn/ui and it is great, but I prefer components I can use as a library rather than copy and paste.
My usual pick is HeroUI, and I kept reaching for it every time. So I looked for a component kit built on a headless UI library and found Kumo, made by Cloudflare and built on Base UI. Kumo (雲) means "cloud" in Japanese, and as a Japanese developer I felt an affinity for it.
Kumo shows up in 24 source files, from Button and Select to DatePicker, Table, Dialog, Toast, and CommandPalette. Wiring it into Tailwind CSS 4 takes three lines in app.css:
@import '@cloudflare/kumo/styles/tailwind';
@import 'tailwindcss';
@source '../node_modules/@cloudflare/kumo/dist/**/*.{js,jsx,ts,tsx}';
Kumo has no time picker, so the event editor pairs Kumo's DatePicker and Popover with a native <input type="time"> themed by Kumo's Input. Phosphor Icons is installed only as a Kumo peer dependency, while the app's own icons come from Gravity UI Icons.

Kumo DatePicker, Popover, Input, and Select components working together inside the event editor.
Cmd+K / Ctrl+K opens Kumo's CommandPalette to jump between sections, switch test users or calendars, or go to a date.

Navigating views and actions with Kumo's CommandPalette.
On top of Kumo, I added a "timing-bench" theme with warm paper surfaces, near-black ink, monospace readouts, and one signal color for selection and the "now" line. A dark theme follows your system setting by default.

The week schedule view rendered in the dark timing-bench theme.
Testing Strategy, Supply-Chain Precautions, and Releases
If your tests rely on Calendarpit, Calendarpit itself has to be well tested. The repository has 61 bun:test files and 5 Playwright E2E files, organized in layers:
| Layer | Runner & Scope |
|---|---|
| Rules & Operations |
bun:test with test.each on pure validation/recurrence functions, plus in-memory workspace operations using a fixed clock. |
| HTTP Contract & Lifecycle | Direct app.handle(Request) contract assertions and temporary SQLite file tests verifying server restarts, tokens, and watch persistence. |
Official SDK (apps/sample) |
Integration tests using the official @googleapis/calendar and google-auth-library clients over HTTP. |
| Browser E2E (Playwright) | Runs against the built CLI (--database :memory:), resetting fixtures before each test and submitting OAuth consent in a real browser. |
| Binary & Docker Smoke | Tests the compiled executable in an isolated directory, plus the container's non-root runtime, HTTP/UI, volume persistence, reset, and memory isolation. |
Dependencies are pinned to exact versions. As a supply-chain precaution, bunfig.toml only resolves package versions published at least 48 hours ago, with a matching 2-day cooldown for npm-check-updates.
CI runs bun run validate, builds and smoke-tests the binary, and runs the Playwright suite. The release workflow validates native executables per OS and CPU, publishes checksummed archives to GitHub Releases, smoke-tests and pushes the GHCR image, and updates the Homebrew formula.
Give It a Try
If you are building a Google Calendar integration, an MCP server, or an AI agent that works with calendar events, give Calendarpit a try locally or in CI:
docker run --rm -p 127.0.0.1:4173:4173 ghcr.io/iktakahiro/calendarpit:latest
- GitHub Repository: https://github.com/iktakahiro/calendarpit
- Documentation (English): https://calendarpit.dev
- Documentation (Japanese): https://calendarpit.dev/ja/
If it saves you time, a GitHub star helps others find it. Issues and feedback are welcome, especially on which endpoints, query parameters, or recurrence patterns you need next.
Top comments (0)