OpenTodo 0.23.0 is about trust. It publishes a threat model, a security retrospective and a disclosure policy, with documented limits, and ships the hardening they called for: OAuth refresh tokens rotate, AI agents get per-account limits and can no longer build or run routines that send data out, first-run setup under a domain asks for the setup code, cookie writes need Fetch Metadata, and imports, backups and single sign-on are stricter (see Security). The MCP endpoint now speaks OAuth, so apps such as ChatGPT, claude.ai and Gemini connect by URL. Also new: Simplified Chinese, a new logo, calendar events in Upcoming, a subtask's full path, comment webhooks, keyboard grouping and sorting, every drag with a one-click alternative, and the 1.0 docs (self-hosting handbook, configuration reference, contributor guide, roadmap). Many fixes come from your reports, including imported tasks no longer showing their import id.
Upgrade notes
- Replace the image or the binary and restart; upgrade the server before the devices. Three migrations run at start (
0057–0059: MCP OAuth clients and grants, API token expiry, refresh-token rotation); there are no down-migrations, so back up first. New synced fieldsuser_prefs.today_assigneesanduser_prefs.upcoming_events, additive. No new mandatory variable. - First-run setup under a public domain name now asks for the setup code until
OPENTODO_BASE_URLis set; installs opened by IP address,localhostor a LAN name are unchanged. - Backups and single sign-on no longer use
HTTP_PROXY/HTTPS_PROXYfrom the environment; a server that reached them only through a proxy needs a direct route.
Added
- Simplified Chinese. The app, reminder notifications, the browser extension, the Android and iPhone shells, emails and notification channels can use 简体中文. A browser set to
zh-CN,zh-Hansorzh-SGfollows it;zh-TW,zh-HK,zh-MOand Traditional Chinese stay in English. Dates use the browser's own Chinese locale. Quick add stays on its existing languages. (#883) - The logo files travel in the site bundle. The release's site bundle (
opentodo-site-bundle-<version>.tar.gz) now hasbrand/kit/: the repository's brand kit, with the logo on light and dark backgrounds, in one colour and with the name beside it, the app icon from 16 to 1024 px, an avatar and two social images, as SVG and PNG with a README. The otherbrand/files keep their names. The website's press kit offers these files. - A new logo. The OpenTodo mark is now an open tray with a tick dropping into it, in the app, the browser tab, the installed app's icon, the browser extension and the deploy catalogues. The iOS and Android apps get it too, and Android's launcher icon is now an adaptive icon with a themed version on Android 13 and later.
- The docs website has clear entry points. open-todo.app/docs now opens on the search and six entry points (Get started, Use OpenTodo, Self-host, Integrate, Reference, Under the hood), each with its key pages and a page that lists all of its guides, the deeper ones nested under their topic. The sidebar follows the same six, the breadcrumb names them, and the header and footer are the website's. Pages read more easily (a narrower measure, calmer headings and code), take their colours from the app's own tokens, and no longer load fonts from Google.
- Repeat on the first, second, third or fourth weekday of the month. Next to
every last weekday, a rule can now beevery first weekday,every 2nd weekday,every third weekdayorevery 4th weekday: that Monday-to-Friday day of each month, with weekends not counted. In March 2026, which starts on a Sunday,every first weekdayis Monday 2 March andevery 2nd weekdayis Tuesday 3 March. It is stored asevery 1st weekday, works withuntilandfor N timesbut notafter completion, and CalDAV apps see it asFREQ=MONTHLY;BYDAY=MO,TU,WE,TH,FR;BYSETPOS=1. Quick add understands it in every language (jeden ersten Werktag,chaque premier jour ouvré,ogni primo giorno feriale,cada primer día laborable,todo primeiro dia útil), and a Todoist import keepsevery first workday. Quick add also readsogni ultimo giorno feriale,cada último día laborableandtodo último dia útilas the last weekday; it took them for the last day and left the last word in the title. (#834) - Group and order a list from the keyboard. In a project's list, on a label's page and in a saved view,
r gand a letter sets Group by (ssection,jproject,nnone,ddue date,ppriority,llabel),r oand a letter sets Order by (mmanual,ppriority,ddue date,ccreated,ldeadline,ttitle), andr rreverses the order. Sor g pgroups by priority andr o dorders by due date. The keys change the same synced setting as Display options, only offer what the page's panel offers, and a short notice says what is set. Pause afterrand the key card lists what can follow. They work with Vim keybindings on. See Keyboard shortcuts. (#842) - Today can hide tasks assigned to other people. Today's menu offers Hide tasks assigned to others once a shared project has someone else in it. It is off until you turn it on. While it is on, Today, its count, the icon badge, the briefing and the home-screen widget leave out a shared task assigned to someone else. Tasks with no assignee, tasks assigned to you, Inbox and projects this device does not know as shared stay. Upcoming, including when it starts with Today, is unchanged. Filters can say
assigned,unassigned,assigned: meorassigned: "Name". (#827) - The phone's tab bar follows the sidebar order. Inbox, Today and Upcoming on a phone use the order from Settings → Appearance → Sidebar destinations, the same order as the sidebar. Search stays last, and Calendar and the other destinations stay in the drawer. Someone who never reordered still sees Inbox, Today, Upcoming, Search. The order is the one already saved with the account, so it is right offline and follows the other devices after sync. (#873)
- Calendar events in Upcoming. Upcoming can show the appointments of each day above that day's tasks, the same read-only agenda as Today: all-day events as chips, then timed events with their time, title, calendar colour and location. Show calendar events in Upcoming's menu (next to Show repeating occurrences) is off until you turn it on, saved with the account as
user_prefs.upcoming_events, and separate from Today's choice. Events stay out of the day's count, a filter does not hide them, a day that only has meetings still appears (through the last planned day, up to 92 days ahead), and hiding calendars on this device hides them here too. Offline, the list uses the cached events and says when they were refreshed. The first load grew 0.8 KB (Calendar events in Today, #808). - Apps that require OAuth can now connect to the MCP endpoint. Google Gemini's Connected apps refuses a pasted token ("Gemini requires standard OAuth for server connections"), so the server now answers as an OAuth 2.1 authorization server next to
/mcp: discovery documents, dynamic registration limited to Google's connector addresses, authorization code with PKCE, a consent dialog where you allow the connection, access tokens that stop working after an hour, and a refresh token that keeps Gemini linked. The owner creates a client in Settings → Integrations → AI assistants → Apps that sign in with OAuth and pastes its id and secret into the app; the secret is shown once. Every access token is an ordinary API token — it appears asMCP: <client>in Settings → Integrations → API tokens — and a client can be cut off by deleting it here. See OAuth clients. - A subtask shows where it sits. The task page and the docked panel name the whole path, Work › Draft the budget › Gather numbers › Ask finance. Each part opens that task. When the path is long, the parents in the middle sit in a … menu that stays next to the header on a phone. Press u to open the parent. (#862)
- Webhooks can subscribe to comments. A comment now fires
comment.created,comment.updatedorcomment.deleted, with the comment and the task it is on in the payload, the same signature, project filter, retries and delivery log as other events. A comment an assistant wrote carries the device idagent:<token>, so a receiver can ignore its own writes. See Webhooks (#863). - Every screen is checked for accessibility on every change. Each pull request now opens every screen and overlay of the app at phone and computer widths, light and dark, and fails on any serious WCAG 2.2 AA problem axe-core finds, a focused control without a visible ring or hidden under a bar, a target smaller than 24 px with no space around it, sideways scrolling at 320 px (400% zoom), text cut off with wider spacing, or a password field that refuses a paste. Every night the same runs in English and German with large browser text. The 2,000-task list is checked as a screen reader gets it.
- Single-key shortcuts can be turned off. If you use speech input or press keys by accident, turn off Settings → General → Keyboard → Single-key shortcuts on that device: a letter, number or symbol on its own (q, j, 1, ?, g then a key) then does nothing, while shortcuts with Ctrl, Alt or ⌘ and the arrow, Enter, Escape and Delete keys keep working.
- A threat model, a security retrospective and a disclosure policy with response times. docs/security/ now publishes the threat model: the assets, the trust boundaries and thirteen attacker profiles, from an anonymous visitor to a malicious server for an encrypted account, a prompt-injected AI agent and another tenant in host mode. For each it says what is defended, by which mechanism, which tests would fail if that broke, and what is not defended. Security limits says in one place what is not protected: what the operator sees with and without end-to-end encryption, what is unencrypted at rest, what backups contain, and how far host mode and the demo isolate. How we verify security is the retrospective people asked for: the op-log sync and its convergence tests, the encryption format and the vectors shared by the app, the server and
ot, the route policy test, token scopes, the SSRF policy, importer limits, upgrade tests, static analysis and fuzzing, every past security fix and the gaps still open. SECURITY.md now promises an acknowledgement within 3 business days and an assessment within 10, fix targets per severity (7 days for Critical, 30 for High, 90 for Medium, the next minor for Low), coordinated disclosure within 90 days with an advisory and a CVE, a safe harbour for research on your own instance, and which versions get fixes after 1.0; a repository check keeps those numbers from disappearing. Every server now answers/.well-known/security.txt(RFC 9116) with the same reporting channels and a link to the policy; a comment in it says that problems with how a particular server is run belong to its operator. The scope for an independent review is ready: no external review has taken place yet, and none is claimed. From 1.0,make release-preparerefuses a release whose review findings break the fix rule, once a review exists. (OpenSpecexternal-security-review, #588) - Docs for 1.0: a self-hosting handbook, a configuration reference, a contributor guide and a public roadmap. The self-hosting handbook takes an operator from an empty host to a monitored server in eight steps: install (every platform in one table), first run, reverse proxy and TLS (Caddy, nginx, Traefik), backups with a 10-minute restore drill, upgrading, monitoring, a hardening checklist and sizing from the measured budgets. The configuration reference lists every
OPENTODO_*variable with its default and an example; a test fails when the code reads one that is not listed. Every error code the server sends is in the API table, checked both ways, and its Checked examples run against a test server on every change. The contributor guide has seven how-tos, from a first contribution in 30 minutes to adding an importer, a language or a shared contract, and CONTRIBUTING.md says how pull requests are made and how AI agents are used on the project. The roadmap page, also in Help under About, shows each change as shipped, in progress or planned, worked out from the repository at every build. The docs check now fails for a screen of the app that no guide explains, and an audit fixed settings paths, labels and language counts that had gone stale. (OpenSpecdocs-1-0, #592) - Everything you can drag, you can also do with one click or tap. A task's menu has Reorder (Move up, Move down, Move to top, Move to bottom) in projects, the Inbox, a label's page and saved views; frames, a task's turn order and objectives have a handle that offers Move up and Move down when clicked; a click on the sidebar's or the task panel's edge switches between the widest and the usual width. A test now fails when a new drag has no such alternative (WCAG 2.2 2.5.7).
Changed
- The new logo reaches the last places. The Home Assistant add-on's icon and logo (now 250 × 100, as Home Assistant asks), Runtipi's logo, the Claude plugin's icon, every screenshot in the docs, the README and the website, and the card shown when a docs page is shared now show the tray mark. The brand kit gains Google Play's feature graphic, and a check fails on any icon or logo that is not generated from the mark.
byin quick add sets the deadline.Install winter tires by March 31now gives Install winter tires with a deadline of 31 March and no scheduled date, instead of scheduling it for that day.onandduestill set the date you plan to do it, and the two combine:Pay tax tomorrow by fridayis planned for tomorrow with a deadline on Friday. A time keepsbya due time, since a deadline has no time:Call back by 5pmis today at 17:00 (the word no longer stays in the title), andPay tax by friday 5pmis due Friday at 17:00. Germanbis, Italianentroand Portugueseatéwork the same way; Frenchpourand Spanishparastill set the due date. The app,ot add --naturaland the Obsidian plugin read lines the same way. See Deadlines. (#890)
Fixed
- Imported tasks no longer show an internal id, and list columns line up. After a Todoist import every row ended with a code such as
dfc92cb10aed05a4:1, the key the importer keeps to spot duplicates, and rows with something at their end had their date and labels further left than the others. The id is now shown nowhere: the task's details, panel and history and the command palette read "Imported from Todoist" (or the app it came from),ot listshows "Todoist", and a Trello card's link is named "Imported from Trello". On a list row wide enough for the property column, the row's end is one width whether the task has an origin or not, so dates and labels sit in one column down the list; a long origin there ends with an ellipsis (OpenSpecimport-origin-reads-as-app). (#945) - A layout chosen while a project is still opening is kept. Until the project's own layout (Board, Timeline, Calendar) had loaded, the switch showed List as chosen and a click on List was ignored, so the project then opened in its previous layout anyway.
- The app's first load is about 8 KB smaller. The QR code of the two-factor enrolment wizard and its encoder now load when the wizard opens instead of with every first visit; a square of the same size holds its place until it is drawn.
- Ticking a task in a long list is quicker. A click on a task's circle drew the whole list again in that frame; in a 2,000-task project it now draws only that row, and the list catches up when the task leaves it. The completion stays the same to look at and to a screen reader. (#854)
- A saved view in display mode lists the same tasks as the view. Display mode ran a saved view without your workspaces, the people you share with, custom fields, project flows and tracked time, so a view whose query uses
workspace:,assigned:, a custom field,availableortrackedshowed no tasks there. It now uses the same information as the view's own screen. The sidebar's count of a view on a custom field counted nothing for the same reason and now matches the view. (#901) - Settings fixes from the settings audit. The audit log and Recent activity name ten events they showed as raw codes (calendar connections, task pages, the project AI-assistant switch, and OAuth clients and connections for AI apps), and they can be filtered; a test now keeps the web list in step with the server's catalogue. On Security, Sign out everywhere is the button styled as dangerous, not the sign-out of this device. Turning on Require single sign-on asks for confirmation first. The OAuth client name field clears after a client is created, and a long backup folder path no longer runs off a phone screen.
- The title can be corrected while processing the Inbox. Clicking it showed a cursor, but the decision keys took every letter, so a voice-to-text line could not be fixed without leaving the mode. Click the title or press Tab, type, and Tab, Enter or a click away saves it; Esc puts the old title back. The decision keys work again once the field is left. Enter still opens the task beside the card for comments, subtasks and the rest. (#858)
- Voice input in the Android app can ask for the microphone. The app never declared the microphone, so Android showed no prompt and no switch in the app's settings, and the page told you to allow it from an address bar the app does not have. The first time you dictate, Android asks; allow it and the page can record. A refusal says to allow the microphone for OpenTodo in the phone's settings. (#866)
- A board from a template stays in the workspace you are in. Choosing New board → Eisenhower matrix in a workspace other than the default saved the board and its four filters in the default workspace, so they were missing from the sidebar you were looking at. They are now saved in the current workspace, the same as an empty board. (#870)
- A long title in the task panel shows in full. The title stopped at two lines, so a longer one had to be edited inside a box that hid most of it. The title on the task page and in the docked panel now grows with the text, up to eight lines, and scrolls only past that. (#859)
- A location can be a name that is not on the map. Typing "Garage" or "Mum's" could not be saved unless the map search found it, so a place with no address never stuck (#817). The location field now offers Use “Garage” first; Enter keeps that name, with no address and no pin. An address the search finds stays in the list under it. Saved locations are unchanged. See Locations.
- Keyboard and screen reader fixes across the app. While a dialog or a phone sheet is open, the page behind it can no longer be reached with Tab or a screen reader. A focused menu item shows the focus ring. A reminder, a break reminder or the daily check no longer covers the row you tabbed to (on a phone it moves to the top), the sidebar no longer hides a toast on the left, and a header or the filter's Save bar no longer covers the control you tabbed to. Inside dialogs and sheets too (the help panel, the task page), the focused control is scrolled out from under their sticky headers. A row's actions no longer cover the cut project name you tabbed to, and with large text the sidebar's account button and sync status fit, the Help page's tasks and a date picker's month no longer push the page sideways. Each section of a project list is announced with its name. The weekly review's steps fit a 320 px screen (they wrap at 400% zoom with large text), and the task page's breadcrumbs, the feed's project links, the board's column dots and the time zone's Change are easier to hit.
- Password managers fill the account form of a workspace invitation. Its email, name and password fields now say what they hold, like every other sign-in form.
- Readable text on the iPhone app's first screen. The "Server address" heading, the line under it and the error messages were light grey, a little under the contrast small text needs; they now use the full text colour.
- A task's right-click menu opens complete and stays where it appears. The menu was shown before it had read the task it was opened for, so its first frame lacked the task's own items (Add to focus, Start tracking, Start focus), which then appeared and pushed the menu to another place. It now opens once those local reads have landed, a few milliseconds later, with every item and in its final place; the same holds for the row's More button, the keyboard's menu and the Selection bar's Actions (OpenSpec
context-menu-first-paint). (#840) - A bulk action that empties the list closes its menu and opens nothing. On a label page with every task ticked, removing the label through Actions → Labels… turned the still-open menu into one task's context menu at the far left of the window, and the last ticked task opened in the side panel. The Actions menu now closes once the ticked tasks have left the list, and the side panel follows the cursor only while its task is still listed (OpenSpec
bulk-menu-after-empty-list). (#841) - Task titles are readable on a phone after an import. Tasks imported from Todoist showed the importer's internal id (such as
dfc92cb10aed05a4:1) at the end of every row, and in the Inbox the line "Imported from todoist" stood beside the title instead of under it, so on a phone the title was cut to a character or two. A row no longer shows an imported task's id: an import without a link shows nothing there, a Trello card shows its icon as the link. On a row narrower than 30 rem any origin is its icon, on a phone it moves to the row's details and the task page, and the Inbox's source line is the line under the title (OpenSpecrow-title-first-on-phones). (#844)
Security
run_routinenow stays within an AI assistant's daily write limit. A routine run started over MCP counted its writes toward the token's 1,000 items a day only after it ran, and it ran even when nothing was left. It now runs only within what is left: with none left it answersrate_limited, and a run that would write more than is left writes nothing and is logged as failed with the reason. Tests are not limited. See Limits.- An AI assistant can no longer create, change, turn on or run a routine that sends a webhook or an email. Routines with Send a webhook, Email me or Email project members steps are now the person's alone. Over MCP and in the in-app assistant,
create_routine,update_routine,set_routine_enabled(turning on) andrun_routineanswerforbidden_for_agentsfor them. An assistant can still read, test, turn off and trash them, and nothing changes in the app. This makes the MCP guide's promise that no tool sends anything off the server hold for routines too. - AI agent write limits also apply per account. All of one person's agent tokens and connected OAuth apps together may now make at most 60 write calls a minute and change at most 2,000 items a UTC day, next to the existing 30 calls and 1,000 items for each token, so creating more tokens no longer raises the budget that contains a runaway or misled agent. Over a limit the call is refused with
rate_limitedand nothing changes. See MCP (OpenSpecagent-limits-per-account). - Backups and single sign-on no longer use a proxy from the environment or follow a redirect to another host. The backup client took
HTTP_PROXY/HTTPS_PROXYfrom the server's environment and followed redirects to any host; the single sign-on client also took the proxy. Both now connect directly to the destination or issuer the owner configured, as webhooks and every other outbound request already did. A backup destination may still redirect up to three times within its own scheme and host (a WebDAV folder without its trailing slash, for example); a redirect elsewhere, or from https to http, fails the run, and the sign-on client still follows none. Destinations and issuers on your own network keep working. If your server reached a backup destination or your identity provider only through a proxy, it now needs a direct route. See Backups (OpenSpecoperator-clients-no-env-proxy). - Imports refuse oversized CSV fields and deeply nested JSON. A file within the upload size and the 50,000-row limit could still hold one enormous field, a record with a very large number of fields, or deeply nested arrays, costing far more memory than any real export. Every CSV import (CSV, Todoist, TickTick, Linear, Asana, OmniFocus, Notion) now refuses a field over 1 MiB, a record over 4 MiB or with more than 1,000 fields, and a JSON file, also inside a ZIP, nested more than 256 levels deep. The import answers too large with the limit and, for CSV, the line, and nothing is imported. Exports from supported apps stay far below these limits. See Import (OpenSpec
importer-field-limits). - First-run setup under a domain name asks for the setup code until a base URL is set. While a server has no owner and no
OPENTODO_BASE_URL, it cannot tell its own name from another site's name pointed at it, so a first registration addressed to a domain name now needs the one-time setup code from the server log, even withOPENTODO_SETUP_CODEoff. Opening the server by its IP address,localhost, a name without a dot or a local name (.local,.lan,.home.arpa,.internaland a few others) needs no code, so a fresh Docker install works as before; withOPENTODO_BASE_URLset nothing changes. The setup screen says why it asks and offers the IP address or the base URL instead. See The setup code (OpenSpechost-check-without-base-url). - Cookie writes without an Origin need Fetch Metadata or a JSON body. A change sent with the session cookie but no
Originheader used to be accepted, relying on the cookie'sSameSite=Laxalone. It is now refused with 403bad_originwhen the browser'sSec-Fetch-Sitesays it came from anywhere but the app's own pages, and, when it names neither header, unless it sends a JSON body: a form, multipart or bodiless request, which is all another site can make an old browser send, is refused. Every supported browser, the Android and iOS apps, the service worker andot loginkeep working; API tokens are unaffected (OpenSpeccookie-writes-need-origin). - OAuth apps on the MCP endpoint get a new refresh token on every refresh. The refresh token an app sent stops working once it has been exchanged, and if an old one is sent again, the server ends that connection: its refresh and access tokens stop working, the app has to be connected again, and the audit log records
mcp_oauth.refresh_reused. Clients registered only for Google's connector addresses, which Gemini uses, keep one refresh token, because Google's guide requires it. See OAuth clients (OpenSpecmcp-oauth-refresh-rotation). - Encrypted accounts refuse a key record weaker than the one the app writes. The app and
otaccepted a key record with 100,000 PBKDF2 iterations or more, while the app writes 600,000 and the server stores only 600,000 to 10,000,000. They now accept only the server's range, so a server cannot get a device to unlock with a cheaper derivation than the app uses. Every key record a server holds already uses at least 600,000, so no device is locked out and nothing has to be re-entered (OpenSpece2ee-iteration-floor). - The share target keeps a share only when the browser says where it came from. The installed app stored a share that carried no
Sec-Fetch-Siteheader, which older browsers leave out, so it could not tell the system share sheet from a form on another site. Such a request is no longer stored; the capture screen opens empty and says to update the browser. Every browser that can share to an installed web app sends the header, and the Android and iOS apps hand shares over another way, so shares keep working (OpenSpecshare-target-fetch-metadata).
Install
- Docker:
docker run -d -p 8080:8080 -v opentodo:/data ghcr.io/opentodoapp/opentodo:0.23.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.