Skip to content

Repository files navigation

Talk To

A tiny, self-hosted scheduling page you actually own.

Talk To turns your calendars into a single link you can drop in an email or a bio: "here's when I'm free — pick a time." It reads your .ics calendar feeds (Google, Outlook, iCloud, anything that exports one), overlays them on your weekly working hours, and shows visitors nothing but the open slots — no account required on either end, no data leaving your own server.

CI Docker License: MIT

See it live →

Talk To booking screen, light theme Talk To booking screen, dark theme

Why

Calendly-style tools are great until you need them to check more than one calendar — your personal calendar, your family's, your work one — at which point you're either paying for the "premium" tier or copy-pasting free/busy times by hand. Talk To does the one thing those tools charge extra for — crossing multiple calendars against your availability — for free, on hardware you control, with nothing stored about your visitors because nothing needs to be.

Features

  • Multi-calendar aware. Point it at as many .ics feeds as you like (personal, work, family) — a slot only shows up as free if it's clear on all of them.
  • Your own hours, your own rules. Set a weekly availability template once — different hours per weekday, lunch breaks, half-days — and it's applied automatically going forward.
  • Timezone-smart. Visitors always see times converted to their own timezone, with a plain-English note when it isn't the same as yours.
  • Real bookings, not just links. Configure SMTP and a visitor can reserve an open slot right on the page — it emails a proper calendar invite to both of you, blocking the time on both calendars, with a cancel link in the invite for either side to back out later. (Every slot can still be added straight to Google Calendar or downloaded as a universal .ics file instead, no SMTP required.)
  • Looks like it was designed, not scaffolded. Six built-in color palettes, automatic light/dark mode, no generic admin-template look — and a light/dark toggle in the corner so visitors can override it too, remembered for their next visit.
  • No account, no database, no tracking. It's a stateless page backed by one JSON config file. Nothing to migrate, back up, or leak.
  • Refreshes itself. Calendars are re-fetched on a timer in the background, so the page is always showing current availability.

How it compares

Two similar tools people land on while looking for a Calendly replacement: cal.diy (the MIT-licensed, self-hosted fork of Cal.com with enterprise features stripped out) and Calendly itself.

Hosting & ownership

Talk To cal.diy Calendly
Self-hosted (your server, your data) ❌ SaaS only
Free / open source ✅ MIT ✅ MIT ❌ Free tier is crippled; paid from ~$10–12/user/mo
No account or login needed to administer it ❌ full user-auth system ❌ your Calendly account

Setup & footprint

Talk To cal.diy Calendly
Requires a database ✅ PostgreSQL n/a (their infra)
Requires a build step / Node toolchain ❌ (~1,500 lines Python, CDN frontend) ✅ Next.js/Yarn build, 4GB+ RAM recommended — slow, and prone to getting OOM-killed mid-build in practice n/a
Self-hoster's own docs call it production-ready ❌ "strictly recommended for personal, non-production use" n/a

Calendar handling

Talk To cal.diy Calendly
Reads any exported .ics URL directly, no OAuth/API keys ❌ OAuth calendar integrations ❌ OAuth calendar integrations
Crosses multiple calendars for free/busy, for free ✅ unlimited, every install ✅ via connected integrations ❌ free plan = 1 calendar; 6 calendars needs a paid plan
Visitor timezone auto-detected and shown

Booking, team features & extras

Talk To cal.diy Calendly
Real bookings via emailed calendar invites ⚠️ core booking flow only — the automated reminder/follow-up engine ("Workflows") was removed ✅ plus automated reminders/follow-ups
Cancel link removes the event from both calendars — undocumented in the OSS fork
Team / round-robin / multi-host scheduling ❌ deliberately single-user ❌ removed (Teams/Organizations) ✅ paid plans
Routing forms / lead qualification ❌ removed ✅ paid plans
Payment collection at booking (Stripe/PayPal) — not in the OSS fork ✅ paid plans
Analytics / insights dashboard ❌ removed ✅ paid plans
SSO / SAML ❌ (no auth at all) ❌ removed Enterprise plan only
Custom color themes + auto light/dark, no paid tier ✅ 6 palettes ⚠️ branding exists, degree varies ⚠️ limited on free/standard, full white-labeling is a paid add-on

Quick start

