Framework-agnostic Python toolkit for calendars and reminders — data in, RFC 5545 bytes out.
calkit turns plain Python data into calendar and reminder artifacts: .ics
files (VEVENT, VTODO tasks, VALARM, timezones), Google Calendar
"add event" links, and Apple Reminders via the Shortcuts app. It is pure
functions — no aiogram, FastAPI, Telegram, network, auth, or secrets. The
core builds strings and bytes; how you deliver them (a Telegram document, an
HTTP download, an inline button) stays in your project. Reuse the same core from
a Telegram bot and a web backend alike.
Built on the reliable icalendar library
(BSD) for correct RFC 5545 serialization.
- Python 3.10+
- Typed (
py.typedshipped) - Zero framework dependencies (only
icalendar) - MIT licensed
PyPI naming note: the distribution name in
pyproject.tomliscalkit. Before publishing to PyPI, verify the name is not already taken — if it is, rename the distribution and keep the import namecalkit.
pip install calkit # once published
# or, from a local checkout:
pip install -e ".[dev]".icsgeneration — single events (build_event) or whole calendars (build_calendar), returned as RFC 5545 bytes.- Tasks (
VTODO) —build_todo(...)builds a realVTODOcomponent withDUE, optionalDTSTART,PRIORITY(0–9),STATUS(NEEDS-ACTION/IN-PROCESS/COMPLETED/CANCELLED), andVALARMreminders — same timezone and URL handling asbuild_event. - Timed & all-day events — pass a
datetimeor adate; all-day emitsVALUE=DATEwith a correct exclusiveDTEND. - Timezones done right — attach an IANA/zoneinfo name to naive datetimes;
the matching
VTIMEZONEis embedded so the file is self-contained. VALARMreminders — one or more display alarms fromtimedeltaoffsets orAlarmobjects.- Google Calendar links —
google_template_url(...)gives Google users a reliable path (Google ignoresVALARMfrom imported.ics). - Apple Reminders via Shortcuts — pure string/bytes plumbing to drive Apple's
Shortcuts app into creating a Reminders task: a
shortcuts://launch URL, a fixed JSON payload contract, an unsigned.shortcutfile builder, Apple platform detection, and ready-made first-run onboarding + per-task landing pages (RU/EN). - No delivery lock-in — the library never touches the network, auth, or secrets. It hands you strings and bytes; delivery is yours.
- Typed and dependency-light — ships
py.typed; the only runtime dependency isicalendar.
build_event(
*, summary, start, end=None, all_day=False, tz="UTC",
location=None, url=None, description=None,
alarms=(), uid=None, organizer=None,
) -> bytes # one VEVENT inside a VCALENDAR
build_calendar(events) -> bytes # many Event objects -> one VCALENDAR
build_todo(
*, summary, due=None, start=None, all_day=False, tz="UTC",
location=None, url=None, description=None, alarms=(),
priority=None, status=None, uid=None,
) -> bytes # one VTODO (task) inside a VCALENDAR
google_template_url(
*, summary, start, end, details=None, location=None,
) -> str # Google Calendar "add event" link
# --- Apple Reminders via Shortcuts (pure strings/bytes, no network) ---
shortcuts_reminder_url(
*, name, title, due=None, notes=None, url=None,
tz="UTC", shortcut_input_extra=None,
) -> str # shortcuts://run-shortcut launch URL
detect_apple(user_agent) -> AppleDevice # UA -> platform guess
reminder_entry_mode(user_agent) -> str # "apple" | "unknown"
reminder_landing_html(
*, shortcut_url, mode, lang="ru", labels=None,
) -> str # per-task self-contained landing page
# First-run setup (prepare the Shortcut once)
build_reminder_shortcut(*, name="Add Reminder") -> bytes # unsigned .shortcut plist
write_reminder_shortcut(path, *, name="Add Reminder") -> None
reminder_setup_instructions(
*, lang="ru", source="untrusted",
) -> list[str] # ordered first-run steps ("untrusted"|"icloud")
reminder_setup_html(
*, install_url, source="icloud", lang="ru", labels=None, mode=None,
) -> str # self-contained first-run setup pageConvenience dataclasses Event, Alarm, and AppleDevice are also exported.
import datetime as dt
from calkit import build_event, Alarm
ics = build_event(
summary="Dentist appointment",
start=dt.datetime(2026, 7, 1, 15, 0),
end=dt.datetime(2026, 7, 1, 15, 45),
tz="Europe/Berlin",
location="Main St 5",
url="https://example.com/booking/42",
description="Bring the insurance card.",
alarms=[
-dt.timedelta(hours=1), # 1 hour before
Alarm(-dt.timedelta(days=1), "Tomorrow!"), # 1 day before, custom text
],
)
with open("event.ics", "wb") as f:
f.write(ics)All-day event:
import datetime as dt
from calkit import build_event
ics = build_event(summary="Public holiday", start=dt.date(2026, 7, 4), all_day=True)Task (VTODO) with a due date, priority, and a reminder:
import datetime as dt
from calkit import build_todo
ics = build_todo(
summary="File quarterly taxes",
due=dt.datetime(2026, 7, 15, 17, 0),
tz="Europe/Berlin",
priority=1, # 0 = undefined, 1 = highest, 9 = lowest
status="NEEDS-ACTION", # NEEDS-ACTION | IN-PROCESS | COMPLETED | CANCELLED
url="https://example.com/tax/q2",
description="Gather receipts first.",
alarms=[-dt.timedelta(days=1)], # remind 1 day before due
)
with open("task.ics", "wb") as f:
f.write(ics)Multiple events in one calendar:
import datetime as dt
from calkit import build_calendar, Event
ics = build_calendar([
Event(summary="Kickoff", start=dt.datetime(2026, 7, 1, 9, 0), tz="UTC"),
Event(summary="Review", start=dt.datetime(2026, 7, 8, 9, 0), tz="UTC"),
])The library only produces bytes/strings — how you deliver them is up to the caller. Two common patterns (illustrative; not part of the library):
import datetime as dt
from calkit import build_event
# aiogram v3 example
from aiogram.types import BufferedInputFile
ics = build_event(
summary="Consultation",
start=dt.datetime(2026, 7, 1, 15, 0),
tz="Europe/Berlin",
)
await message.answer_document(
BufferedInputFile(ics, filename="event.ics"),
caption="Add this to your calendar",
)import datetime as dt
from fastapi import FastAPI
from fastapi.responses import Response
from calkit import build_event, google_template_url
app = FastAPI()
@app.get("/event.ics")
def event_ics():
ics = build_event(
summary="Consultation",
start=dt.datetime(2026, 7, 1, 15, 0),
end=dt.datetime(2026, 7, 1, 16, 0),
tz="Europe/Berlin",
)
return Response(
content=ics,
media_type="text/calendar",
headers={"Content-Disposition": 'attachment; filename="event.ics"'},
)
# Render this URL as an "Add to Google Calendar" button next to the download:
gcal = google_template_url(
summary="Consultation",
start=dt.datetime(2026, 7, 1, 15, 0),
end=dt.datetime(2026, 7, 1, 16, 0),
details="See you there.",
location="Main St 5",
)Apple has no public URL scheme to create a Reminders task directly, but the
Shortcuts app does: a shortcut can receive a dictionary as input and add a
Reminder from it. calkit supplies the string plumbing for that flow (pure
functions — no network, no auth).
- One-time setup (first run, per user). The user installs one Shortcut
that reads its dictionary input and creates a Reminder from a fixed set
of keys. There are two install sources — see
First-run setup below.
calkitcan generate the setup material for either. - Per-task launch. For each task the project builds a
shortcuts://run-shortcut?name=<Shortcut name>&input=<JSON>URL withshortcuts_reminder_url(...)and opens it on the Apple device. The Shortcut receives the JSON and creates the Reminder.
The JSON passed as input always carries these keys, so your Shortcut has a
stable contract:
| key | type | meaning |
|---|---|---|
title |
str |
reminder title |
due |
str|null |
ISO-8601 due date/time (offset if aware) |
notes |
str|null |
free-text notes |
url |
str|null |
associated link |
Extra fields can be merged in via shortcut_input_extra (e.g. a target list
name or priority) without breaking the fixed keys.
import datetime as dt
from calkit import shortcuts_reminder_url
link = shortcuts_reminder_url(
name="Add Reminder", # the installed Shortcut's name
title="Call the dentist",
due=dt.datetime(2026, 7, 1, 15, 0), # naive -> localized to tz below
notes="Bring the insurance card.",
url="https://example.com/booking/42",
tz="Europe/Berlin",
shortcut_input_extra={"list": "Personal"}, # optional extra fields
)
# shortcuts://run-shortcut?name=Add%20Reminder&input=%7B%22title%22%3A...%7Dname and the JSON input are fully URL-escaped; the JSON is compact and
ensure_ascii=False, so non-ASCII text stays readable before percent-encoding.
Per-task launch (above) only works once the Shortcut is installed. calkit
carries the first-run onboarding as a library concern, so a project does
not assemble the Shortcut or the setup page by hand.
Honest Apple limit — read this first. An Apple-signed iCloud share link
(https://www.icloud.com/shortcuts/...) cannot be generated from code — the
signature is minted on Apple's side. So there are two install sources:
| source | how it's obtained | user friction on device | recommendation |
|---|---|---|---|
"untrusted" |
build_reminder_shortcut() generates an unsigned .shortcut |
must enable "Allow Untrusted Shortcuts" in Settings + run it once manually to grant Reminders access | self-contained fallback |
"icloud" |
a human publishes the Shortcut once and pastes its iCloud link | none — tap to add | recommended for prod |
The first-run onboarding works with either source; you pick which via the
source argument and the install_url you pass.
⚠️ The generated.shortcutis not verified on a physical Apple device.build_reminder_shortcut()emits a valid binary property list using Apple's documented action identifiers (is.workflow.actions.detect.dictionary,is.workflow.actions.getvalueforkey,is.workflow.actions.addnewreminder) and the standard magic-variable serialization, but the exact runtime wiring (due-date alarm, notes concatenation) has not been round-tripped on a real device — treat the first on-device import as a one-time validation step.
(A) built-in unsigned .shortcut:
from calkit import build_reminder_shortcut, write_reminder_shortcut
data = build_reminder_shortcut(name="Add Reminder") # -> bytes (binary plist)
write_reminder_shortcut("add-reminder.shortcut", name="Add Reminder")
# Serve `data` from an endpoint, or attach the file. Then point the setup
# page's install button at that download URL with source="untrusted".Setup page and steps (works for either source):
from calkit import (
reminder_setup_instructions,
reminder_setup_html,
reminder_entry_mode,
)
# Plain ordered steps (render however you like):
steps = reminder_setup_instructions(lang="ru", source="untrusted")
# Or a full self-contained page: numbered steps + an "Install" button on
# install_url + a "I've installed it -> continue" hook (id="reminder-continue",
# which your app wires to its next step). Pass mode="unknown" to warn a
# non-Apple visitor; an inline script re-checks the client (incl. iPadOS
# masquerade via maxTouchPoints).
mode = reminder_entry_mode(request.headers.get("User-Agent"))
html = reminder_setup_html(
install_url="https://www.icloud.com/shortcuts/abc123", # signed iCloud link
source="icloud",
lang="ru",
mode=mode,
)Whether the Shortcut is already installed is state the project owns. The
library does not track it. Your flow is: on a user's first reminder, show
reminder_setup_html(...); once they confirm install (your "continue" hook),
remember that and from then on go straight to the per-task
reminder_landing_html(...) + shortcuts_reminder_url(...) path.
Full first-run → per-task flow:
- First run: user has no Shortcut yet → render
reminder_setup_html(...)with your chosensource/install_url; user installs it and taps continue; your project records "installed". - Per task, thereafter: build the launch URL with
shortcuts_reminder_url(...)and hand it to the user viareminder_landing_html(...)(or your own UI).
Telegram inline buttons reject custom schemes like shortcuts:// — they
only accept http(s)://. So don't put the shortcuts:// URL on an inline
button directly. Instead, host a small https landing page and link the
button there; the landing page carries the shortcuts:// link as a normal
anchor the user taps. reminder_landing_html(...) renders exactly such a page.
detect_apple(user_agent) classifies the visitor's User-Agent into an
AppleDevice(is_apple, kind) where kind is one of
iphone/ipad/ipod/mac/unknown. reminder_entry_mode(user_agent)
collapses that to "apple" or "unknown".
Limitation: iPadOS Safari can masquerade as
Macintosh, so a modern iPad may reportkind="mac"— stillis_apple=True, which is all this flow needs.
reminder_landing_html(...) renders a self-contained, dependency-free page with
two states:
mode="apple"— Apple detected: shows the action as an explicit tool, a prominent "📋 Добавить в Напоминания" button linking to theshortcuts://URL.mode="unknown"— not detected: shows the same button as an option plus a warning that it only works on an Apple device, and the user may continue if they are on one.
An inline <script> re-checks navigator on the client (more accurate than the
server's UA guess, and handles the iPadOS masquerade via maxTouchPoints); if
it finds Apple, it flips an unknown page into the explicit-tool state. Default
texts ship for lang="ru" and lang="en"; labels overrides any string.
from calkit import reminder_entry_mode, shortcuts_reminder_url, reminder_landing_html
mode = reminder_entry_mode(request.headers.get("User-Agent"))
link = shortcuts_reminder_url(name="Add Reminder", title="Call the dentist")
html = reminder_landing_html(shortcut_url=link, mode=mode, lang="ru")
# return `html` as text/html from your web handlerreminder_landing_html is an optional convenience helper — a project can
render its own UI using reminder_entry_mode + shortcuts_reminder_url.
Scope: this path is Apple-only. For non-Apple users, fall back to a calendar reminder (
build_event(...)with aVALARM, orgoogle_template_url(...)) or another task-provider integration.
A framework-agnostic core: data → strings, nothing else. The functions here
take plain Python values and return bytes or str. They do not import a web or
bot framework, open a socket, read an environment variable, or hold a secret.
That is deliberate: the same build_event(...) powers a Telegram bot and a
FastAPI endpoint without either dragging in the other's dependencies.
Generation is separated from delivery. Building an .ics and sending it
are different jobs with different failure modes. calkit owns the first and
stays out of the second. It hands you the artifact; you decide whether it becomes
a Telegram document, an HTTP response with a Content-Disposition header, or a
file on disk. This keeps the tested surface small and pure, and lets delivery
evolve (new transports, new frameworks) without touching generation.
Honest about platform limits. Calendars have a universal file (.ics);
tasks do not. There is no single file a user can open to create a task in their
task manager, and an Apple-signed iCloud Shortcut link cannot be minted from code
— signing happens on Apple's side. Rather than pretend otherwise, calkit
models the real mechanics: .ics for calendars, google_template_url for Google,
and a Shortcuts-based flow (with two honest install sources) for Apple Reminders.
See docs/01-tasks-have-no-universal-file.md
for the full reasoning.
First-run onboarding lives in the library, auth does not. The fiddly,
reusable parts of the Apple flow — the setup steps, the install page, the
per-task landing page, platform detection — ship with calkit so projects don't
reinvent them. Anything that needs a secret or an account (OAuth for a task
provider, hosting a signed link) stays in the project, by design.
Calendar clients differ in how they treat imported .ics files:
-
Reminders / VALARM. Apple Calendar and Microsoft Outlook honor the
VALARMreminders embedded in an imported.ics. Google Calendar ignores them — on import it discards file alarms and applies the account's default notifications instead. If reminders matter for Google users, offer agoogle_template_url(...)link alongside the.icsdownload: the template URL lets Google create the event natively (where the user's defaults apply, and the event is editable before saving). -
Links. Some clients only surface a link if it appears inside the event body.
calkittherefore writes the URL to both theURLproperty and the end ofDESCRIPTION, maximizing the chance the link is clickable. -
Timezones.
calkitattaches the giventz(zoneinfo name) to naive datetimes and embeds the matchingVTIMEZONEcomponent, so the file is self-contained and portable.
pip install -e ".[dev]"
pytestv0.4.0 — Beta. The .ics core (build_event, build_calendar,
build_todo, google_template_url) and the Apple Reminders string plumbing are
implemented and covered by tests (pytest, 72 passing, run on Python
3.10–3.13 in CI). Public API is settled but may still change before 1.0.
Known caveat: the generated unsigned .shortcut uses documented Apple action
identifiers and standard magic-variable serialization but has not been
round-trip verified on a physical Apple device — treat the first on-device import
as a one-time validation step.
Not yet on PyPI (see the naming note above).
MIT — see LICENSE.