Skip to content

Calendar

Knobs Calendar supports multiple calendars per workspace, single and recurring events, attendees with RSVPs, free/busy lookups, external ICS subscriptions, and a token-gated published ICS feed.

All routes are workspace-scoped and accept a session JWT or API key, except the public feed at the end. Calendars use the per-object ACL model (owner + grants + workspaceAccess) with one extra rung: freeBusy, which reveals busy intervals only. Event attendees can always read and RSVP to their own events regardless of calendar access.

Endpoint Description
GET /v1/workspaces/{wid}/calendar/calendars List calendars you can see (ensures your default personal calendar exists)
POST /v1/workspaces/{wid}/calendar/calendars Create {name, color?, timeZone?}
GET /v1/workspaces/{wid}/calendar/calendars/{cid} Calendar metadata
PATCH /v1/workspaces/{wid}/calendar/calendars/{cid} Rename / recolor / retimezone
DELETE /v1/workspaces/{wid}/calendar/calendars/{cid} Delete (the default calendar can’t be deleted)
GET /v1/workspaces/{wid}/calendar/calendars/{cid}/access Sharing panel (incl. the freeBusy level)
PUT /v1/workspaces/{wid}/calendar/calendars/{cid}/access Replace {workspaceAccess, grants}

Render views from instances — the server expands recurrences and applies ACL/privacy filtering:

Endpoint Description
GET /v1/workspaces/{wid}/calendar/instances Expanded instances; ?from=&to= (RFC 3339, required) and ?calendarIds= (comma-separated)
POST /v1/workspaces/{wid}/calendar/events Create an event (single or recurring)
GET /v1/workspaces/{wid}/calendar/events/{eid} The event document (series master, single, or override)
PATCH /v1/workspaces/{wid}/calendar/events/{eid} Edit; on a series add ?mode=single|following|all&originalStart=
DELETE /v1/workspaces/{wid}/calendar/events/{eid} Delete/cancel with the same ?mode=&originalStart= scoping
POST /v1/workspaces/{wid}/calendar/events/{eid}/rsvp {response, originalStart?} — attendee self-service (accepted/declined/tentative; per-occurrence when originalStart is set)
Terminal window
curl -X POST https://api.knobs.io/v1/workspaces/{wid}/calendar/events \
-H "Authorization: Bearer knak_..." \
-H "Content-Type: application/json" \
-d '{
"calendarId": "c_11ff…",
"title": "Sprint planning",
"startLocal": "2026-07-21T10:00:00",
"endLocal": "2026-07-21T11:00:00",
"timeZone": "America/New_York",
"recurrence": ["RRULE:FREQ=WEEKLY;BYDAY=TU"],
"attendeeEmails": ["sam@acme.knobs.io"],
"reminders": [{"method": "notification", "minutesBefore": 10}]
}'

Times are wall-clock local (startLocal/endLocal) plus an IANA timeZone — recurring events stay at the same local time across DST. allDay: true with date-only values creates all-day events. Recurrence is RFC 5545 RRULE strings.

Terminal window
curl -X PATCH "https://api.knobs.io/v1/workspaces/{wid}/calendar/events/{eid}?mode=single&originalStart=2026-07-28T14:00:00Z" \
-H "Authorization: Bearer knak_..." \
-H "Content-Type: application/json" \
-d '{"title": "Sprint planning (moved)", "startLocal": "2026-07-28T11:00:00", "endLocal": "2026-07-28T12:00:00"}'

mode=single overrides one occurrence, mode=following splits the series, mode=all edits the master.

Endpoint Description
POST /v1/workspaces/{wid}/calendar/freebusy {uids?[], emails?[], from, to} → merged busy blocks + working hours per target (intervals only, never titles)
GET /v1/workspaces/{wid}/calendar/settings Your display timezone, working hours, default reminders
PUT /v1/workspaces/{wid}/calendar/settings Replace those settings
Endpoint Description
POST /v1/workspaces/{wid}/calendar/events:quick-add NLP quick-add {text, tz, confirm} — confirm:false returns {draft} for review; confirm:true creates → {draft, event}. Rate-limited
POST /v1/workspaces/{wid}/calendar/events:extract-from-mail LLM event draft from a thread {threadId, tz?} → {draft, sourceRef}
POST /v1/workspaces/{wid}/calendar/events:create-from-mail Non-AI source-link create {objectId, title?} from a mail thread
POST /v1/workspaces/{wid}/calendar/events:create-from-chat Same from a chat message
POST /v1/workspaces/{wid}/calendar/invites:import Import an inbound iCalendar invite {messageId, respond?} — adds it to your default calendar and optionally sends the iTIP reply
Endpoint Description
GET /v1/workspaces/{wid}/calendar/subscriptions List ICS subscriptions
POST /v1/workspaces/{wid}/calendar/subscriptions Add {url, name} — creates a read-only overlay calendar
DELETE /v1/workspaces/{wid}/calendar/subscriptions/{sid} Remove (and its overlay calendar)
POST /v1/workspaces/{wid}/calendar/subscriptions/{sid}/poll Poll the feed now

Subscribed calendars are read-only (403 read_only_calendar on event writes).

Owners can publish a calendar behind a capability token:

Endpoint Description
POST /v1/workspaces/{wid}/calendar/calendars/{cid}/feed/rotate Mint/rotate the publish token → {token, path} — shown once; rotating invalidates the old URL

The published feed itself is the platform’s one anonymous data-bearing GET (note: not under /v1):

GET /calendar-feeds/{cid}/{token}/calendar.ics
Terminal window
curl https://api.knobs.io/calendar-feeds/{cid}/{token}/calendar.ics

It requires no bearer, is rate-limited per IP, answers a uniform 404 to a bad calendar or token, and exports private events as busy placeholders. Paste the URL into any calendar app that supports ICS subscriptions.

GET …/calendar/instances accepts repeatable attendeeEmail and attendeeDomain parameters (≤30 each), unioned with each other.

These match external attendees, which the ordinary attendee lookup cannot: it filters on member uids, and someone outside the workspace has none.

The filter narrows what you could already see; it never widens it. Instances you have no access to do not appear because you named someone on them.

A busy-only instance — one downgraded for privacy or free/busy-tier access — carries no attendees at all, so it can never match an attendee filter and drops out of a filtered response entirely. That is deliberate: matching it would itself reveal that someone on your filter list is on a private event.

For third-party OAuth tokens. Reads and writes are separate grants.

Routes Scope
GET …/calendar/calendars, …/calendars/{cid}, …/calendars/{cid}/access, …/instances, …/events/{eid}, …/settings, …/subscriptions; POST …/calendar/freebusy calendar:read
POST/PATCH/DELETE …/calendar/calendars…, PUT …/access, POST/PATCH/DELETE …/calendar/events…, POST …/events/{eid}/rsvp, PUT …/settings, subscription create/delete/poll, POST …/calendar/invites:import calendar:write
POST …/calendar/events:quick-add calendar:ai
POST …/calendar/events:extract-from-mail calendar:ai and mail:read
POST …/calendar/events:create-from-mail calendar:write and mail:read
POST …/calendar/events:create-from-chat, POST …/calendars/{cid}/feed/rotate none — OAuth tokens are refused (Chat has no OAuth scope; rotating a feed token mints a credential)

freebusy is a read despite being a POST — the body carries the target list, not a change.