A privacy-first calendar: the scheduling of Calendly, the daily client of Fantastical.
What your invitees tell you β and what you put in your own calendar β is end-to-end encrypted.
The server knows when you are busy. It can never know what you are doing.
Roadmap Β Β·Β Architecture Β Β·Β Zero-knowledge (ADR) Β Β·Β Privacy Β Β·Β Screenshots Β Β·Β Self-hosting
Project status β v0.1. A working scheduler (FastAPI) and booking frontend (Next.js) built on a pure, exhaustively-tested availability engine: timezone- and DST-correct slot computation with no-double-booking guaranteed by the database, not by the application. Multi-tenant from day one (Postgres RLS), zero-knowledge invitee data and calendar content throughout, and part of the ghost suite β it talks to GhostMail, and neither app ever calls the other's backend.
A plain Calendly clone has no reason to exist. GhostCal's reason is privacy, in the spirit of the ghost suite (ghostbit for pastes, ghostmon for monitoring). Scheduling isn't a paste bin β the server must email people and prevent double-booking β so we push zero-knowledge as far as the product allows and encrypt the rest at rest. Three honest tiers:
π Tier 1 β zero-knowledge (the server can never read it). The invitee's name, their answers to your custom questions (phone, address, "what's this about", case numbers, medical contextβ¦) and a free-text notes / agenda field are sealed in the browser (WebCrypto X25519 ECDH β AES-256-GCM) to your organization's public key. The server stores one opaque blob and cannot decrypt it β only you can, in your browser.
Invitee's browser ββseal(name+answers+notes, ORG_PUBLIC_KEY)βββΆ server stores ciphertext only
Your browser ββopen(blob, ORG_PRIVATE_KEY)βββΆ decrypted on the dashboard, never on the server
Your org private key never reaches the server: it is wrapped with a key derived from your password (Argon2id) and again under a one-time recovery key (ProtonMail-style), so a forgotten password is recoverable but the server still holds nothing usable.
π‘οΈ Tier 2 β encrypted at rest (a stolen database dump reveals nothing). Fields the server
genuinely needs β invitee email, additional guest emails, the meeting link/location β
are envelope-encrypted with the application key (transparent SQLAlchemy TypeDecorators). The
server can decrypt them to send a reminder days later; a leaked dump cannot.
β±οΈ Tier 3 β cleartext, irreducible. Only the slot time stays in the clear β it is the
backbone of the Postgres EXCLUDE no-double-booking constraint and drives reminders and calendar
sync. Times without identities leak little.
| Data | What the server can do | Where it's readable |
|---|---|---|
| Name, answers, notes | Nothing β ciphertext only | Your browser (dashboard) |
| Invitee & guest emails, meeting link | Decrypt with the app key to send mail | App memory at send time; never a plain dump |
| Slot time, status | Read (integrity + scheduling) | Database |
No telemetry, no third-party calls on the booking path, multi-tenant Postgres Row-Level Security everywhere, signed stateless links for invitee self-service. See ADR-0002 for the full design and its limits.
Recovery key (zero-knowledge sign-up). The org keypair is generated in the browser; the private key is wrapped under your password and a one-time recovery key β shown once, never sent to the server:
Optional SSO / OIDC. Sign in with your identity provider (Authlib + PKCE). SSO proves who you are; a separate encryption passphrase β which the server never sees β is what decrypts your content, so single sign-on never weakens the zero-knowledge guarantee:
Host dashboard β decrypted in your browser. The booking row's name and the "Decrypted details" panel (phone, topic, notes) are opened client-side; the server only ever held ciphertext:
| Dark | Light |
|---|---|
![]() |
![]() |
Booking page β sealed before it leaves the browser. Timezone-aware slots, custom questions and a notes field; everything personal is end-to-end encrypted on submit:
| Dark | Light |
|---|---|
![]() |
![]() |
Event types & availability. Solo / round-robin / collective / group events with buffers, notice and custom questions; a weekly, timezone-correct availability editor:
| Event types | Availability |
|---|---|
![]() |
![]() |
Private calendar β the server knows when, never what. A zero-knowledge month view: event titles, locations and notes are sealed in your browser; only the times are cleartext (so free-busy and reminders still work). The foundation for ghostmail:
| Dark | Light |
|---|---|
![]() |
![]() |
Share when you are free, never what you are doing. Three ways to share, and they are not the same promise β the UI says so in as many words. A calendar link carries its decryption key in the URL fragment (the server never sees it). An availability link carries no key at all: your busy times are already cleartext on the server (the booking engine has to know them to offer slots), so there is nothing to hand over. Whoever finds it learns when you are occupied, and never once what occupies you β and "Email it" drops it straight into the GhostMail composer:
| Sharing, three ways β dark | Light |
|---|---|
![]() |
![]() |
What the recipient sees. No account, no key, no titles. The seven meetings behind this page are sealed; the server could not name them if it wanted to, and it was never asked to:
| Availability, as a visitor sees it β dark | Light |
|---|---|
![]() |
![]() |
A free, open-source, privacy-first Fantastical. Month / week / day views, plus natural-language quick-add β type βLunch with Sam tomorrow 12:30 for 1h at CafΓ©β and the event is parsed in your browser (EN/FR) and sealed before itβs saved:
| Week view β dark | Week view β light |
|---|---|
![]() |
![]() |
Public calendars & weather, right in the grid. Subscribe to any public iCal/ICS feed (holidays, sports fixtures, a shared calendar) β it becomes a read-only, toggleable overlay. Add a location and the daily forecast overlays the month cells and week/day headers. The feed is fetched server-side (SSRF-guarded); the weather location stays on your device and is never persisted:
| Month + weather + subscription β dark | Light |
|---|---|
![]() |
![]() |
| Week view with forecast in the headers β dark | Light |
|---|---|
![]() |
![]() |
Tasks β a zero-knowledge to-do list. The Fantastical companion you use daily: natural-language quick-add (βCall the dentist tomorrow 3pmβ sets the due date), check to complete, due-date sort. Titles and notes are sealed client-side; only the due date is cleartext:
| Dark | Light |
|---|---|
![]() |
![]() |
- Event types β solo, round-robin (least-loaded host), collective (all hosts attend), and group (many invitees per slot, capacity). Buffers, minimum notice, bookable window, per-day caps, and custom booking questions (text/select/checkbox/β¦) β answers are zero-knowledge.
- Booking page β timezone picker, custom questions, a notes field, additional guests; readable
slug URLs; an embeddable widget (
/embed/...) with a copy-paste iframe snippet. - Private calendar β a zero-knowledge month view: create recurring events whose title/location/notes are sealed in the browser; the server stores ciphertext and only reads the times (for free-busy and reminders). Unified agenda over your events + bookings + external busy.
- Privacy β zero-knowledge invitee name/answers/notes and calendar content (WebCrypto X25519 ECDH + AES-256-GCM, Argon2id-wrapped org key + recovery key); at-rest envelope encryption for emails and meeting links; multi-tenant Postgres RLS; no telemetry, no third-party calls on the booking path.
- Sharing β inside the team (read-only or read-write, decrypted with the team key they already hold); outside it by secret link, the key riding in the URL fragment so the server can serve a calendar it cannot read (ADR-0009); and free-busy links that show when you are busy and never what you are doing β no key, because there is nothing to decrypt (ADR-0010).
- Meeting invitations β an
.icsin a GhostMail message is parsed in the browser and imported exactly: end time, location and recurrence survive, where a natural-language guess would have flattened a 90-minute weekly stand-up into a one-off hour. A cancellation offers no button. - Account lifecycle β deletion (with tombstoned shared bookings), data export, retention and auto-purge (ADR-0006).
- Key rotation β rotate the organization keypair and re-seal, so removing a member truly revokes the key they had cached (ADR-0007).
- Invitee self-service β cancel / reschedule via a signed link (no account).
- Teams β organizations, members & roles (owner/admin/member), token invitations.
- SSO / OIDC β optional single-provider sign-in (Authlib, PKCE), off by default. Authentication only: the zero-knowledge content stays sealed and is unlocked by a separate encryption passphrase the server never sees (Proton/Bitwarden-style), so SSO never weakens the guarantee.
- Meeting polls β propose times β invitees vote β host finalizes and everyone is emailed.
- Notifications β confirmation/cancellation emails with
.ics; automated reminders (Celery). Server-sent mail never names the invitee β that stays encrypted. - Calendar sync β bidirectional CalDAV (read busy + write bookings).
- Public calendar subscriptions β subscribe to any public iCal/ICS feed (holidays, fixtures, a shared calendar); read-only, colour-coded, toggleable overlays, refreshed by a background worker. Fetched server-side behind the SSRF guard; event summaries encrypted at rest.
- Weather β an optional daily forecast (Open-Meteo, keyless) overlaid on the month cells and week/day headers. Proxied server-side to keep the CSP strict; the location stays on-device.
- Integrations β outbound webhooks (HMAC-signed) for booking/poll events.
- i18n β full UI in English and French, with a light/dark theme toggle.
- Ops β per-IP rate limiting, Redis availability cache, booking analytics dashboard, structured JSON logs with request ids.
Shipped: the scheduler; zero-knowledge invitee data and team key sharing; the zero-knowledge
calendar and the personal client on top of it (natural-language quick-add, month/week/day/year views,
tasks, attendees & RSVP, external CalDAV and ICS calendars, notifications, command palette); account
lifecycle and GDPR erasure; organization key rotation (so removing a member revokes the key they
cached); sharing outside the organization by secret link, and free-busy links; and the suite
integration β SSO, the ghostboard portal widget, the app switcher, and the GhostMail bridges in both
directions (.ics invitation import in, "email guests" and "email my availability" out).
A self-hoster whose CalDAV server lives on their own LAN can now reach it: set
GHOSTCAL_CALENDAR_ALLOWED_PRIVATE_CIDRS to the range it sits on. Empty by default, so a hosted
deployment keeps refusing every private address, and the cloud-metadata address stays refused
however it is set. (see ADR-0011)
Next β see docs/roadmap.md: invitations that carry no key in the link.
Python 3.14 Β· FastAPI Β· PostgreSQL (RLS, tstzrange + EXCLUDE) Β· SQLAlchemy 2.0 (async) Β·
Alembic Β· Celery (Redis) Β· Next.js 16 + WebCrypto/hash-wasm (frontend). Tooling: uv, ruff, mypy,
pytest + Hypothesis. Containers built and run with Podman.
Prerequisites: uv, Podman, and PostgreSQL + Redis (via the
provided compose file).
uv sync # create .venv and install all deps
cp .env.example .env # then edit secrets
podman-compose up -d postgres redis # local infra
uv run alembic upgrade head # apply migrations
uv run uvicorn ghostcal.presentation.api:app --reload
# β http://127.0.0.1:8000/health and /docsRun the Celery worker and the beat scheduler (background jobs incl. periodic CalDAV sync and reminders):
uv run celery -A ghostcal.celery_app:celery_app worker -l info
uv run celery -A ghostcal.celery_app:celery_app beat -l infoThe frontend (Next.js) runs separately:
cd frontend && npm install && npm run dev # β http://localhost:3001 (proxies /api to :8000)The whole backend runs as separate Podman containers β Postgres, Redis, the API, the Celery worker
and the Celery beat scheduler (app and Celery share one image). Set the secrets in .env (see
.env.example, including GHOSTCAL_APP_DB_PASSWORD and GHOSTCAL_TOKEN_ENCRYPTION_KEY), then:
podman-compose up -d --build
# postgres provisions the non-privileged app role; the one-shot `migrate` service runs
# `alembic upgrade head`; then app (:8000), worker and beat start.To send real emails, set GHOSTCAL_BREVO_API_KEY or GHOSTCAL_RESEND_API_KEY (otherwise emails are logged, not sent). Brevo is used when both are present.
Key management note.
GHOSTCAL_TOKEN_ENCRYPTION_KEYdecrypts the at-rest (Tier 2) data; keep it stable and backed up. The zero-knowledge (Tier 1) keys are derived in the browser β the server never has them, so rotating the app key never exposes invitee answers.
uv run ruff check . # lint
uv run ruff format --check . # format
uv run mypy # types (strict)
uv run pytest # unit + integration (skips integration when no DB is reachable)
cd frontend && npm run lint && npm run buildHexagonal (ports & adapters): domain/ (pure logic, no I/O) β application/ (use cases + ports) β
infrastructure/ (adapters); presentation/ (FastAPI). The domain never reads the clock β "now"
is injected β so availability is deterministic and property-testable. See
docs/ARCHITECTURE.md and docs/adr/.
Elastic License 2.0. Read it, audit it, self-host it, modify it, run it for your own organisation. What it reserves is resale: you may not provide GhostCal to third parties as a hosted or managed service. That is source available, not open source in the OSI sense. GhostCal was AGPL-3.0-or-later until 2026-08-31 and that change is not retroactive; see NOTICE.






















