New
Apps 1,618Makers 1,616This week 1,595
Promote your app−30%
District Scheduler screenshot 1
0views in
the last 30 days

District Scheduler

A lean, self-hostable scheduling engine that lives in your AI stack: a Calendly-style booking...

Built with Claude Code

by distronode-corporation · Unclaimed · from CACanada · added Oct 29, 2026
no ratings0 comments

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's Dockerfile (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 under MULTI_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

OpenSSF Scorecard · Audit it yourself

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 the LITESTREAM_* 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 .ics feeds).
  • Yours. Your data, your calendar credentials, your infrastructure. Run one instance as one workspace, or turn on MULTI_TENANT and 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 is docker run -i … mcp (single-tenant only, see Quick start).
  • Streamable HTTP at POST /mcp for 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. A cno_ 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: MCP get_meeting_notes/get_transcript + recording.completed/transcript.ready/notes.ready webhooks. Switchable as a whole with MEETING_RECORDING (off by default under MULTI_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-Language with 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-Key on booking creation; transactional double-booking guard
  • Envelope encryption at rest (secrets sealed with a KEK; recovery escrow)
  • Optional analytics: <head> code injection + window.dataLayer events (GTM/GA4)
  • GET /metrics in Prometheus text exposition, bearer-gated on METRICS_TOKEN and a 404 without it
  • Multi-domain: one instance, many hostnames, each workspace on its own public host in MULTI_TENANT mode
  • 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.26 minimum, built and tested with 1.27.3) · PostgreSQL 17 via pgx in the fork image (row-level security, multi-tenant), or pure-Go SQLite (modernc.org/sqlite, CGO-free, the upstream single-binary shape) · goose migrations
  • Admin UI: SvelteKit 2 / Svelte 5 · Vite 8 · Tailwi

Description taken from the repository README.

Ratings

–
0 ratings
5 0
4 0
3 0
2 0
1 0

Rate this app

Sign in to rate

Comments 0

Sign in to comment

No comments yet.

Related apps

#scheduling · #booking · #self-hosted