git clone https://github.com/mpcabd/talk-to.git
cd talk-to
cp .env.example .env
cp config.json.example config.json
# edit config.json with your calendar URLs and availability hours
docker compose up --build

That's it — visit http://localhost:5050.

Prefer running it without Docker? You only need Python 3.13+ and uv:

uv sync
cp config.json.example config.json
uv run hypercorn -b 127.0.0.1:5099 talk_to.app:app

Setting it up

Everything about how the page looks and behaves lives in one file. Copy config.json.example to config.json (same idea as .env.example.envconfig.json holds your real calendar URLs, so it's gitignored and never committed) and edit it:

{
  "name": "Jane Doe",
  "email": "jane@example.com",
  "calendars": ["https://calendar.google.com/calendar/ical/.../basic.ics"],
  "timezone": "Europe/Berlin",
  "palette": "indigo",
  "theme": "auto",
  "weekday_availability": [
    [["09:00", "13:00"], ["14:00", "18:00"]],  // Monday, with a lunch break
    [["09:00", "13:00"], ["14:00", "18:00"]],  // Tuesday
    [["09:00", "13:00"], ["14:00", "18:00"]],  // Wednesday
    [["09:00", "13:00"], ["14:00", "18:00"]],  // Thursday
    [["09:00", "12:00"], ["14:30", "15:00"], ["17:00", "18:00"]],  // Friday
    [],  // Saturday — not available
    []   // Sunday — not available
  ]
}

weekday_availability takes seven entries (Monday first), each a list of ["HH:MM", "HH:MM"] ranges — as many as you like per day, as long as they don't overlap.

palette picks the color scheme: ochre (default), oxblood, forest, indigo, aubergine, or terracotta. theme is just the defaultauto (follows the visitor's system setting, default), light, or dark — visitors can always flip it themselves with the toggle in the corner of the page, which is remembered in their browser for next time. See every palette in both themes below.

Changed the config and don't want to restart the container? POST /reload picks it up and re-fetches your calendars on the spot.

Favicon and social sharing preview

Drop a static/favicon.png (square, ideally 512×512 or larger) and a static/hero.jpg (landscape, ideally 1200×630 — the standard Open Graph/ Twitter Card ratio) into static/ and they're picked up automatically: the favicon becomes the browser tab icon and iOS home-screen icon, and the hero image becomes the preview picture shown when the page is shared on Facebook, LinkedIn, X/Twitter, Slack, etc. Both are optional — the page works fine without them, just without a tab icon or a preview image on share. Unlike config.json, static/ isn't gitignored — files placed there get committed like any other file in the repo. If you'd rather keep your own favicon/hero out of a public fork, name the files favicon.png.local/hero.jpg.local instead (any *.local path is gitignored) and update the two filenames templates/index.html references.

Only / itself is meant to be indexed/shared — robots.txt explicitly allows just the homepage and disallows every other path (booking routes, /availability, etc.).

Real bookings (SMTP)

Add an smtp block to config.json and visitors get a "Reserve" form instead of just the manual add-to-calendar links:

{
  "smtp": {
    "host": "smtp.gmail.com",
    "port": 587,
    "username": "jane@example.com",
    "password": "an-app-password",
    "from_address": "jane@example.com",
    "use_tls": true
  },
  "booking_confirmation": "immediate"
}

booking_confirmation is "immediate" (the booking finalizes and invites go out the moment a visitor submits the form) or "host" (you get emailed a confirm/decline link first; nothing is booked until you click it). Omit smtp entirely to disable the Reserve form and keep the manual-link behavior only.

port decides which TLS handshake gets used, not use_tls alone: 465 means implicit TLS (SMTPS — TLS from the first byte), anything else means STARTTLS (a plaintext connection upgraded to TLS), matching how mail providers actually offer these ports. use_tls just turns TLS on/off within whichever of those the port implies; set it to false only for a local, unencrypted test catcher.

Prefer not to put SMTP credentials in config.json? Set these env vars instead (e.g. in .env, or injected as container secrets) — they're only used as a fallback when config.json has no smtp key at all; a smtp block in config.json, if present, always wins outright over them:

TALK_TO_SMTP_HOST=smtp.gmail.com
TALK_TO_SMTP_PORT=587
TALK_TO_SMTP_USERNAME=jane@example.com
TALK_TO_SMTP_PASSWORD=an-app-password
TALK_TO_SMTP_FROM_ADDRESS=jane@example.com
TALK_TO_SMTP_USE_TLS=true   # optional, defaults to true

Setting some of these but not all of them is treated as a startup error, same as a malformed smtp block in config.json — a half-configured SMTP setup fails loudly rather than silently doing nothing.

Without SMTP configured anywhere (neither source), the Reserve form never appears, and /reserve, /confirm-booking/*, /decline-booking/*, and /cancel-booking/* all respond 404 — they behave as if they don't exist, not as a disabled feature explaining itself.

Every confirmed invite — yours and the guest's — includes a cancel link. Clicking it frees the slot immediately and emails a proper cancellation (METHOD:CANCEL) to both of you, so it comes off both calendars the same way it was added. There's no separate reschedule flow; cancelling links back to the booking page to pick a new time instead.

Deliverability matters more than anything else here. No amount of correctly-formed invite email guarantees it lands on either calendar — that depends entirely on your mail provider's SPF/DKIM/DMARC setup, not this app. The safest setup by far is pointing smtp at the same mailbox from_address belongs to (e.g. smtp.gmail.com with that Gmail account's own credentials/app password) — invites you send yourself are the best-supported case across Gmail/Outlook/Apple Mail. If invites end up in spam or never auto-add, that's a mail-provider/DNS problem to fix on your end, not a bug in the invite itself.

Injecting your own tracking/scripts

By default the page loads nothing but its own CDN-hosted libraries and sends no data anywhere else — that's what the "no tracking" feature above means. If you want to add something (analytics, a pixel, a chat widget), drop a file at templates/extra-header.html and/or templates/extra-footer.html; if present, their contents are included verbatim into the page — the header one right before </head>, the footer one right before </body>. Neither file exists by default (they're gitignored, so your own copies never get committed), and the page renders identically to today if you never create them. No rebuild needed — the whole repo is already bind-mounted into the container, so a file dropped in takes effect on the next request.

Palettes

Click any thumbnail for the full-size screenshot. theme: "auto" (the default) just follows whichever of the two the visitor's system is already set to.

LightDark
ochre (default)
oxblood
forest
indigo
aubergine
terracotta

Deploying it for real

The included docker-compose.yml is meant for both local dev and production — the same image, everything switched by your .env file:

QUART_ENV=production
QUART_LOG_LEVEL=ERROR
PORT=80

Put it behind nginx, Caddy, or Traefik for TLS and you're done. See .env.example for every available knob.

Using the published image

Don't want to clone the repo and build it yourself? Every push builds and publishes an image to GitHub Container Registry — just point your own docker-compose.yml (or docker run) at it instead of a local build:. You still need your own config.json next to it (see "Setting it up" above) — that's the only required file:

services:
  talk_to:
    image: ghcr.io/mpcabd/talk-to:latest
    restart: always
    environment:
      QUART_ENV: production
      QUART_LOG_LEVEL: ERROR
      PORT: 5050
    volumes:
      - ./config.json:/usr/src/app/config.json
      - ./static/me.jpg:/usr/src/app/static/me.jpg # optional, your own photo
    ports:
      - "5050:5050"

Available tags:

  • latest — the newest build from main.
  • sha-xxxxxxx — every build is also tagged with its commit SHA, for pinning to an exact, reproducible version.
  • vX.Y.Z (e.g. v1.3.0) — pushing a git tag publishes an image tagged to match, for stable, deliberate releases. Prefer these (or a SHA) over latest for anything long-running.

See available tags and the workflow that builds them.

How small is it, really

There's no database, no build step, no npm install, no background job queue. The whole backend is ~1,500 lines of Python behind 7 dependencies; the entire frontend is three hand-written files (HTML, CSS, and vanilla-ish Vue) loaded straight from a CDN — nothing to compile, nothing to bundle. It's backed by a 192-test suite with 99% coverage, so "small" doesn't mean "fragile."

Built with

Quart for the async backend, Vue 3 for the frontend with no build step, icalendar + recurring-ical-events to parse and expand real-world .ics feeds correctly (recurring events and all), aiosmtplib to send real booking invites, and Luxon for the timezone math.

License

MIT — clone it, brand it, run it for yourself.

About

A simple web app to show your availability for people who want to talk to you

Resources

Stars

5 stars

Watchers

1 watching

Forks

Releases

Packages

Used by

Contributors

Languages