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.
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.
- Multi-calendar aware. Point it at as many
.icsfeeds 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
.icsfile 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.
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.
| 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 |
| 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 |
| 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 | ✅ | ✅ | ✅ |
| Talk To | cal.diy | Calendly | |
|---|---|---|---|
| Real bookings via emailed calendar invites | ✅ | ✅ 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 |
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 --buildThat'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:appEverything about how the page looks and behaves lives in one file. Copy
config.json.example to config.json (same idea as
.env.example → .env — config.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 default —
auto (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.
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.).
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.
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.
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.
| Light | Dark | |
|---|---|---|
| ochre (default) | ![]() |
![]() |
| oxblood | ![]() |
![]() |
| forest | ![]() |
![]() |
| indigo | ![]() |
![]() |
| aubergine | ![]() |
![]() |
| terracotta | ![]() |
![]() |
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.
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 frommain.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) overlatestfor anything long-running.
See available tags and the workflow that builds them.
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."
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.
MIT — clone it, brand it, run it for yourself.













