A fork of Calnode, the Apache-2.0 scheduling engine, run by Distronode Corporation as the scheduling engine behind every District AI plan's booking page.
A lean, self-hostable scheduling engine that lives in your AI stack: a Calendly-style booking app with first-party video meetings built in, shipped as one Go binary that serves its own public booking pages and its own admin console. It is API-first, webhook-native, and built for a world where agents do the booking. Nothing is paywalled.
Meeting recording and the AI notetaker are upstream Calnode features. This fork switches
them off under MULTI_TENANT (the MEETING_RECORDING switch below), which is how District
AI runs it: District AI records no meetings.
What this fork adds:
MULTI_TENANT— one PostgreSQL-backed process serving many isolated workspaces, with row-level security as the isolation mechanism, a platform API for provisioning, a signed session hand-off, per-workspace vendor credentials, export/import and erasure. Everything about it is in docs/MULTI_TENANT.md. With the variable unset none of it is reachable, and the engine behaves as upstream's does apart from the branding below.- A PostgreSQL-only image:
ghcr.io/distronode-corporation/district-scheduler, built from Dockerfile.district: distroless, non-root, no SQLite, no Litestream. This repository also keeps upstream'sDockerfile(the single-binary SQLite image), with base images pinned by digest and the Litestream download checked against a recorded checksum. MEETING_RECORDING: a switch that removes meeting recording, the notetaker and the stored notes and transcripts. Unset it is on, as upstream, except underMULTI_TENANT, where it is off: District AI records no meetings. See DEPLOY.md.- District AI branding, unconditional: the transactional email letterhead and the
default
EMAIL_FROM_NAME("District AI Scheduling"), the District AI theme on the hosted booking, manage and index pages, the "Powered by District AI" backlink in the embed widget, and the video room's page title.
The first two were offered upstream and declined on architectural grounds, so they are this fork's to carry. See Relationship to upstream Calnode.
Branches: district is the default branch and what District AI runs. The Go module path is
kept as upstream's so the fork syncs cleanly. See NOTICE for attribution.
Apache-2.0 · Go 1.26+ (built with 1.27.3) · PostgreSQL 17 / SQLite
Status: in production behind District AI. Shipped as a container image by digest
(:edge and :sha-<short> on GHCR); there are no GitHub releases, by design (see
Releases and versioning).
Links: project page · CHANGELOG · upstream Calnode. There is no GitLab mirror of this repository; issues and pull requests live here on GitHub.
Quick start
This fork's image. MULTI_TENANT is PostgreSQL-only, and it needs two DSNs for two
database roles: the platform role that owns the schema, and the application role every
request runs as. Boot refuses anything less, because a single role would make the
row-level security policies inert against the very connection they exist to confine.
docker run -p 3000:3000 \
-e MULTI_TENANT=1 \
-e BASE_URL=https://scheduling.example.com \
-e DATABASE_URL=postgres://app:PASS@db:5432/scheduler \
-e DATABASE_ADMIN_URL=postgres://platform:PASS@db:5432/scheduler \
-e CALNODE_ENCRYPTION_KEY="$(openssl rand -hex 32)" \
-e CALNODE_PLATFORM_TOKEN="$(openssl rand -hex 32)" \
ghcr.io/distronode-corporation/district-scheduler:edge
CALNODE_PLATFORM_TOKEN is the bearer for the platform API (/v1/platform/*); without it
those routes answer 404 and no workspace can be provisioned. Read
docs/MULTI_TENANT.md before running that anywhere real: the
two roles are the whole of the isolation guarantee, startup verifies both, and a workspace
is provisioned through the platform API rather than through the console. The fork publishes
no release tags, so :edge is the tip of district and :sha-<short> is the tag to pin.
See Releases and versioning.
The image's entrypoint is the binary itself, /district-scheduler, so its subcommands
follow the image name. The MCP stdio server, for a single-tenant instance on PostgreSQL:
docker run -i --rm -e DATABASE_URL=postgres://app:PASS@db:5432/scheduler \
ghcr.io/distronode-corporation/district-scheduler:edge mcp
Under MULTI_TENANT the stdio transport is refused (it carries no credential to resolve
a workspace from); agents use the HTTP transport at POST /mcp instead.
Upstream's image, for a look at the engine itself. Upstream Calnode builds it from its
own Dockerfile (SQLite, Litestream, single binary). It needs no database, and it has
none of this fork's additions, the District AI branding included:
docker run -p 3000:3000 -v ./data:/data ghcr.io/calnode/calnode:latest
# → open http://localhost:3000
The whole app — booking pages, admin UI, SQLite — in one container, with data in
./data. With no encryption key set it runs on an ephemeral one (fine for a look;
stored credentials won't survive a restart). Deploying for real — HTTPS, a
persistent encryption key, backups — see Deploy for real below,
or the full DEPLOY.md.
Why District Scheduler
- One binary. The Go code compiles CGO-free to a fully static binary. In the single-tenant SQLite shape (upstream's image) there are no external services to orchestrate at all; the fork's multi-tenant image needs PostgreSQL and nothing else. Built-in video, if you turn it on, is the one add-on: it needs a LiveKit server.
- Meetings built in. Optional first-party video rooms (LiveKit) as a booking
location: guests join in-browser, no app or account. Where recording is on (upstream's
default; off under
MULTI_TENANT), recordings go to the bucket configured by theLITESTREAM_*variables (the SQLite shape's backup bucket; the fork image, which runs no Litestream, reads the same variables for recordings only); an AI notetaker turns each call into a transcript + notes, exposed as MCP tools and webhooks. Video needs a LiveKit endpoint (Cloud or self-hosted). - API-first, agent-ready. A full REST API with API keys and HMAC-signed webhooks configured via API — script every booking action from Claude, ChatGPT, n8n, or curl. Plus a native MCP server built into the binary (official Go SDK; stdio + Streamable HTTP) so agents get first-class booking tools.
- Modern, no bloat. Go backend + a SvelteKit 5 admin app; public booking pages are server-rendered Go templates for instant first paint and a tiny payload.
- Correct by construction. DST-safe time handling (UTC instant + IANA name),
a transactional double-booking guard, and native-API calendar free/busy (never
stale
.icsfeeds). - Yours. Your data, your calendar credentials, your infrastructure. Run one
instance as one workspace, or turn on
MULTI_TENANTand serve many from one process with the database enforcing the boundary. Both shapes are this repository. - Easy to extend. A clean Go codebase with
sqlc-generated queries — not a 100-package monorepo. Add an endpoint without spelunking.
AI-native
Everything a human can do, an agent can do — over the API today:
# Find slots and book, with an API key
curl -s "$BASE/v1/event-types/intro-call/slots?from=2026-06-16&to=2026-06-20&tz=Pacific/Auckland" \
-H "Authorization: Bearer $API_KEY"
curl -s -X POST "$BASE/v1/bookings" -H "Authorization: Bearer $API_KEY" \
-H 'Idempotency-Key: 9f3c…' -H 'Content-Type: application/json' \
-d '{"event_type_slug":"intro-call","start_at":"2026-06-17T21:00:00Z","name":"Alex","email":"alex@example.com","timezone":"Pacific/Auckland"}'
Wire booking lifecycle events (booking.created / .rescheduled / .cancelled)
to n8n / Make / your own service with HMAC-signed webhooks — all configured through
the API, not buried in a UI.
Native MCP server. A Model Context Protocol server is compiled into the binary
(official Go SDK), exposing ten first-class tools: list_event_types,
get_event_type, get_available_slots, create_booking, get_booking,
reschedule_booking, cancel_booking, list_bookings, get_meeting_notes and
get_transcript. The MCP tools call the same internal services as
the REST API (no parallel code path), so booking side effects — calendar events,
confirmation emails, webhooks, reminders — fire identically.
Two transports:
- stdio for local agents: run
calnode mcp(logs to stderr, JSON-RPC on stdout); on the fork's image the binary is/district-scheduler, so it isdocker run -i … mcp(single-tenant only, see Quick start). - Streamable HTTP at
POST /mcpfor remote agents. The binary is its own OAuth 2.1 authorization server (dynamic client registration + PKCE), so an agent adds the server by URL and clicks Connect → signs in with the workspace's Google/Microsoft login → approves a consent screen — no pre-shared key. Acno_API key also works (Authorization: Bearer <key>) for scripts. (The Connect UX needs HTTPS — it shines on a deployed instance.)
User: "Book a 30-min call with Wynne next week — I'm in Auckland."
Agent: get_available_slots("intro-call", "2026-06-16", "2026-06-20", "Pacific/Auckland")
→ presents options → create_booking(…) → returns confirmation + meeting link
Conversational booking — in the booking page itself. Beyond agents, the booking page (and the embed widget) ships an optional "Book by chat" assistant: a visitor types "free Tuesday afternoon or next week" and it resolves real availability and books — the deterministic engine still computes the slots (the model never invents times), and the assistant only ever sees free/busy windows, never your calendar contents. Bring your own model — any OpenAI-compatible endpoint (a hosted model or one you run yourself); off by default, with the standard calendar always there as the fallback.
Connecting Claude (remote / HTTP). In Claude (claude.ai or Desktop) →
Settings → Connectors → Add custom connector → enter https://<your-instance>/mcp
→ Connect → sign in → Allow. Custom connectors need a paid Claude plan; the
server must be on HTTPS. Local stdio alternative (any plan): point an MCP client at
the calnode mcp subcommand via its config file — no OAuth, runs against the local DB.
Permissions. MCP tools are role-scoped, mirroring the rest of the app: an owner/admin acts across the whole workspace, a member sees and manages only bookings they host. (The stdio subcommand is the local operator → full access.) Booking creation and availability are the public booking surface, open to all. Roles are fixed (owner / admin / member); configurable RBAC is intentionally out of the lean core.
Multi-tenant mode
MULTI_TENANT turns one process into a host for many isolated workspaces. A workspaces
table is the tenant root, every application table carries a workspace_id, and
PostgreSQL row-level security, not the query author, is what keeps one workspace out of
another's rows: a statement that forgets its predicate returns nothing rather than
everything. The tenant of a request is resolved from its Host (each workspace has its
own public hostname) or from the credential it carries, and every route declares which,
with a source-scanning test that fails on a registration that declares neither.
It requires PostgreSQL and two roles, because SQLite has no row-level security to express the isolation with. On top of that it adds a platform API for provisioning, export, import and erasure, a signed session hand-off so an external console can seat a session on a tenant's own domain, and per-workspace vendor credentials. Unset, none of it is reachable and nothing about single-tenant behaviour changes.
→ docs/MULTI_TENANT.md is the full contract: the isolation model, the environment, the platform API, and the operator checklist.
Deploy for real
This fork, multi-tenant, which is the shape District AI runs:
docker run -d -p 3000:3000 \
-e MULTI_TENANT=1 \
-e BASE_URL=https://scheduling.example.com \
-e DATABASE_URL=postgres://app:PASS@db:5432/scheduler \
-e DATABASE_ADMIN_URL=postgres://platform:PASS@db:5432/scheduler \
-e CALNODE_ENCRYPTION_KEY="$(openssl rand -hex 32)" \
-e CALNODE_RECOVERY_SECRET="$(openssl rand -hex 32)" \
-e CALNODE_PLATFORM_TOKEN="$(openssl rand -hex 32)" \
-e CALNODE_SSO_SHARED_SECRET="$(openssl rand -hex 32)" \
-e ADMIN_SPA=off \
ghcr.io/distronode-corporation/district-scheduler:sha-abc1234
Single-tenant, on SQLite, from upstream's image:
docker run -d -p 3000:3000 \
-e BASE_URL=https://booking.example.com \
-e CALNODE_ENCRYPTION_KEY="$(openssl rand -hex 32)" \
-e CALNODE_RECOVERY_SECRET="$(openssl rand -hex 32)" \
-e DATABASE_URL=sqlite:///data/calnode.db \
-v calnode-data:/data \
ghcr.io/calnode/calnode:latest
Releases and versioning
This fork has no release lines and no backports. It ships by digest: a push to
district publishes :edge and :sha-<short>, a push to dev publishes :dev and
:sha-<short>, and nothing moves :latest. Pin the digest you tested, or at least
:sha-<short>, which names the commit; :edge and :dev move under you, and then
nothing records which commit an instance is running. Upstream's own tags and releases are
upstream's, and this repository inherits them without publishing releases of its own. See
this fork's releases,
which is deliberately empty, and CHANGELOG.md, which is the record.
Open / → it redirects to /admin/ and walks you through first-run setup (create
the owner account, connect a calendar, add an event type). Put a TLS-terminating
proxy in front that forwards the original Host header. In multi-tenant mode with
ADMIN_SPA=off there is no console to redirect to, and / serves a neutral index of
that workspace's public event types instead.
Full guide → DEPLOY.md (env vars, both images, Railway step-by-step, custom domains, Resend email, Google & Microsoft OAuth, Litestream backups, troubleshooting).
Features
Shipped
- Event types with per-type duration, location, custom questions, custom email copy
- DST-correct availability (working hours, day-of-week rules, date overrides)
- Team routing: fixed · round-robin · collective · priority
- Google Calendar & Microsoft 365 / Outlook — native free/busy conflict checks behind one provider abstraction; auto Google Meet / Teams links, minted only when the host's connected calendar matches the platform (else a manual link is used)
- Sign in with Google or Microsoft (OAuth), email + password, or passwordless magic-link
- CalDAV calendars — iCloud / Fastmail / Nextcloud via app-password (free/busy + event write-back)
- Public booking + self-serve reschedule/cancel via signed manage links
- HTML branded email (logo, banner, business name, size/opacity) with add-to-calendar links
- REST API + API keys; HMAC webhooks with per-webhook payloads + delivery log
- Native MCP server (10 tools incl. meeting notes + transcript; stdio via
calnode mcp+ Streamable HTTP at/mcp) - Conversational booking ("Book by chat" on the booking page + embed widget; BYO-LLM, off by default)
- Paid bookings — Stripe Checkout (pay-then-book: the slot is held, confirmed on the payment webhook, auto-refunded on cancel)
- Zoom — per-host OAuth; a Zoom-located booking mints a meeting under the assigned host's account
- Built-in video meetings (LiveKit) — in-browser rooms as a booking location (no app or account for guests); host controls (end-for-all, hand-off and reclaim host, attendee screen-share toggle), meeting recording straight to the bucket configured by the
LITESTREAM_*variables (the SQLite shape's backup bucket; the fork image uses them for recordings only) with in-app downloads, recording consent (notice + consent-or-leave), and an AI notetaker (Deepgram transcript → LLM notes). Headless-consumable: MCPget_meeting_notes/get_transcript+recording.completed/transcript.ready/notes.readywebhooks. Switchable as a whole withMEETING_RECORDING(off by default underMULTI_TENANT). BYO LiveKit endpoint (Cloud or self-hosted); configured in Settings → Video — see docs/VIDEO.md - 9 languages on every booker-facing surface - booking page, manage/reschedule page,
embed widget, all four emails, and the calendar invite: English · Spanish · French ·
Canadian French · German · Italian · Portuguese · Dutch · Swedish. Picked from
Accept-Languagewith a footer switcher and an operator-set fallback language; the booker's choice is stored on the booking, so reminders arrive in the language they booked in. Adding a language is adding one JSON file - no code change. (The admin UI and the built-in video room are English-only. Non-English translations are machine drafts without native review - corrections by PR are very welcome.) - Embeddable booking widget (Shadow-DOM web component; inline + popup)
- Members, roles (owner/admin/member), email-token invitations
Idempotency-Keyon booking creation; transactional double-booking guard- Envelope encryption at rest (secrets sealed with a KEK; recovery escrow)
- Optional analytics:
<head>code injection +window.dataLayerevents (GTM/GA4) GET /metricsin Prometheus text exposition, bearer-gated onMETRICS_TOKENand a 404 without it- Multi-domain: one instance, many hostnames, each workspace on its own public host in
MULTI_TENANTmode MULTI_TENANT: many isolated workspaces in one process, PostgreSQL row-level security, a platform API, a signed session hand-off, export, import and per-attendee erasure
On the roadmap
- OpenAPI spec
Stack
- Backend: Go (
go 1.26minimum, built and tested with 1.27.3) · PostgreSQL 17 viapgxin the fork image (row-level security, multi-tenant), or pure-Go SQLite (modernc.org/sqlite, CGO-free, the upstream single-binary shape) ·goosemigrations - Admin UI: SvelteKit 2 / Svelte 5 · Vite 8 · Tailwi
