OpenTodo 0.20.0 is about routines and themes. Automation rules become routines: a routine has a trigger and steps that find tasks, change them, create tasks or projects and comment, built on a page with a plain sentence for each step, started from a starter or described in one sentence, tried against your data before it runs, with a run history and "Routines since your last visit". A routine can ask AI to judge or summarise, and shows you the proposal before anything changes; a shared project can have its own routines, and encrypted accounts run theirs on one of their devices. The look of the app becomes themes: OpenTodo Light and Dark plus fourteen editor palettes, your own made in an editor, from a file or from a VS Code theme, and a server default for everyone. Also new: Outlook and Microsoft 365 in calendar sync, an Obsidian plugin that sends a line as a task and syncs a folder of task files on a phone too, Upcoming that starts with Today, Today's count on the app icon, and tasks with a time that stay in sight while they wait for a Today slot. The server turns Multipath TCP off (CVE-2026-98432); update the host's kernel too (see Security).
Security
- Multipath TCP is off. Go turns on Multipath TCP for Linux listeners by default, and CVE-2026-98432, a Linux kernel use-after-free in TCP connection handling, can be reached through MPTCP listeners. OpenTodo gains nothing from MPTCP, so
go.modnow setsgodebug multipathtcp=0for the server andot, pinned by a test. The real fix is a patched kernel: the server, and its container, run on the host's kernel, so update it (advisory, Host kernel). The Go toolchain stays at 1.27.3, which already carries the crypto/x509 fix for CVE-2026-111943.
Upgrade notes
- Replace the image or the binary and restart; upgrade the server before the devices. Four migrations run at start (
0051–0054: calendar sync cursors, routines, routine AI steps, assistant usage drafts); there are no down-migrations, so back up first. New synced entitycustom_themeand new synced fieldsrule.steps,rule.ai_apply,rule.ai_budget,rule.project_id,time_entry.focus_mode,time_entry.focus_session,time_entry.planned_end_at,user_prefs.theme_light,user_prefs.theme_dark,user_prefs.routines_seen_at,user_prefs.routines_hide_successesanduser_prefs.upcoming_today, all additive: older devices keep working and ignore them, but a routine with steps runs only on an upgraded server. - No new mandatory variable. Outlook calendar sync is optional and needs a Microsoft client, set in Settings or with
OPENTODO_MICROSOFT_CLIENT_ID,OPENTODO_MICROSOFT_CLIENT_SECRET(or_FILE) andOPENTODO_MICROSOFT_TENANT(Calendar sync). - Update the host's kernel. Multipath TCP is now off in OpenTodo, but the kernel fix for CVE-2026-98432 is the real one (Host kernel).
Added
- Today limit: tasks with a time stay in sight. With Today full, a task captured as "send the form today by 4" waited in the folded Waiting for a slot group, shown only as a count, so its time could pass unseen (#766). The folded heading now names the next waiting task due today with a time or a deadline, Waiting for a slot · 4 — next: Send the form, 4:00 PM, and lists up to three of them under it, soonest first, each with its time and Take a slot. The slots stay first come, first served: a time never moves anything.
ot todayprints the same under its Waiting heading and markstime_sensitivein--json; the agent tools'today_summarymarkstime_sensitiveon waiting tasks and lists them inwaiting_time_sensitive. - Today's count on the app icon. The installed app's icon now shows how many of today's tasks are still open, the same number as Today in the sidebar (all of them, not only the Today limit's places), and goes away when nothing is left (#725). It follows every change and every sync while the app runs; while it is closed, a reminder push sets it to the current day's count from the counts the device stored for the next seven days, and the push itself carries nothing new. It works in the home-screen app on iPhone and iPad (iOS 16.4 or later, once notifications are allowed) and in apps installed from Chrome or Edge; Settings → Notifications → App icon badge turns it off per device and only appears where badges are supported. See App icon badge.
- Describe a routine in one sentence. Some routines are easier to say than to build: "every Friday at five, make tasks in Errands from my Groceries note" still meant finding four steps and wiring them. Routines → Describe it (and Describe a routine in the command palette) takes one sentence of up to 500 characters and lets the assistant draft the routine: it gets the steps available to you and the names of your projects, sections, labels, saved views and webhooks (as material whose instructions are ignored), answers through one tool,
draft_routine, and its draft is type-checked, with one more try carrying the errors. The editor opens the draft unsaved, with Drafted by AI: check each step, then Save and any error on its card; nothing is saved, switched on, tested or run, a draft never turns on Apply without asking, and it never picks a task by hand. It is offered only when you can use the assistant, counts as one turn toward your allowance (listed as routine drafts in your usage; at most 10 an hour), and on an end-to-end encrypted account runs on your device with the on-device assistant, so nothing about it reaches the server. New routePOST /api/v1/assistant/routine-draft(sessions only); not in the CLI or MCP, whose assistants already write routines withcreate_routine(OpenSpecroutines-describe-it, #735). - Routines that read text, on end-to-end encrypted accounts. With encryption on, the server cannot read a note or a title, so a routine that split a note into tasks, wrote a comment or asked AI was skipped with encrypted. Such a routine now runs on one of your own devices: turn on Run my routines on this device in Settings → Encryption (or from the editor's warning). When the routine is due, the server sends that device the run's ids over the device relay; the device plans the routine on its decrypted copy exactly as the server would (the same planner, pinned to the server's by the shared use cases), and sends back only encrypted changes, which the server applies like any device's edits. Ask AI and Summarise call the provider of the on-device assistant, and their results wait as a proposal encrypted on the device that any of your devices opens and applies. With no device online the run waits (waiting for one of your devices), only the latest occurrence of each routine, and is recorded as missed after 24 hours; Run now runs on the device in hand. The opt-in stays on while the device is locked and ends at sign-out. Email me and webhooks with text stay unavailable. See Routines on encrypted accounts.
- Routines that belong to a shared project. A household or a small team can now give a shared project its own routines: every Monday, give this week's chores a date, when anyone adds a task to Releases, set it due in three days. New routine asks Where (Just me, or a shared project you edit), and the project's menu has Routines, the Routines page filtered to it. Everyone in the project sees the routine and its runs; editors, admins and the owner change it, viewers read it. It runs as whoever last turned it on (Runs as Alice), with that person's current role and never more than an editor, and it sees only the project: Find tasks, conditions and triggers never reach anyone's personal tasks, Create tasks creates in the project, and a step that would write outside it fails before anything is written. Labels, today's focus, timers, other projects, Email me and webhooks belong to one person, so those steps are marked Not available in a shared routine. Its triggers fire on any member's changes. When someone else edits its steps or AI settings it pauses until someone turns it on again (the editor warns before saving), so nobody's role or AI tokens are used for steps they did not approve; when the person it runs as leaves or becomes a viewer, it pauses and the project's editors are notified. AI steps use that person's assistant and allowance, and Apply without asking counts only when they turned it on. Shared projects are plaintext, so these routines run on the server even for an encrypted account, reading only the project. The CLI creates one with
ot routine create --projectand lists PROJECT and RUNS AS; AI assistants passprojecttocreate_routine. Email project members follows in #741. (#662) - Start a routine from a starter. An empty routine editor is a hard start, and the step catalogue is large. Routines → Start from a starter (and the Routines page while it is empty) now lists eleven ready-made routines, drawn from the routines people build most: Groceries from a note, Sunday overdue sweep, Evening roll-over, Morning focus, Done journal, Note to subtasks, Waiting follow-up, Friday digest by email, Meeting notes to tasks, Inbox over 20 and Monthly review. They ship with the app, work offline and read in your language; search finds them by name, summary or step, in your language or English. A starter's page shows what it does as sentences, what it needs (a step not set up on the server says why) and your choices, the project or label it works with, picked from a list next to the word, with Create Groceries first when you have no such project. Use this starter opens the editor on a new, unsaved routine. Starters follow the rule that routines are dynamic: they read the task that triggered, the task you run them on or tasks an earlier step found, never a task chosen by hand. Each one lives in
templates/routines/beside the project templates, translated into the six languages, checked in CI and run by a Go test against seeded data that must produce exactly the expected writes; anyone can propose one by pull request (templates/README.md). The site bundle publishes them intemplates/routines.jsonwith aroutineTemplatesfact. "Describe it", drafting a routine with AI, moved to its own change (routines-describe-it). (#660) - Themes: the server's default and server themes. A family, club or company server already carried its own name and logo; now its look can match. In Settings → Server → Branding the owner chooses the default light and dark theme, from OpenTodo's, the built-in and the server themes, and adds up to 20 server themes, from a
.opentodo-theme.jsonfile (Add from file) or as an independent copy of one of their own (Offer one of my themes), each checked on the device and the server (at most 16 KB). Everyone who never chose a theme for a side sees the server's default, and a person's own choice always wins: nothing is written to anyone's data, so changing the default later reaches everyone who still follows it. Each list then starts with Default (Server default: name), the server's default is marked Server default, and server themes appear as From this server, to choose, customise as a copy or export, never to change; choosing OpenTodo's theme while a server default is set now stores it by name, so it keeps winning. A server theme with contrast warnings shows Low contrast · n, and making it a default asks first, listing the pairs. Devices remember the default and the server themes for offline use and the first frame, and read them again whenauth/statusreports a newerthemes.version(at start, and when the app comes back online or to the front). Removing a server theme that is a default clears that default; people who chose it fall back to their side's default and keep their choice. New routes:GET /api/v1/themes/instancefor every signed-in person, the owner'sPUT /api/v1/settings/themes/defaultandPUT/DELETE /api/v1/settings/themes/instance/{id}, refused on the demo instance and recorded assettings.changedwith ids only. No environment variable; the sign-in screens, emails, splash and icons stay neutral. Deriving a theme from base colours in the middle of the lightness range can take two seconds, so the app now derives a theme at start in a worker, behind the colours the boot script already applied. The first load grew 0.8 KB (Branding, Themes, API,theme-instance-default, #711, closes #638). - Routines that ask AI, with your review first. A routine could split, count and move, but not judge: "turn these meeting notes into tasks" or "summarise what I finished this week" needed someone at the keyboard. Routines now have Ask AI (your instruction, up to three values from earlier steps, an answer as text or lines) and Summarise, which call your assistant's provider once each, without tools, with what they read marked as material whose instructions are ignored. A run of a routine with an AI step writes nothing by itself: its changes wait as a proposal in the Inbox card (Sort inbox · proposal waiting · Review) and the bell, reviewed item by item with Written by AI on text the AI wrote, applied in one tap with one Undo, and expire after 7 days. Apply without asking, off by default and only switched on by you in the app (an AI assistant, MCP tool or agent token never can), lets a routine create tasks, comments and labels and set task fields at once, at most 20 items a run, always listed in the Inbox with Undo; it turns itself off after a run that would do more, or after two failed runs in a row. Each routine has a daily AI budget (20,000 tokens by default), the card previews never call the model, and Test says which provider gets the inputs and about how many tokens it uses before sending anything. AI steps need the assistant's updated terms (Accept for routines in Settings → Integrations → Assistant). See Routines.
- Themes: your own, made in an editor, from a file or from a VS Code theme. Settings → Appearance → Theme and density gains Your themes: up to 20 themes of your own, synced to your devices (never to anyone else's) through the new personal
themeentity, offline included, and part of the export. Every theme card has a menu: Customise opens an exact copy credited to the original ("Based on Nord by Sven Greb (MIT)"), and your own themes add Use for light or Use for dark, Edit, Duplicate, Export and Delete (a theme in use goes back to OpenTodo's in the same change, with Undo). The editor (/settings/appearance/themes/<id>) has the four base colours and the optional status colours, each with swatches, a hex field and keyboard-operable lightness, chroma and hue sliders; Advanced, with every colour of the theme by group, Derived or Set, its worst contrast pair, Undo and Reset, and a filter in your language or English; corners, the font (an installed font is never downloaded) and the list text size; and a live preview of a list, a board, the week and a task panel built from sample data. Contrast lists every pair below AA with Fix and Fix all, which keep each colour's hue and move only its lightness, and a screen reader hears the results. A theme with warnings can still be saved; its card reads Low contrast · n. Changes stay a draft on the device until Save, with undo and redo for the session (Ctrl+Z,Ctrl+Shift+Z,Ctrl+Sin the editor). Themes travel as.opentodo-theme.jsonfiles, read and checked on the device with line-precise errors, and Import a VS Code theme maps a VS Code colour theme's workbench colours onto the theme (the full table is in the docs), derives the rest and shows a report first, with Fix contrast now ticked. Themes are not encrypted, and the editor says so on encrypted accounts. The editor, its primitives, the importer and the derivation worker load only when used; the first load grew 1.8 KB, mostly chunk overhead (Themes,theme-editor, #710, part of #638). - The routine editor, run history and "Routines since your last visit". Routines could only be built through the API, the CLI or MCP, and a run that failed at night went unnoticed. More → Routines (the old Rules page;
/rulesopens/routines) lists each routine with its trigger and steps as one line, its last run and runs this week, how many steps are not available, Run now and a menu, and a Runs tab with every run. The editor shows a routine as cards stacked one under the other, phone first: a When card and one card per step, each a sentence whose bold words are buttons (Create tasks from the lines in Errands) that open the right picker: the Combobox for projects, labels, tasks, views and webhooks, fields for text (with an Insert menu of earlier outputs), dates and numbers, the rule condition and action controls. Each card ends with its output, offered as a chip to later steps (fitting ones first, others under Needs conversion with the step that converts them); If and Repeat for each show their steps indented. Every card previews what it would do while you edit, worked out on the device for text, lists, logic, Find tasks by a condition and the writing steps' effects (offline and encrypted accounts included) and by the server for the rest (Would write 620 of 500 warns of a limit); a preview never writes or sends anything. A wrong kind of value shows on its card with a one-tap fix (Notify me expects Text, got Tasks · Insert Format text), and Save waits until it is fixed;Ctrl+Ztakes an edit back. + Add step searches every step by name, description and synonyms in your language and English, suggests the steps that fit the step above, and shows a step that is not set up dimmed with what to set up and a link; a saved step that stops being available reads Not available with the reason. Test runs the draft as a test, Save works offline, and each routine has a History tab; a run opens step by step with the tasks it touched (titles from your device), why it stopped and Undo run. A one-card rule keeps the rule form, so older versions still edit it. The keyboard covers it all:Alt+↑/Alt+↓move a step (focus stays and the move is announced),aadds one below,←/→move between words,Ctrl+Entersaves,Ctrl+Shift+Entertests; every card and word has a screen-reader name. The Inbox shows a card Routines since your last visit above the first task when something happened: one line per routine (at most five, then See all runs), runs you started yourself left out, failures until handled, successes until seen (after two seconds on screen or Mark as seen, on every device through the synceduser_prefs.routines_seen_at) or dismissed, and Hide successful runs in the card's menu and Settings → Notifications (user_prefs.routines_hide_successes). A failing routine also rings the bell once a day per routine with a count (routine_failed) and reaches delivery channels with Routine notices on; a failed run is now kept until seen and 30 days more, at most 90. New routesGET /api/v1/me/rules/runs?for=inboxandPOST /api/v1/rules/{id}/runs/{run}/dismiss; links to a run read/routines/<id>?run=<run>. The page and editor are one lazy chunk (28.5 KB gzip) and the Inbox card loads only for users with routines; the first load grew about 1 KB. (Routines, API,routines-editor, closes #659) - Themes: fourteen built-in editor palettes. Next to OpenTodo Light and Dark, Settings → Appearance → Theme and density → Theme now offers Solarized Light and Dark, Gruvbox Light and Dark, Catppuccin Latte and Mocha, Rosé Pine Dawn and Rosé Pine, Tokyo Night Light and Tokyo Night, One Light and One Dark, and, dark only as their projects publish them, Nord and Dracula. Each palette is mapped onto the app's colours by one written mapping and passes the same WCAG AA contrast check as every theme; where a published colour was too faint for its place (most often a comment colour used for secondary text), the theme keeps its hue at the nearest lightness that reads, records the published value, and says so in its details. Under System, choosing one side of a pair offers the other (Also use Solarized Dark at night). Every card credits the palette's author, About this theme shows its licence, copyright line and project, and the full MIT notices ship with the app and in the docs; OpenTodo is not affiliated with these projects. The palettes load with Settings, never with the first screen, and set colours only, so switching never moves the layout. GitHub's Primer, Monokai and Material Theme were left out over their names and licences (Themes, #709, part of #638).
- Themes: the engine, with OpenTodo Light and OpenTodo Dark as its first two themes. The look of the app was fixed apart from light or dark, density, high contrast and the accent, and #638 asked for more, the way code editors do it. A theme is now a small, documented JSON file (format 1, a JSON Schema in
docs/themes/) of a few seeds, canvas, surface, text and accent, and optional values for any of the app's semantic colours, its corner radii and its typeface; every colour a theme leaves out is derived on the device, keeping each seed's hue and moving only lightness until every contrast pair passes, so a theme of four colours is complete and stays readable. Settings → Appearance → Theme and density gains a Theme row: one theme for light and one for dark (both lists under System, which switches between them with the device), synced like the other appearance choices and part of the export. The theme applies before the first frame with no flash, high contrast always wins over it, and the browser bar and the Android app's status and navigation bars follow it. This version ships only the OpenTodo themes, which look exactly as before (the accent row is now called Accent); the built-in palettes, your own themes and the owner's default theme follow. Every theme in the repository and 200 seed-only themes must pass the contrast check inmake check. There is deliberately no custom CSS; Themes explains why (#708, part of #638). - Routines: rules with steps. An automation rule could change the task that triggered it and nothing more, so "every Friday, turn the lines of my Groceries note into tasks in Errands" or "count what waits, and tell me when it is more than ten" were out of reach. A rule can now hold steps, each using what the steps before it produced: find tasks (by a condition, a filter query or a saved view, evaluated on the server by a port of the app's filter evaluator), create tasks (one per line, read like quick add), change tasks, comment, read and append to or clear a task's or project's note, split into lines, join, format, find and replace, filter, sort, take, count, If/Otherwise, Repeat for each, Stop, start and stop the timer, tracked time, set today's focus, create projects, sections and labels or a project from the template library, and Notify me (in the app and, with the new Routine notices event, through your delivery channels), Send a webhook (one of yours, with a
routineobject inrule.fired) and Email me (your own verified address only, at most 10 a routine and 20 an account a day, never for an encrypted account). Every existing rule keeps working as a one-step routine; nothing is converted. A step is offered only when what it needs is set up, and a step that stops being available fails the run at that step with the reason. A run plans every step first and writes only when all of them planned, one batch per item as the routine; limits are 20 steps, 60 seconds, 500 items written, lists of 500 and texts of 64 KB. Routines can run on a new manual trigger and be run now or tested from the command palette (Run routine…, with the open task),POST /api/v1/rules/{id}/run,ot routine runor the MCP toolrun_routine; every run is logged per step with ids and counts only, and Undo takes a whole run back. New routes:/api/v1/rules/test,…/{id}/run,…/{id}/runs/{run},…/runs/{run}/undo,/api/v1/me/rules/runs,/api/v1/me/rules/catalogue;ot routinelists, shows, runs, creates and edits routines from JSON or YAML with names resolved, and exits 6 on a failed run; MCP gainslist_routines,get_routine,list_routine_runs,get_routine_run, the resourcesopentodo://routines(/{id}), and behind the write gatecreate_routine,update_routine,set_routine_enabled,run_routineandtrash_routine. A routine with a text step is skipped for an encrypted account until device runs arrive. The history now labels a rule's changes Routine (name). Synced as the additiverule.stepsand trigger kindmanual; one migration adds run records; the routine editor comes next (routines-editor). (Routines, API, CLI, MCP,routines-engine, closes #658) - Speak a brain dump or photograph your notes, and review the tasks. Dictation filled quick add with one task, so a list spoken on the way back from a meeting or written on a whiteboard still had to be typed task by task. The assistant's message box now has a microphone and Add photo, and a new skill, Turn this into tasks, proposes the whole list as one change set: one task per action in your words, a project, label, date, priority or estimate only where the material says so, and a change to an existing task ("already in your tasks") instead of a duplicate; it never trashes anything, and a capture is always reviewed, never applied automatically. The microphone uses the recogniser voice capture already uses on the device (the browser, or the server's speech service in pieces of at most 55 seconds) in a continuous mode that stops on the button,
Esc, 8 seconds of silence or 5 minutes ("Listening 1:24 / 5:00"); the text lands in the box, editable and unsent, without quick-add tokens. Up to 4 photos (also pasted or dropped) are turned upright, scaled to 1568 px and re-encoded as JPEG without location on the device; the first one on a device asks once, naming the provider; the conversation keeps "2 photos (not kept)" and never resends them. The command palette's Ramble into tasks opens the assistant with the skill and the microphone listening; there is no new key or sidebar entry, and none of it appears without an assistant.POST /api/v1/assistant/turnacceptsimageson the last message (at most 4 of 3.75 MB, 16 MB per request, the text still 256 KB), held in memory for the turn only and never logged or stored; Anthropic gets image blocks, OpenAI and Gemini image parts, a compatible endpoint only when the owner ticks This model reads images (providers.compatible.images; Test connection then sends a small image), and a model that refuses photos answersimages_unsupportedwith the message and photos kept in the box.GET /api/v1/me/assistantreportsimages; the disclosure (version 2) adds "photos you add" while earlier consents stay valid. Encrypted accounts get the same on the device, which sends photos straight to its own provider.captureis also an MCP prompt (text only). A new provider test sends a Gemini request with tools, streaming and a photo to Google's OpenAI-compatible path, and a redirect in front of Test connection is shown to be refused before Gemini is asked (refs #501). (Speak or photograph a list, API,voice-ramble, closes #608) - The running Focus session, readable outside the app. A "now" panel beside the screen (a small web page, a Home Assistant card, an e-ink display) could see at most that a time entry ran, so it kept its own timer and drifted from the app. The time entry a Focus work session writes now carries the timer (
pomodoro,flowtimeorcountdown), the session number in the Pomodoro cycle and when the stretch is planned to end (none for Flowtime); a resume after a pause writes the new end, and "+5 min" starts an entry with its own. The newGET /api/v1/time-entries/runninganswers your own running entry with those details asfocus: {mode, session, plannedEndAt}, ornull, withserverTime, for every token scope includingread; the list routes and the JSON export carry the same, the CSV export is unchanged.ot time statusprints it ("Pomodoro, session 3, ends 10:25"), MCP'slist_time_entriesgainsfocusper entry, and the resourceopentodo://time-entries/runningholds the running entry (null for a task in a project kept away from assistants). Breaks and pauses write no entry, so they read as nothing running; the phase, the pause bookkeeping and the breaks stay on the device. Synced as the additivetime_entry.focus_mode,focus_session(0 to 99) andplanned_end_at(never beforestarted_at, refused with reasoninvalid_planned_end), written only by the entry's author; older devices ignore them. (Show Focus elsewhere, API, Home Assistant,focus-session-readable, closes #604) - AI assistants finish the job. An assistant planning the day through an
agenttoken could create and schedule tasks but had to hand back everything around them: a reminder for the 10:00 block, the client's project note, a section or a project move over REST, custom field values over REST, archiving a finished project, renaming a label, logging the Pomodoro it worked beside you. New MCP write tools do all of it under the same switches, limits (50 items a call, 30 calls a minute, 1,000 items a day),agent.writeaudit, history label and "kept away" projects as every assistant write:add_reminders,update_remindersandremove_reminders(relative to the due time, before the deadline or at a fixed moment,fire_atcomputed in your time zone; your own reminders only, at most 10 on a task;get_tasklists them),update_projectwithdescriptionorappend_descriptionfor the note (editors may, viewers not),archive_projects(with the sub-projects, owner or admin, one project a call),update_labels, andstart_timer,stop_timer,log_timeandupdate_time_entries. Starting never silently stops a timer you run: it answerstimer_runningunless the call saysstop_running: true, and an assistant can change only the time entries it created and never delete one. The agent REST routes gain the same:fieldson tasks (the spec promised it; the server refused it),PATCH /api/v1/projects/{id}withicon,parent_id,descriptionorarchivedon its own,POST/PATCH /api/v1/sections,PATCH /api/v1/labels/{id},POST/PATCH/DELETE /api/v1/reminders, andPOST /api/v1/time-entries,…/stopandPATCH …/{id}(409timer_runningunlessstopRunning: true);ot project archiveandot timenow work with an agent token. Deleting sections, labels, projects or time entries stays refused. On an encrypted account the note, label names and entry notes take ciphertext only, andappend_descriptionworks through an opted-in device orot mcp --decrypt. No new synced field, no migration. (MCP write tools, Agent tokens,agent-tools-complete, closes #565) - Outlook and Microsoft 365 in calendar sync. Outlook.com, Hotmail and Live accounts and Microsoft 365 work or school accounts offer no CalDAV, so calendar sync could not reach the calendar most office workers live in. With a Microsoft client set up by the server owner, Connect Microsoft sits next to Connect CalDAV and Connect Google in Settings → Integrations → Calendar sync (stacked full-width on a phone): Microsoft asks for the account and the permission, and the connection works like any other, with the same card, states, limits (3 accounts, 200 writes a check, 10 shown calendars), newest-change-wins rule, encrypted-account rule and "calendar sync" in a task's history. Underneath, the cycle now runs over one transport interface, WebDAV for CalDAV and Google and Microsoft Graph for Outlook:
calendarView/deltaper calendar with its delta link kept in a new side table and rebuilt from a full listing when the window moves or Graph answers 410; events created with an OpenTodo extended property holding the task id and atransactionId(a retried creation makes no second event), immutable ids, andPATCH/DELETEwithIf-Match(a 412 is never forced); only events carrying the property change tasks. A zoned task is written in its zone's Windows name, a floating or all-day task in the mailbox's time zone (UTC, with a note on the card, when Microsoft does not say), so wall-clock times round-trip. A 429 or 503 waits at least Graph'sRetry-After; a revoked grant asks to reconnect; a refused or expired client secret stops every Microsoft connection withmicrosoft_client_refused, shows the owner when it happened, and saving a new secret resumes them at once; a target that is gone or can no longer be written stops withcalendar_missingorcalendar_read_only. Refresh tokens, which Microsoft rotates, are stored before the access token is used. The owner configures the app registration's client id, tenant (commonby default,organizations,consumers, a tenant id or a domain) and secret in the panel, or with the optionalOPENTODO_MICROSOFT_CLIENT_ID,OPENTODO_MICROSOFT_CLIENT_SECRET(or_FILE) andOPENTODO_MICROSOFT_TENANT; the guide walks through the Entra ID app registration.login.microsoftonline.comandgraph.microsoft.comare contacted only once a client is set up and someone connects, through the outbound policy and without redirects. New routesGET/PUT /api/v1/settings/calendar-sync/microsoft,POST /api/v1/calendar-sync/microsoft/startandGET /api/v1/calendar-sync/microsoft/callback, all refused on a demo instance. One additive migration (calsync_cursors), no op schema change, no new dependency. (Calendar sync, API,outlook-calendar-sync, closes #609) - A reproducible Android app with one signature, ready for Google Play and F-Droid. The Android app was only a signed APK on the GitHub release, and nobody could check that it was built from this source. Building a release tag now gives the same APK apart from the signature, whatever the machine, path, time or Gradle cache: the toolchain is pinned (Temurin JDK 17.0.21+9, the Gradle wrapper with its checksum, Android Gradle Plugin 9.4.1, build tools 36.0.0) and the APK no longer carries the encrypted dependency block or the commit file. Every release tag builds the app a second time in a fresh checkout and fails before anything is published when the two differ;
scripts/android-repro.sh v<version> opentodo-android-<version>.apklets anyone compare a downloaded APK with a build of its tag (withapksigcopier, pinned by hash), and Actions → Android reproducible build runs the check on any commit. The release certificate's SHA-256 is published in the guide and checked by CI on every signed build, so the APK from GitHub, F-Droid and Google Play can carry one certificate and you can switch between them without uninstalling. The F-Droid metadata (mobile/android/store/fdroid/app.opentodo.yml) now points F-Droid at the release APK and that certificate, so F-Droid publishes the project-signed APK only when its own build matches. A final tag also uploads the app bundle to Google Play's internal testing track with the release notes (scripts/release/play-upload.mjs, the Play Developer API with no dependency; release candidates upload nothing and nothing is ever promoted to production by CI); without the publishing secrets the step warns and the release goes on. The store listings open once the owner's Play Console and F-Droid steps are done; until then, install from GitHub. No change to the app itself; the APK stays about 1 MB. (The Android app, mobile/README.md,play-production, refs #603) - Tasks that link back to an Obsidian note. A task written from a line in an Obsidian daily or meeting note had no way to point back at that note: origins were web links only. A task's origin can now be an Obsidian note (
{kind: "obsidian", id: "Daily/2026-10-29.md", url: "obsidian://open?vault=Notes&file=Daily%2F2026-10-29"}), set through the API, an AI agent or the coming Obsidian plugin. Its chip shows a note icon and the note's name, its tooltip "Open in Obsidian: Notes › Daily/2026-10-29.md", and activating it opens the note in Obsidian (the browser asks first);origin: obsidianlists these tasks in a filter. Only that one link shape is accepted, on the server and again on each device after decryption: another Obsidian action (new,search, a plugin's own command), apath=link or extra members are refused, and the link must open the note the origin names. The Android and iPhone apps hand the link to Obsidian through their external-link method behind a new bridge capability,obsidian-links; an older app shows the chip with Copy link instead. No new field, migration or variable: an older server refuses the new kind, so writers check the version first (#605). - Sync a folder of task files from Obsidian, on a phone too.
ot vaultkept a folder of Obsidian Tasks files in two-way sync, but only where a Go binary runs, so people with Obsidian only on an iPhone, an iPad or an Android phone had no vault sync. The OpenTodo plugin for Obsidian now does whatot vault syncdoes, inside Obsidian: Settings → OpenTodo → Sync a folder with OpenTodo picks a folder (defaultOpenTodo/), and the plugin writes one file per project and one line per task, merges edits from both sides field by field and keeps lines that changed on both sides in.conflict.mdfiles, with the same files, limits and.opentodo/state.jsonasot vault, so a folder moves between the two. It syncs when the plugin starts, a second after a file in the folder changes, every 30 seconds while Obsidian is in front and with Sync vault with OpenTodo now; never in the background. Its engine is a TypeScript port ofinternal/vault, kept in step by step-by-step sync vectors that the Go engine writes and both engines run (internal/vault/testdata/sync/). A folder now has one syncing device: the state file names it, with a five-minute lease every sync renews, so a folder that Obsidian Sync, iCloud or git copies to several devices is no longer synced by two of them pushing each other's edits back. The others show which device syncs it and offer Take over syncing here, a fresh first sync that keeps the server's values and lists differing lines in conflict files; on the command lineot vault syncandwatchrefuse such a folder before any request withvault_owned(exit 2) and gain--take-over. A file iCloud has not downloaded yet (a.<name>.icloudplaceholder) is skipped instead of being read as deleted. Encrypted accounts work with the plugin's unlock, after a notice that the folder holds plain text. The plugin'smain.jsbudget is 400 KB (258 KB now); guide: docs/obsidian.md#sync-a-folder (#607). - Send a line from Obsidian to OpenTodo. Tasks jotted in daily and meeting notes stayed there, and Obsidian on a phone could not run
ot vaultat all. The new OpenTodo plugin for Obsidian (desktop and mobile) sends the line at the cursor, or each line of a selection, as a task: a Tasks checkbox line (- [ ] Renew passport 📅 2026-11-05 ⏫ #errand) is read with the vault's grammar, any other line with quick add (Call the plumber tomorrow 9am p2). A sheet shows what was recognised and the project before sending; afterwards the line gains[opentodo:: <link to the task>](or is ticked or removed), Undo stays ten seconds, and the task's origin opens the note again, following renames. Sends wait on the device while offline, and encrypted accounts are encrypted on the device with the web app's own code. It connects with afulltoken kept on the device, never in the vault, talks only to your server and collects nothing. On the server, the sync exchange gains an optionalpush_onlyfeature that applies a device's ops without pulling the log. The plugin lives inobsidian/, is built and tested bymake obsidianandmake obsidian-e2e, and each release publishes it to OpenTodoApp/obsidian-opentodo; guide: docs/obsidian.md (#606). - Start Upcoming with Today. Planning now and next meant switching between Today and Upcoming, or a saved view that only came close (#670, thanks @calli33). Start with Today in Upcoming's
⋯menu, off by default, makes Upcoming open with Today's own groups, picked and grouped exactly as Today does it: Earlier, Today (with Morning and Afternoon when day parts are on), This Evening, Blocked and, with a Today limit, the folded Waiting for a slot group; then Tomorrow and the following days as before. Each task is listed once and the Today part holds the same tasks as Today, so the numbers agree; Done today and Close the day stay on Today, and Today itself doesn't change. The choice is one synced preference of the account (user_prefs.upcoming_today).ot upcomingand the agent tools keep listing the days after today. See Start Upcoming with Today.
Changed
- The Obsidian plugin, ready for Obsidian's community directory. Obsidian now reviews plugins automatically at community.obsidian.md (pull requests to
obsidianmd/obsidian-releasesended in May 2026), and its review rebuilds the plugin from the release repository. Each release therefore also publishes the plugin's source toOpenTodoApp/obsidian-opentodo, wherenpm ci && npm run buildgives the releasedmain.jsbyte for byte, and CI runs the directory's checks first (make obsidian-review: build verification, dependency audit, type-check and Obsidian's own ESLint rules). Following Obsidian's guidelines, the commands no longer repeat the plugin's name, which Obsidian already shows (OpenTodo: Send line as a task, Send pending tasks, Fix task links in this note, Unlock encrypted account, Sync folder now; the editor menu keeps Send line to OpenTodo), the settings tab starts without a heading and calls its last section Folder sync, and files the folder sync removes go where Obsidian's "Deleted files" setting says. The submission is prepared inobsidian/store/submission.md. See Obsidian plugin. - Theme settings that use the space, and a theme template. The theme editor was a narrow column of full-width rows beside a preview that left most of a wide screen empty, and at 1024 px the preview squeezed task titles to one letter. It now lays out by the room it has: from about 830 px the controls sit beside a preview that stays in view, with the base and status colours as compact rows two to a line (swatch, name, Derived or Set, a hex field you can type in, Reset), a contrast warning on the row of each colour that fails (2 pairs below AA) as well as in the Contrast panel, labels above Corners, Font, List text size and Kind, and Advanced folded with its counts; narrower, the preview is a strip above the controls. In Settings → Appearance, the buttons under Your themes touched the next setting; the row now has the spacing of the others, a short explanation with Learn more, an empty state, and one row of actions, New theme, Import (theme file or VS Code theme) and the new Download a template: a starter file made on your device from the theme you use, with every colour of the format and a note on each in your language,
$schemafor editors, and the colours you do not set as lines to uncomment. Theme files may now hold such notes and trailing commas; the editor's menu offers Download as a template, and Server → Branding offers it beside Add from file, whose list no longer touches its divider either. Your own themes' cards show their label colours. The layout check now fails when controls overlap or touch a row divider on these pages. - No dialogs in the routine editor. + Add step covered the page with a dialog, and the trigger word (Only when I run it) opened a form hanging off its left edge over the sidebar. Now the list of steps expands right where you pressed + Add step (under the last card, inside a Then, Otherwise or Repeat block, or between two cards for Add step below), with its search focused, groups, arrow keys and Enter, and Escape closes it; the new step appears in that place. The trigger word opens the list of triggers right under it, at its start edge, like every other word, and a schedule's time and days (or a trigger's label or project) are set inside the When card, as are the When card's conditions. Delete step removes the step at once with Undo in a toast instead of asking. Test with AI steps asks in the Test panel, and Describe it opens its field on the Routines page under the header. See Routines.
- Told once that the assistant's list is complete. Since the consent list became complete (below), people who had accepted the earlier, shorter one kept using the assistant without seeing it. They are still not asked again, and nothing pauses, but the assistant panel, and the Routines page when their consent covers AI steps, now show a notice once: Review the list opens the complete list in place, Turn off the assistant withdraws the consent, and Got it closes the notice on every device. The same goes for the on-device consent of an encrypted account.
GET /api/v1/me/assistantreportsdisclosure_noticeandconsent.seen_version;POST /api/v1/me/assistant/disclosure-seenrecords Got it. (Assistant settings, API,assistant-disclosure-notice, follows #762) - No gap before a colon in a routine's sentence. Comment on [each task] : text had a space before the colon, as did Change [the tasks] :, Favourite [project] :, Email me [subject] : and In [text] , replace: a word followed by punctuation no longer keeps its trailing margin.
- The routine editor's pickers, polished. Pick's mode menu hung off its word over the sidebar with a solid green pill for the chosen mode, a short word such as all sat in a wide box, a comment was cut to one line with its values read as «each task», the
/list covered the step, a step you had just added already said needs a value here, and a second Escape threw away a typed comment. Now every word's list opens at the word's start edge (the/list at the cursor, + value at its button), below it when there is room, with the chosen entry a row with a check; words are as wide as their word; a text shows whole, wrapped, with its values as chips; needs a value here waits until you move on or press Save; and Escape keeps what you typed. The condition rows line up in one grid, the phone sheet keeps Done in reach, and the condition reads due earlier instead of due: due earlier (Routines,routines-editor-polish). - Routines work on what they find, not on tasks you pick by hand. Building "find my overdue tasks, then comment on the first" made the editor insert Get the task [choose a task], and inside Repeat for each a comment still asked for a task. Now every step that acts on tasks takes the tasks that flow to it and acts on each: a step you add is already wired to the step above, the Repeat's item (each task) or the trigger (the task that triggered), and fixes use what flows there or insert Find tasks…, never a task picker. Take first became Pick: Pick the first of the tasks, and its first word switches to the last, the first N, the last N, all, or the ones where a condition holds, with Sort first when the order matters. A task chosen by hand is still possible for the rare fixed target, folded under A specific task… and Get a specific task, and routines saved with one keep working. The pickers no longer open a large box: a choice opens a short searchable list next to the word (arrows, Enter, Escape), a text is edited right in the card with
/or + value to insert a value, and condition rows open inside the card on a wide screen and from the bottom of the screen below 1024 px. New, so more routines can be built: a project's note chosen directly, Get the date, Get the project of a task, subtasks of a task, Split into lines with only the open checkboxes, Find tasks with a query that namescompleted(completed = this week), the condition Not changed for N days (unchanged) and schedules on days of the month (month_days, with Last day). A suite of 31 realistic routines runs against seeded data on every build and the most common ones are built through the editor in tests.ot routine showprints Pick by its mode and the MCP routine tools describe the contract. No new synced field (Routines, API,routines-dynamic-data, closes #722). - The routine editor is a page. In a dialog, a routine of several steps, its test result and its History were squeezed into one scrolling box, a run's detail opened as a second dialog on top, and the editor could not be linked or reloaded. A routine now opens at
/routines/<id>, and a new one at/routines/new. Both work from a link and on reload, and/rules/...addresses redirect there. Opening a routine from a view or a board column opens the same page with the draft filled in. The sticky header shows the breadcrumb Routines › name, Unsaved changes, Test, Run now and Save; on a phone these actions sit in a bar at the bottom. On a computer the steps sit in a centred reading column, with the test result, the routine's History and a run's detail (/routines/<id>?run=<run>) beside them, inline. On narrower screens, tabs switch between Steps, Test and History. Save keeps you on the page, and saving a new routine moves the address to its id. Leaving with unsaved changes asks first, whether you use Back, the breadcrumb,Esc, another page or the browser's Back, and closing the tab brings up the browser's prompt.Ctrl+Snow saves too. Focus moves to the page heading when the page opens and returns to the routine's row on Back. A save message such as A routine needs at least one step now goes away as soon as you edit, instead of staying after the steps were added. The layout check now covers the Routines page and the editor at every width. Web only: no op, route or server change (Routines,routines-editor-page).
Fixed
- The assistant's consent lists everything it may send, in your language. The list you accept before your first message had fallen behind the assistant: it left out notes, tracked time, routines and calendar frames, workspaces, custom fields, locations, the names of attachments, and the saved view names and webhook hosts a Describe it draft uses. The routines paragraph now also says that an Ask AI step sends its instruction and can read a whole project. On an encrypted account the on-device assistant shows its own, shorter list, and every entry is translated instead of shown in English. Nothing that is sent changed, so consents you already gave still count (What the assistant sends and keeps).
- A restored attachment and an upload can no longer share the last room in the quota. Restoring a removed attachment checked the quota against stored files only, so a restore that arrived while an upload was still in progress could take an account over its quota by one file. A restore now counts the uploads in progress, and an upload counts a restore that is still being saved; whichever comes second is refused with quota_exceeded (#676).
ot vault watchno longer reads a file while an editor is saving it. Many editors save in two steps, an empty file and then its text. When a change from the app arrived between them, the watch synced at once, read the empty file as every line removed, moved its tasks to the trash and wrote the file over the save. A change from the app now waits, like a saved file already did, until the files have held still for a second.- A vault line with both
[evening:: true]and[daypart:: …]reads the same after a sync. The vault and the Obsidian plugin kept the part when reading the line, but printed only the evening flag, so the task's line changed on the next write. Evening wins when reading too, as the docs say (found by the nightly fuzzer, #679). - Long task titles use the room beside them. In a window that is not full width, list rows kept about 9 rem free at their right end, on every row and at all times, for the actions that appear on hover, and cut the title to one line before it, so titles were truncated next to an empty area. Titles now use the row's whole width, wrap onto a second line and end with an ellipsis after two; resting the pointer on a cut title shows it in full, and a screen reader always hears all of it. The hover actions float over the row's end, so hovering a row moves nothing and never changes its height. On a very wide list a title wraps at about 100 characters, and the date and the other columns stay on its first line. Phone rows keep one line. The Inbox also no longer keeps an empty No task selected pane beside the list below 1440 px (zooming out to see more brought it in at 1024 px): there the list takes the whole width and the pane appears only while a task is selected (
task-list-width, #650). - Typing on an iPhone no longer zooms the app in. Safari on iOS zooms the page when a text field with text smaller than 16 px gets focus, and the installed app stayed zoomed after the keyboard closed, so parts of the screen hung off the edge. On touch screens every text field, select and editable area (quick add, the task editor, comments, search, the command palette, the routine editor, pickers and the notes editor) now uses at least 16 px text; the desktop design is unchanged and pinch zoom stays on. When the keyboard closes, a document Safari left scrolled goes back into place. The layout check now fails on any field under 16 px in its touch emulation (#724).
- Events from your other calendars in Week, Month and Year. Events of subscribed and connected calendars showed in Day and the Plan, but not on a phone's Week, nor in Month or Year (#733). The phone's Week now lists them in each day: all-day events after the multi-day tasks, timed ones among the timed tasks at their start. Month lists a day's events after its tasks (all-day first, then by time), inside +N more so tasks keep the first rows; on a phone a small ring marks a day with events and the day's list under the grid shows them. Year marks a day with events with a small hollow ring, apart from the density bar, which still counts tasks only; the day's name and tip say how many. Month and Year get the Calendars button and the "Calendars as of" line, and all views follow it and show only enabled calendars; events stay read-only. A device now fetches a year or several months of events in requests of at most 92 days (OpenSpec change
calendar-events-all-views). - The start guide's "How do you want to work?" step fits its card. The guide is a narrow card, but its three ways of working went side by side by the window's width, so in a wide window they were squeezed and long words (in German, "Methodenhinweisen") ran past the edge. The two offers that follow choosing the full Open Loops method kept their buttons beside the text, so the question shrank to a word a line and a button ended up outside the card, at every width. The cards now go side by side only when the chooser itself has room, which they still do in Settings, and an offer's buttons, like those of every notice with actions, move under the text when there is no room beside it. The layout check now opens each step of the guide with each way of working chosen, in every language it runs, and fails when content sticks out of the card or a sentence is squeezed beside its buttons (#731).
- Routines no longer find tasks of archived projects or of projects in the Trash. A routine's Find tasks (by a condition, a filter query or a saved view) read every task not itself in the Trash, so it also found the tasks of archived projects and of projects moved to the Trash, which no list in the app shows: a saved view listed more in a routine than on its own page, and a Sunday sweep commented on tasks archived away. Find tasks, the matching tasks of a routine or rule on a schedule, the card previews and the When card's count now follow the app's own rule: never a task in the Trash or of a project in the Trash, and tasks of archived projects only when the Find tasks card has Archived projects on (added from its menu; the additive
archivedinput in the API,ot routineand MCP). Only the task's own project counts; completed tasks keep their rule. (Routines,routines-find-scope, closes #734) - A busy day in the Month no longer pushes the grid sideways. A day cell showed its planned load ("3h 45m / 8h") on one line beside the date, wider than a cell on a tablet or a narrow window, so it ran over the next day and, in the last column, made the whole Month scroll sideways. The load now moves under the date when the two do not fit and is cut short inside its cell, with the full figure on hover.
Install
- Docker:
docker run -d -p 8080:8080 -v opentodo:/data ghcr.io/opentodoapp/opentodo:0.20.0 - Binary: download
opentodo-<os>-<arch>below, then run it (data goes to./data). - Command-line client:
ot-<os>-<arch>. Browser extension:opentodo-browser-extension-chrome.zipandopentodo-browser-extension-firefox.zip.
Verify downloads with sha256sum -c SHA256SUMS. Self-hosting guides: docs/self-hosting.