For developers running Claude Code
Giving Claude Code write access to your calendar, without issuing an API key
Groundkeep's calendar isn't behind a cloud API. It's a row in a SQLite file on your own machine, served by a small local process. When a Claude Code session runs on that same machine, it can write to it with a plain HTTP PUT — no key to generate, no OAuth consent screen, no request that ever leaves localhost. This page walks through exactly how, at the level of the actual request on the wire.
There's no key because there's no third party
An API key exists to answer one question: who is this stranger, and what are they allowed to touch? Google Calendar needs one because your calendar lives on Google's server and the caller could be anyone. Groundkeep's bridge (chat-bridge/server.cjs, a small Node/Express process) binds to localhost:4321 and answers only requests that already arrived on the machine it's running on. A Claude Code session on that machine isn't a stranger — it can already read your files and run arbitrary shell commands under your own OS-level permissions. Asking it to authenticate before it can also add a calendar row would be a barrier around a door that's already open. So by default, the local bridge doesn't require one; auth (HTTP Basic) only gets switched on when you deliberately expose it past localhost, over a tunnel, for remote access from your phone.
That's not the same as "no protection at all." Every write still has to clear a CORS allowlist that only recognizes localhost origins, and PUT/DELETE are never simple requests under the fetch spec — a browser always preflights them, so a malicious webpage sitting in another tab can't quietly fire one at your bridge. The one exception is a bare POST with a "simple" content type, which skips preflight entirely; Groundkeep closes that gap by requiring Content-Type: application/json on every POST, which a plain cross-origin form submission can't set. It's a narrow, specific guard against a narrow, specific hole — not general-purpose auth standing in for one.
The request itself
Every collection — events, tasks, routines, journal entries, and so on — follows the same rule: PUT /api/<collection>/<id> with the complete JSON object as the body. It's a full-object replace (the server runs INSERT ... ON CONFLICT(id) DO UPDATE SET json = excluded.json), not a patch — any field you leave out gets dropped, not preserved. You pick the id yourself; there's no auto-increment or POST-to-create endpoint. Here's an agent adding a real calendar event, exactly as it would leave the terminal:
curl -X PUT http://localhost:4321/api/events/ev9Qx3Lm \
-H "Content-Type: application/json" \
-d '{
"id": "ev9Qx3Lm",
"title": "Dentist",
"date": "2026-09-15",
"start": "09:30",
"end": "10:00",
"color": "primary",
"notes": "",
"done": false,
"createdAt": 1757289600000
}'
The server doesn't validate the shape of that body against a schema — it trusts the caller to match the collection's TypeScript interface (CalEvent, in this case), and it's the agent's job to fetch GET /api/state first if it's editing something that already exists, since a full-replace PUT with a stale copy would silently erase whatever changed since. That trade — no server-side validation, no auth handshake — is only reasonable because the caller and the server are the same trust domain to begin with. It would be a bad idea to expose this endpoint to the open internet unauthenticated; it's a perfectly ordinary one to expose to a process running as you, on your own PC.
What a typical webhook integration doesn't give you
GET /api/state returns every collection as one JSON object, so an agent can check what already exists before deciding whether to create or update.broadcastData event over a Server-Sent Events stream at /api/stream — separate from the chat transcript itself.EventSource on that stream and merges the event straight into its local database — the same path a second device's own edit takes.In practice that means: the agent writes the dentist appointment, and it shows up on the calendar view you already had open — and on your phone, if it has the dashboard open on the same network — without a refresh, a webhook relay, or a polling loop anywhere in the chain.