docs(phase6): documentation site, landing page and open-source scaffolding

Material for MkDocs under docs/, covering self-hosting, the Rune, the
quick-add and filter languages, importing from Todoist, CalDAV,
mail-to-task, MCP, the AI layer and the API. The version is pinned: the
maintainers have announced that MkDocs 2.0 removes the plugin system
with no migration path, and an unpinned install would break this build
on the day it lands.

The landing page shows the quick-add parser as actual text rather than a
screenshot of it — that is the thing worth showing, and it reads better
as the real thing. It shares the app's design tokens, so the site and
what it describes look like one project.

MIT licence, contributing guide, security policy and issue templates.
Pages deployment is behind workflow_dispatch, so the docs build on every
push but nothing is published until the owner chooses to.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
Kristian Wind
2026-08-17 08:08:36 +02:00
co-authored by Claude Opus 5
parent 6b19ed062d
commit 7c9bde2cfd
25 changed files with 1716 additions and 14 deletions
+43
View File
@@ -0,0 +1,43 @@
name: Bug
description: Something does not work
labels: [bug]
body:
- type: textarea
id: what
attributes:
label: What happened
description: And what you expected instead.
validations:
required: true
- type: textarea
id: steps
attributes:
label: How to reproduce it
placeholder: |
1. …
2. …
validations:
required: true
- type: input
id: version
attributes:
label: Version
description: The image tag, or the commit you built from.
validations:
required: true
- type: dropdown
id: how
attributes:
label: How are you running it
options:
- As a Rune in Yggdrasil Panel
- Docker directly
- From source
validations:
required: true
- type: textarea
id: logs
attributes:
label: Anything in the logs
description: Please remove tokens and email addresses first.
render: text
+5
View File
@@ -0,0 +1,5 @@
blank_issues_enabled: false
contact_links:
- name: Security issue
url: https://github.com/kristianwind/verdande/blob/main/SECURITY.md
about: Please report vulnerabilities by email, not as a public issue.
+22
View File
@@ -0,0 +1,22 @@
name: Feature
description: Suggest something
labels: [enhancement]
body:
- type: markdown
attributes:
value: |
Please check the [non-goals](../../blob/main/README.md#non-goals) first —
some things are deliberately not being built.
- type: textarea
id: problem
attributes:
label: What are you trying to do
description: The problem, not the solution. What does the current version make hard?
validations:
required: true
- type: textarea
id: idea
attributes:
label: What you had in mind
validations:
required: false
+58
View File
@@ -0,0 +1,58 @@
name: Documentation
# The docs are built on every push so a broken build is caught immediately, but
# only published once GitHub Pages has been enabled for the repository. Until the
# owner decides to publish, this job builds and stops.
on:
push:
branches: [main]
paths:
- 'docs/**'
- 'mkdocs.yml'
- '.github/workflows/docs.yml'
workflow_dispatch:
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: pages
cancel-in-progress: false
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.12'
cache: pip
# Pinned deliberately. The Material maintainers have announced that MkDocs
# 2.0 removes the plugin system and breaks every theme override with no
# migration path — an unpinned install would break this build on the day it
# lands, for a docs site nobody was editing.
- run: pip install 'mkdocs-material==9.7.7'
- run: mkdocs build --strict
- uses: actions/upload-pages-artifact@v3
with:
path: site_build
deploy:
needs: build
runs-on: ubuntu-latest
# Only runs once Pages is enabled. Until then the build above still guards
# against a broken docs tree.
if: github.event_name == 'workflow_dispatch'
environment:
name: github-pages
url: ${{ steps.deploy.outputs.page_url }}
steps:
- id: deploy
uses: actions/deploy-pages@v4
+3
View File
@@ -30,3 +30,6 @@ node_modules/
# Frontend build output and dependencies
/web/build/
/web/.svelte-kit/
# Built documentation
/site_build/
+61
View File
@@ -0,0 +1,61 @@
# Contributing
Thank you for looking. verdande is one person's self-hosted to-do app, so the bar
for a change is "does this make it better for people running it on their own
hardware" rather than "is it a feature some product has".
## Before you write code
Open an issue first for anything beyond a bug fix. The
[non-goals](README.md#non-goals) are deliberate, and it is kinder to say no to a
paragraph than to a weekend.
## Running it
```bash
VERDANDE_DATA_DIR=./data VERDANDE_DEV=true go run ./cmd/verdande
```
```bash
cd web && npm install && npm run dev
```
The API is on 8080, the interface on 5173 with `/api` proxied to it — so the
session cookie stays first-party exactly as it does in production.
## What CI checks
```bash
gofmt -l . # must print nothing
go vet ./...
go test -race ./...
```
The Docker image is built on every pull request too, so a Dockerfile that no
longer works fails there rather than at release.
## What a change needs
**Tests for anything that could be silently wrong.** Not coverage for its own
sake — the parser, the permission checks and the import round trip are tested
because a quiet failure in any of them loses somebody's data or shows them
somebody else's.
**Comments that say why, not what.** The code says what it does. A comment earns
its place by explaining a decision that is not obvious from reading it: why the
connection pool is not 1, why recurrence is parsed before dates, why an uploaded
file is never served inline.
**Danish and English both.** Anything that reads or writes text a person typed
has to work in both. `ø` is not an accented `o`, and a feature that only works
for one of the two languages is a broken feature.
## Commits
[Conventional Commits](https://www.conventionalcommits.org/): `feat(store):`,
`fix(web):`, `docs:`. Releases are semver tags, which build and publish the image.
## Security
Please do not open a public issue for a vulnerability. Email the address in
[SECURITY.md](SECURITY.md) instead.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Kristian Wind
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+27 -14
View File
@@ -13,8 +13,10 @@ Which is what a to-do list is.
## Status
Early. The foundation is built and tested; the API and web interface are being
written. See [What works today](#what-works-today).
All six phases are built. See [What works today](#what-works-today) for what is
tested and what is not.
Documentation: [`docs/`](docs/), built with Material for MkDocs.
## Why this exists
@@ -40,10 +42,10 @@ mail-to-task address, and an MCP endpoint so Claude can work your task list dire
## What works today
**Phases 1 and 3 are complete; phase 2 is most of the way there.** You can install
it, create an account, add tasks by typing a sentence, share a project, watch
somebody else's changes appear live, set up repeating chores and reminders, save
filters, and subscribe to it from Apple Calendar.
**Every phase in the plan is built.** You can install it, add tasks by typing a
sentence, share projects, watch somebody else's changes appear live, set repeating
chores and reminders, save filters, import your Todoist account, sync two-way with
Apple Reminders, forward mail into it, and add it to Claude as a connector.
| Area | State |
|---|---|
@@ -64,10 +66,20 @@ filters, and subscribe to it from Apple Calendar.
| ICS calendar feed, task durations | Done, tested |
| Project templates | Done, tested |
| Nightly backups with rotation | Done, tested |
| Import/export, CalDAV, mail-to-task, Gmail, MCP, AI | Planned — phases 4–5 |
| Comments, attachments, notifications, Web Push | Built |
| Todoist CSV import and export, account export | Done, tested |
| MCP server for Claude | Done, tested |
| CalDAV server (two-way VTODO) | Done, tested |
| Mail-to-task | Done, tested |
| AI layer (Anthropic/OpenAI/Google/local) | Built, degrades when unconfigured |
| Gmail settings | Partial — see below |
| Documentation, landing page, licence | Done |
Not yet built: comments and attachments, Web Push, and everything from phase 4
onwards.
**Not finished:** the Gmail integration stores its settings but has no OAuth2 flow
yet, so it cannot actually connect. Web Push and the AI providers are implemented
but have only been exercised against their specs, not against live services. And
the interface does not yet expose comments, attachments, reminders, templates,
import/export or the settings pages — all of it works through the API.
Search finds `grøn` when you type `gron`, because Unicode does not consider `ø` an
accented `o` and a Danish-first app that cannot do this is broken for its own users.
@@ -154,13 +166,14 @@ is part of it.
1. **MVP** — auth, projects, sections, tasks, sub-tasks, priorities, due dates,
quick add, Today and Upcoming, search, keyboard shortcuts. *(done)*
2. **Sharing** — invites, roles, assignees, comments, attachments, activity log,
realtime sync, notifications.
realtime sync, notifications. *(done)*
3. **Pro parity** — recurring tasks, reminders, filter queries, board and calendar
views, durations, ICS feeds, templates, automatic backups. *(done)*
4. **Import/export** — Todoist CSV and official export in, CSV/JSON/ICS out.
4. **Import/export** — Todoist CSV and official export in, CSV/JSON/ICS out. *(done)*
5. **Integrations** — CalDAV server, mail-to-task, Gmail, MCP server for Claude,
and a provider-agnostic AI layer that also talks to a local model.
6. **Publication** — documentation, landing page, open-source scaffolding.
and a provider-agnostic AI layer that also talks to a local model. *(done,
except Gmail's OAuth flow)*
6. **Publication** — documentation, landing page, open-source scaffolding. *(done)*
## Development
@@ -199,4 +212,4 @@ protocol of its own — CalDAV and the REST API *are* the sync.
## License
Not yet chosen. MIT is the intention when this is published.
[MIT](LICENSE). See [CONTRIBUTING.md](CONTRIBUTING.md) and [SECURITY.md](SECURITY.md).
+31
View File
@@ -0,0 +1,31 @@
# Security
## Reporting
Email **[email protected]** rather than opening an issue. Include what you
found, how to reproduce it, and what an attacker could do with it. You will get a
reply within a week.
## What verdande does about the obvious things
- **Passwords** are Argon2id with the parameters recorded in the hash, so they can
be raised later without locking anyone out.
- **Every token** — session, invite, reset, API, calendar feed, mail address — is
stored only as a SHA-256 hash. A database dump contains nothing that can be
presented.
- **Sessions** are `httpOnly`, `SameSite=Lax`, and `__Host-` prefixed over HTTPS.
- **Uploads** are stored content-addressed by hash and always served as
`application/octet-stream` with `nosniff`, so an uploaded SVG cannot run script
on this origin.
- **The filter language** compiles to parameterised SQL. No typed text is ever
concatenated into a statement.
- **Permission checks** are route middleware, and anything you may not see answers
404 rather than 403 — a 403 confirms the thing exists.
- **The CSP** is computed at startup by hashing the inline scripts in the built
page, rather than allowing `unsafe-inline`.
## What it does not do
verdande has no rate limiting beyond the login and password-reset endpoints, and
no audit log of reads. It is built to be run behind something — a Cloudflare
Tunnel, a reverse proxy, a VPN — by somebody who knows what they exposed.
+67
View File
@@ -0,0 +1,67 @@
# AI features
Optional, off by default, and bring your own key. verdande works completely without
them — this is a convenience on top of a to-do app, not something the app depends
on.
## Setting up a provider
**Settings → AI**. Four kinds:
=== "Anthropic"
- **Provider**: Anthropic
- **Model**: `claude-sonnet-5`
- **API key**: from [console.anthropic.com](https://console.anthropic.com)
=== "OpenAI"
- **Provider**: OpenAI
- **Model**: `gpt-4o`
- **API key**: from platform.openai.com
=== "Google"
- **Provider**: Google
- **Model**: `gemini-2.0-flash`
- **API key**: from Google AI Studio
=== "Your own model"
Anything speaking the OpenAI-compatible shape — Ollama, vLLM, LM Studio, a
company gateway.
- **Provider**: Compatible
- **Base URL**: `http://localhost:11434/v1`
- **Model**: `llama3.1` — whatever you have pulled
- **API key**: usually blank
This is why the abstraction exists at all: an integration that only spoke to a
hosted API would be no use to somebody running their own models.
The key is stored on your account and is **never sent back** to the settings page —
a page that repopulates a password field is one that will eventually leak it into a
screenshot.
## What it can do
**Split a task into sub-tasks.** On any task: *Split with AI*. It suggests two to
seven concrete actions and creates them as sub-tasks. Written in your language.
**A weekly summary.** A short note over what is outstanding: what looks urgent and
what appears to be slipping. At most six lines — it is a prompt to think, not a
report.
## What is sent
Only what the feature needs: for a split, the task's title and notes; for a
summary, the titles, dates and priorities of your open tasks. Never comments,
attachments, or anything from a project the feature was not invoked on.
Nothing is sent to anybody until you configure a provider. With a local model,
nothing leaves your machine at all.
## Turning it off
Clear the model field. Every AI feature reports itself as unavailable rather than
failing, and the rest of the app is unchanged.
+150
View File
@@ -0,0 +1,150 @@
# API
REST under `/api/v1`, JSON in and out. Everything the web interface does goes
through it, so anything you can do there you can script.
## Authenticating
Two ways, accepted everywhere.
**A session cookie**, which the web interface uses. `httpOnly`, so nothing but the
browser handles it.
**A personal API token**, which is what scripts use:
```bash
curl -H "Authorization: Bearer vrd_..." https://todo.example.dk/api/v1/tasks
```
Make one under **Settings → API tokens**. It carries your permissions exactly —
there is no separate service identity.
## Errors
Every failure has the same shape:
```json
{
"code": "validation_failed",
"error": "one or more fields are not valid",
"fields": { "due_date": "must be a date like 2026-03-15" }
}
```
`code` is stable and meant to be matched on. `error` is English prose for a log —
what a person reads is decided by the client, which knows what language they are in.
| Code | Status | Means |
|---|---|---|
| `bad_request` | 400 | The body could not be read. |
| `unauthorized` | 401 | Not signed in, or the token is wrong. |
| `totp_required` | 401 | The login still needs its verification code. |
| `forbidden` | 403 | Cross-site request, or admin needed. |
| `not_found` | 404 | Does not exist, or you cannot see it. |
| `conflict` | 409 | Already exists, or the state does not allow it. |
| `validation_failed` | 422 | Check `fields`. |
| `rate_limited` | 429 | Too many attempts. |
| `internal_error` | 500 | Our fault. |
!!! note "404 rather than 403"
Anything you may not see answers 404. A 403 would confirm the thing exists,
which is what somebody trying ids is hoping to learn.
## The endpoints
### Tasks
| Method | Path | |
|---|---|---|
| `GET` | `/tasks` | List. Filter with `project_id`, `label_id`, `priority`, `due_before`, `due_from`, `no_date`, `assignee_id=me`, `q`, `completed=include\|only`. |
| `POST` | `/tasks` | Create. |
| `POST` | `/tasks/quick-add` | Create from one line of natural language. |
| `GET` | `/tasks/quick-add/preview?text=` | Parse without saving. |
| `GET` | `/tasks/{id}` | One task. |
| `PATCH` | `/tasks/{id}` | Change it. Only the fields you send. |
| `DELETE` | `/tasks/{id}` | To the trash, recoverable for 30 days. |
| `POST` | `/tasks/{id}/complete` | Tick off. A repeating task advances instead. |
| `POST` | `/tasks/{id}/reopen` | Un-tick. |
| `POST` | `/tasks/{id}/move` | Reorder: `{"after_id": …, "before_id": …}`. |
```bash
curl -X POST https://todo.example.dk/api/v1/tasks/quick-add \
-H "Authorization: Bearer vrd_..." \
-H "Content-Type: application/json" \
-d '{"text": "betal moms i morgen kl 10 p1 #Firma @regnskab"}'
```
### Projects and sections
| Method | Path | |
|---|---|---|
| `GET` `POST` | `/projects` | List, create. |
| `GET` `PATCH` `DELETE` | `/projects/{id}` | Read, change, trash. Owner only for the last two. |
| `GET` `POST` | `/projects/{id}/sections` | |
| `PATCH` `DELETE` | `/sections/{id}` | |
| `GET` | `/projects/{id}/members` | |
| `POST` | `/projects/{id}/invites` | Share it. `{"email": …, "role": "editor"\|"viewer"}` |
| `DELETE` | `/projects/{id}/members/{userID}` | |
| `GET` | `/projects/{id}/activity` | |
### Views, filters and labels
| Method | Path | |
|---|---|---|
| `GET` | `/today` | Overdue and due today, in your timezone. |
| `GET` | `/upcoming?days=7` | One entry per day, empty days included. |
| `GET` | `/search?q=` | Across everything you can see. |
| `GET` `POST` | `/filters` | |
| `GET` | `/filters/{id}/tasks` | Run one. |
| `GET` | `/filters/preview?query=` | Run an expression without saving it. |
| `GET` `POST` | `/labels` | |
### Comments and attachments
| Method | Path | |
|---|---|---|
| `GET` `POST` | `/tasks/{id}/comments` | |
| `PATCH` `DELETE` | `/comments/{id}` | Author only for editing. |
| `POST` | `/tasks/{id}/attachments` | `multipart/form-data`, field `file`, 25 MB. |
| `GET` `DELETE` | `/attachments/{id}` | |
### Import and export
| Method | Path | |
|---|---|---|
| `POST` | `/import/todoist` | A Todoist CSV, `multipart/form-data`. |
| `POST` | `/import/csv` | Mapped rows as JSON. |
| `GET` | `/export/account` | Everything, as JSON. |
| `GET` | `/export/projects/{id}.csv` | Todoist-compatible. |
| `GET` | `/export/projects/{id}.ics` | A calendar file. |
### Everything else
| Method | Path | |
|---|---|---|
| `GET` | `/ws` | WebSocket. Live changes to projects you can see. |
| `GET` | `/notifications` | |
| `GET` `POST` | `/feed`, `/feed/rotate` | The [calendar feed](caldav.md) URL. |
| `GET` `POST` | `/mail-address`, `/mail-address/rotate` | The [mail-to-task](mail.md) address. |
| `POST` | `/mcp` | The [MCP](mcp.md) endpoint. |
| `GET` | `/healthz` | Unauthenticated. Reads the database rather than merely answering. |
## Live updates
```javascript
const socket = new WebSocket('wss://todo.example.dk/api/v1/ws');
socket.onmessage = (event) => {
const { type, project_id, payload } = JSON.parse(event.data);
// task.created, task.updated, task.completed, task.deleted,
// task.moved, comment.created, notification, reminder
};
```
One-way by design: nothing a client sends over the socket changes anything. Writes
go through the REST API, where the permission checks are.
## Rate limits
Only where guessing matters: ten login attempts per address per fifteen minutes,
five password-reset requests per hour. The rest of the API is not limited — it is
your server.
+88
View File
@@ -0,0 +1,88 @@
# Calendar and CalDAV
Two ways to get your tasks into a calendar, and they are not the same thing.
| | [Feed](#subscribing-to-a-feed) | [CalDAV](#connecting-a-caldav-client) |
|---|---|---|
| Direction | Read only | Two-way |
| Works with | Anything that subscribes to a URL | Apple Reminders, Thunderbird, DAVx⁵ |
| You can tick things off | No | Yes |
| Setup | Paste a URL | Username and token |
## Subscribing to a feed
**Settings → Calendar feed** gives you a URL. Paste it into whatever subscribes to
calendars.
=== "Apple Calendar"
**File → New Calendar Subscription**, paste the URL, and set auto-refresh to
every hour.
=== "Google Calendar"
**Other calendars → + → From URL**. Google refetches on its own schedule,
which can be many hours — this is Google's behaviour and nothing here can
change it.
=== "Thunderbird"
**New Calendar → On the Network → iCalendar (ICS)**.
Tasks go out as `VTODO`. Google ignores those entirely, so tasks with a clock time
are *also* sent as events — which is what makes the feed useful in the calendar
most people actually keep.
!!! warning "The URL is the password"
Anybody with the link can read your tasks. Calendar clients cannot log in, so
there is nothing else it could be. If it ends up somewhere it should not,
**Settings → Calendar feed → Rotate** issues a new one and breaks the old
immediately.
## Connecting a CalDAV client
This is the one where ticking something off in Apple Reminders ticks it off here.
**First, make a token.** **Settings → API tokens → New token**. Copy it — it is
shown once.
Then:
- **Server**: your verdande address
- **Username**: your email address
- **Password**: the API token, **not your account password**
=== "Apple Reminders (macOS/iOS)"
**Settings → Accounts → Add Other Account → CalDAV account**, choose *Manual*,
and fill in the three fields above.
=== "Thunderbird"
**New Calendar → On the Network**, enter your email as the username and the
address as the location. It will find the collections itself.
=== "DAVx⁵ (Android)"
**Add account → Login with URL and username**. Then pick which projects to
sync.
Each project appears as its own calendar, so you can turn them on and off
individually.
!!! note "Why a token and not your password"
A CalDAV client stores what you give it and sends it on every request. A token
can only read and write tasks — it cannot sign in to the app or change your
password — and you can revoke one without touching anything else.
## What syncs
Task titles, notes, due dates and times, priorities, repetition, and completion —
in both directions.
Not sections, labels, sub-task relationships, comments or attachments. CalDAV's
VTODO has nowhere to put them, and inventing an encoding would produce something no
other client could read.
Completed tasks stay in the collection. A client that stops seeing one treats it as
deleted and drops its own record of having done it.
+48
View File
@@ -0,0 +1,48 @@
# Configuration
Everything is an environment variable. There is no config file — a Rune has
nowhere to put one that the operator would ever see.
## Core
| Variable | Default | What it does |
|---|---|---|
| `VERDANDE_BASE_URL` | `http://localhost:8080` | The public address. Invite links, password resets, calendar feeds and the mail-to-task address are all built from it. |
| `VERDANDE_ADDR` | `:8080` | Listen address. |
| `VERDANDE_DATA_DIR` | `/data` | Database, uploaded files and backups. |
| `VERDANDE_DEV` | `false` | Human-readable logs at debug level, and a relaxed origin check so a Vite dev server can connect. Never in production. |
## Sessions and links
| Variable | Default | What it does |
|---|---|---|
| `VERDANDE_SESSION_TTL` | `720h` | How long a login lasts. |
| `VERDANDE_INVITE_TTL` | `168h` | How long an invite link stays valid. |
| `VERDANDE_RESET_TTL` | `1h` | How long a password-reset link stays valid. |
Any Go duration: `720h`, `30m`, `45s`.
## Mail
| Variable | Default | What it does |
|---|---|---|
| `VERDANDE_SMTP_HOST` | — | Blank disables outbound mail entirely. |
| `VERDANDE_SMTP_PORT` | `587` | |
| `VERDANDE_SMTP_USER` | — | |
| `VERDANDE_SMTP_PASS` | — | |
| `VERDANDE_SMTP_FROM` | `verdande@localhost` | Sender address. Also decides the domain of the [mail-to-task address](mail.md). |
| `VERDANDE_SMTP_STARTTLS` | `true` unless port 465 | Port 465 is TLS from the first byte; everything else negotiates. |
| `VERDANDE_SMTP_INSECURE` | `false` | Skip certificate verification. For a self-signed internal mail server, and nothing else. |
!!! info "Running without a mail server"
Perfectly supported. Invite links are shown in the app for you to send
yourself, password resets are written to the log, and reminders arrive in the
app and as Web Push rather than by email.
## What is *not* configured here
**AI providers** are per-user, under settings in the app — see [AI](ai.md). One
person's key is not the instance's key.
**The VAPID keypair** for Web Push is generated on first use and stored in the
database. There is nothing to set.
+65
View File
@@ -0,0 +1,65 @@
# Saved filters
A filter is a small expression describing a slice of your tasks. The grammar is
Todoist's, so if you are coming from there you already know it.
```
today & p1
#Firma & @regnskab
overdue | (today & assigned to: me)
7 days & !@venter
```
## What you can write
| Term | Matches |
|---|---|
| `today`, `i dag` | Due today. |
| `tomorrow`, `i morgen` | Due tomorrow. |
| `overdue`, `forsinket` | Due before today. |
| `7 days`, `14 dage`, `2 weeks` | Due between now and then. A window, not a single day. |
| `no date`, `ingen dato` | No due date at all. |
| `p1` … `p4` | That priority. |
| `#Firma` | In that project. |
| `@regnskab` | Carrying that label. |
| `assigned to: me`, `tildelt: mig` | Assigned to you. |
| `assigned to: [email protected]` | Assigned to that person. |
| `assigned to: none` | Assigned to nobody. |
| `recurring`, `gentagen` | Repeats. |
| `completed`, `færdig` | Already done. |
| `subtask`, `underopgave` | Has a parent. |
| `2026-12-24` | Due on that exact day. |
| anything else | Searched for in the title and description. |
## Combining them
| Operator | Means |
|---|---|
| `&` | Both. |
| `|` or `,` | Either. |
| `!` | Not. |
| `( )` | Grouping. |
`&` binds tighter than `|`, so `overdue | today & p1` means
`overdue | (today & p1)`. Use brackets when you mean otherwise.
## Some that are worth saving
```
today & p1 What has to happen today
overdue What slipped
7 days & !@venter The week, minus what you are blocked on
#Firma & assigned to: me Your part of a shared project
p1 & no date Important, unscheduled — usually a mistake
recurring & overdue Habits you have stopped doing
```
## Why an expression is refused
A filter is compiled before it is saved. A filter that cannot run is worse than
none: you would find out later, from a list that is empty for a reason nobody can
see. If you get an error, it names the part it could not read.
!!! note "Labels are personal"
`@regnskab` matches *your* label of that name. Somebody else using the same
word on their own tasks is a different label, and your filter will not find it.
+66
View File
@@ -0,0 +1,66 @@
# Importing from Todoist
## Getting the file out of Todoist
For one project: open it, **⋯ → Export as template → Export as CSV**.
For everything: **Settings → Backups**, then take the most recent one. That gives
you one CSV per project.
## Bringing it in
**Settings → Import → From Todoist**, then pick the file. One project comes in per
file.
## What comes across
| Todoist | verdande |
|---|---|
| Tasks, in their tree | Tasks, with sub-tasks nested the same way |
| Sections | Sections |
| Notes | Comments |
| Priority | Priority — see below |
| Due dates | Due dates, and recurrence where it can be read |
| Descriptions | Descriptions |
!!! info "Priorities are renumbered on purpose"
Todoist's CSV numbers priorities the opposite way to how its interface shows
them: what you see as P1 is written as `4`. verdande stores what the interface
means, so the two are converted. A P1 in Todoist is a P1 here.
## Dates
Todoist stores what you typed rather than a date — `every Monday`, `tomorrow`,
`15 Mar`. Machine-readable dates come across exactly. Anything else goes through
the same [quick-add parser](quick-add.md) the app uses, which understands a great
deal of it in both languages — including recurrence, so `every Monday` arrives as a
real repeating task.
Anything it genuinely cannot read is listed in the import result rather than
dropped silently.
## What does not come across
**Assignees.** A person in your Todoist workspace is not a person here. The import
lists them so you can reassign.
**Attachments.** Todoist's CSV does not include them; they are links into Todoist's
own storage.
**Reminders and filters.** Not in the export format.
## Any other CSV
**Settings → Import → From a CSV file** takes anything with a header row, and asks
you which column means what. The only one it needs is the task text.
## Getting back out
Nothing here is a one-way door.
- **A project as Todoist CSV** — the same format, so it opens in Todoist.
- **A project as ICS** — a calendar file.
- **Your whole account as JSON** — every project, task, comment, label and filter.
A CSV that goes in and comes back out again is the same project: the round trip is
tested, and two exports of an unchanged project are byte-identical.
+49
View File
@@ -0,0 +1,49 @@
# verdande
Self-hosted tasks and projects, shared with the people you share the work with.
One binary with SQLite inside it — no database to provision, no second process.
**Verdande** is one of the three Norns: the one who spins *that which is becoming*.
Not what has been, and not what is fated to be — the thing you are in the middle
of. Which is what a to-do list is.
## What it is for
Todoist Pro, on your own hardware, with your own data. And with the integrations a
hosted product will not build for you: a real CalDAV server, an address you can
forward mail to, and an MCP endpoint so Claude can work your task list directly.
## What it does
- **[Quick add](quick-add.md) that reads what you type.** `betal moms i morgen kl
10 p1 #Firma @regnskab` becomes a task due tomorrow at 10:00, priority 1, in
Firma, labelled regnskab. Danish and English, mixed freely in one line.
- **Projects, sections, sub-tasks, labels, [saved filters](filters.md).** List,
board and calendar views.
- **Sharing** with owner, editor and viewer roles. Anyone outside your instance
joins through an invite link.
- **Live sync** — a change made by somebody else appears without a refresh.
- **Repeating tasks** as RRULE, reminders, comments and attachments.
- **[Calendar](caldav.md), [mail](mail.md) and [Claude](mcp.md)** connected over
open standards rather than private formats.
## Getting it running
```bash
docker run -d --name verdande \
-p 8080:8080 \
-v verdande-data:/data \
-e VERDANDE_BASE_URL=https://todo.example.dk \
ghcr.io/kristianwind/verdande:latest
```
Open the address, create the first account — that account is the administrator.
Running Yggdrasil Panel? [Install it as a Rune](rune.md) instead.
## What it will not do
No karma or points. No team workspaces. No native apps. No mail integration beyond
Gmail and the forwarding address. No languages beyond Danish and English. And no
sync protocol of its own — [CalDAV](caldav.md) and the [API](api.md) *are* the
sync.
+69
View File
@@ -0,0 +1,69 @@
# Mail to task
Forward an email and it becomes a task.
## Your address
**Settings → Mail to task** gives you something like:
```
todo+kJ8xN2pQ...@example.dk
```
The token is in the local part rather than the domain, which is what lets a single
mail alias route it without a wildcard domain or a DNS change per person.
## Using it
Forward anything to it. The **subject becomes the task** and the **body becomes the
description**.
The subject goes through the same [quick-add parser](quick-add.md) as everything
else, so this works:
> **Subject:** Send årsregnskab i morgen p1 #Firma
and arrives as a task called *Send årsregnskab*, due tomorrow, priority 1, in
Firma.
## Routing the mail
verdande does not run a mail server. Something has to hand it the message.
=== "Mailcow"
Make an alias for `todo+*@yourdomain` that pipes to a small script, and have
the script POST the parsed message to:
```
POST https://todo.example.dk/inbound/mail
Content-Type: application/json
{
"to": "todo+TOKEN@example.dk",
"from": "afsender@example.dk",
"subject": "Send årsregnskab i morgen p1 #Firma",
"body": "Vedhæftet er sidste års tal."
}
```
=== "Anything else"
The endpoint takes that JSON from anywhere — a Postfix pipe, a Cloudflare Email
Worker, an IMAP poller. The token in `to` is the whole credential, so no other
authentication is needed and none is accepted.
## Security
The token *is* the credential — anybody who can send mail to that address can
create tasks in your account. It cannot read anything, and it cannot reach anything
else.
An unknown token and a malformed address answer identically, so the endpoint cannot
be used to work out which addresses are live.
**Settings → Mail to task → Rotate** issues a new address and stops the old one
immediately.
!!! tip "Keep the address to yourself"
Treat it like a secret. If you publish it somewhere, rotate it.
+55
View File
@@ -0,0 +1,55 @@
# Claude (MCP)
verdande has a built-in [Model Context Protocol](https://modelcontextprotocol.io)
server, so you can add it to Claude as a connector and work your task list in
conversation.
> *"What's on my plate this week?"*
>
> *"Add a task to call the accountant on Friday, priority 1, in Firma."*
>
> *"What did I not get to that was due last week?"*
## Connecting it
**Make a token.** **Settings → API tokens → New token**. It is shown once.
**Add the connector.** In Claude, add a custom connector with:
- **URL**: `https://todo.example.dk/api/v1/mcp`
- **Authentication**: Bearer token, using the token you just made
!!! warning "It has to be reachable from the internet"
Claude connects from Anthropic's side, not from your browser. An instance only
reachable on your LAN or over a VPN cannot be used this way.
## What Claude can do
| Tool | What it does |
|---|---|
| `list_projects` | List your projects. |
| `search_tasks` | Find tasks — by text, or with the [filter language](filters.md). |
| `create_task` | Create one, from natural language or explicit fields. |
| `update_task` | Change a title, priority, date or project. |
| `complete_task` | Tick one off. |
| `add_comment` | Comment on a task. |
`create_task` prefers natural language, so *"add betal moms i morgen kl 10 p1
#Firma"* goes through the same parser the app uses — dates, priorities, projects,
labels and recurrence in either language.
## What it can reach
**Exactly what you can reach, and nothing more.** A token is a person, and a
person's permissions are already decided: shared projects you are a member of, yes;
somebody else's private projects, no. There is no separate service identity and no
scope to configure.
## What it cannot do
Delete projects, manage members, change settings or read another account. The tools
are the six above; there is no general escape hatch.
!!! note "Revoking it"
**Settings → API tokens**, delete the token. The connector stops working
immediately, and nothing else about your account is affected.
+76
View File
@@ -0,0 +1,76 @@
# Quick add
One line in, a fully-formed task out.
```
betal moms i morgen kl 10 p1 #Firma @regnskab
```
becomes a task called **betal moms**, due tomorrow at 10:00, priority 1, in the
Firma project, labelled regnskab.
The parts it understands are tinted underneath the text as you type, so you can see
it being read before you commit to it.
## What it reads
| You type | It means |
|---|---|
| `#Firma` | Put it in that project. `#"Q3 rapport"` for a name with spaces. |
| `@regnskab` | Add that label. As many as you like. |
| `p1` … `p4` | Priority. `!!!`, `!!` and `!` also work. |
| `i dag`, `i morgen`, `i overmorgen` | Today, tomorrow, the day after. |
| `mandag`, `på fredag` | The next such weekday. |
| `næste mandag` | Next week's Monday, counted from Monday — not merely the next one to come round. |
| `om 3 dage`, `om en uge`, `næste måned` | Relative dates. |
| `15. marts`, `15/3`, `2026-03-15` | Written dates. Day first. |
| `kl 10`, `kl 14:30`, `klokken 16` | A clock time. |
| `hver mandag`, `hverdage`, `hver 2. uge` | Makes it [repeat](#repeating-tasks). |
English works everywhere Danish does: `tomorrow`, `next friday`, `in 3 days`,
`every 2 weeks`, `at 10am`, `weekdays`.
## Mixing the two
```
ring til Anders on friday kl 14 #Arbejde
```
Both languages are read at once rather than behind a setting, because people mix
them mid-sentence. The only thing the language setting decides is a bare hour after
English `at`: `at 3` means three in the afternoon, while Danish `kl 3` means three
in the morning — Danish has no am/pm habit and the 24-hour clock is what people
mean.
## Repeating tasks
```
vand planterne hver mandag
standup hverdage kl 9
betal husleje den 1. i måneden
review every 2 weeks
```
Stored as an RFC 5545 RRULE, so a task exported to Apple Reminders keeps repeating
there.
Ticking off a repeating task **moves it to its next occurrence** rather than
closing it. The same task keeps its id, so its sub-tasks and comments stay
attached — a weekly review is one thing that recurs, not fifty-two things. The
completion is still recorded, so "what did I get done this week" includes the
chores that repeat.
## Dates it cannot read
Anything it does not recognise stays in the title. A task with a clumsy title beats
an error message and a lost thought.
!!! tip "Numeric dates are day-first"
`15/3` is the fifteenth of March. That is how both Danish and British English
write it, and guessing month-first would silently move a date by months rather
than failing visibly.
## The shortcuts
Press ++q++ to jump to the quick-add box from anywhere, ++t++ for Today, ++u++ for
Upcoming, ++cmd+k++ to search, and ++question++ for the full list.
+47
View File
@@ -0,0 +1,47 @@
# Installing as a Rune
verdande ships with a Rune manifest for
[Yggdrasil Panel](https://github.com/kristianwind/yggdrasil).
## Installing
1. **Runes → Carve a rune (upload)** and pick
[`rune/verdande.yaml`](https://github.com/kristianwind/verdande/blob/main/rune/verdande.yaml)
from the repository.
2. Create a server from it.
3. Fill in the settings below.
4. Start it, open the URL, create the first account.
## Settings
| Setting | What to put |
|---|---|
| **Public address** | The address you reach it on. `{{PUBLIC_URL}}` is the default and usually right. |
| **SMTP host** | Your mail server. Leave blank and invite links are shown in the app instead of emailed. |
| **SMTP port** | 587 for STARTTLS, 465 for implicit TLS. |
| **SMTP username / password** | As your mail server expects. |
| **Sender address** | What invites and reminders come from. |
| **Use STARTTLS** | On, unless you are on port 465. |
| **How long a login lasts** | `720h` — thirty days. |
!!! note "Why the public address cannot be guessed"
The host port is not chosen until the server is created, so verdande has no way
to work out its own address. The panel does, and passes it in as
`{{PUBLIC_URL}}`.
## Reaching it from outside
Give the server a **Subdomain** under its settings and enable
**Cloudflare Tunnel (subdomains)** under **Settings → Network**. No port forwarding
and no public IP.
## Backups
The Rune's backup includes the whole `/data` directory, which is what you want —
see [the warning about copying the database alone](self-hosting.md#what-lives-in-data).
## Watchers
The manifest ships log watchers for database errors, repeated failed logins and
HTTP 5xx. They appear under **Settings → Kvasir Watchers** as ordinary per-server
rules you can edit or turn off.
+108
View File
@@ -0,0 +1,108 @@
# Self-hosting
verdande is one static binary and one directory. There is no database server, no
cache, no queue and no second process.
## Docker
```bash
docker run -d --name verdande \
--restart unless-stopped \
-p 8080:8080 \
-v verdande-data:/data \
-e VERDANDE_BASE_URL=https://todo.example.dk \
ghcr.io/kristianwind/verdande:latest
```
`VERDANDE_BASE_URL` has to be the address you actually reach it on. Invite links,
password resets and calendar feeds are all built from it, so getting it wrong
means the links in your email point somewhere that does not answer.
## Docker Compose
```yaml
services:
verdande:
image: ghcr.io/kristianwind/verdande:latest
restart: unless-stopped
ports:
- "8080:8080"
volumes:
- ./data:/data
environment:
VERDANDE_BASE_URL: https://todo.example.dk
VERDANDE_SMTP_HOST: mail.example.dk
VERDANDE_SMTP_USER: verdande@example.dk
VERDANDE_SMTP_PASS: ${SMTP_PASSWORD}
VERDANDE_SMTP_FROM: verdande@example.dk
```
## Behind a reverse proxy
verdande expects to be terminated by something else. It reads `X-Forwarded-For`,
so rate limiting and the activity log see the real caller rather than the proxy.
=== "Caddy"
```
todo.example.dk {
reverse_proxy localhost:8080
}
```
=== "Cloudflare Tunnel"
```yaml
ingress:
- hostname: todo.example.dk
service: http://localhost:8080
- service: http_status:404
```
The WebSocket needs no special handling; Cloudflare proxies it as-is. A silent
socket is closed after 100 seconds, which is why verdande pings every 30.
!!! warning "Use HTTPS"
Over plain HTTP the session cookie cannot be `Secure`, and verdande will not
use the `__Host-` prefix that stops a subdomain overwriting it. Fine on a
laptop; not fine on anything reachable from outside.
## First run
Open the address and create an account. The first one is the administrator, and
the endpoint that creates it refuses once any account exists — so there is no
window in which somebody else can claim your instance.
Everybody after that joins by invite.
## What lives in `/data`
```
/data
├── verdande.db SQLite, WAL mode
├── files/ attachments, content-addressed
└── backups/ nightly snapshots, 14 kept
```
Back up the whole directory.
!!! danger "Do not copy verdande.db on its own"
It runs in WAL mode, so recent writes live in `verdande.db-wal` until they are
checkpointed. A copy of the `.db` file alone can be missing them. Copy the
whole directory, or take one of the nightly snapshots from `backups/` — those
are written with `VACUUM INTO` and are complete on their own.
## Backups
A snapshot is written once a day and the most recent fourteen are kept. They are
counted rather than aged out, so a container that was off for a month comes back
with its backups intact.
## Updating
```bash
docker compose pull && docker compose up -d
```
Migrations run at startup, each in its own transaction. Take a copy of `/data`
first anyway.
+16
View File
@@ -0,0 +1,16 @@
/* The app's accent, so the docs and the thing they describe look like one project. */
:root {
--md-primary-fg-color: #2f8f63;
--md-accent-fg-color: #2f8f63;
}
[data-md-color-scheme='slate'] {
--md-primary-fg-color: #5fbf8f;
--md-accent-fg-color: #6fcf9f;
--md-default-bg-color: #111312;
}
.md-header__title {
font-weight: 560;
letter-spacing: -0.02em;
}
+77
View File
@@ -0,0 +1,77 @@
site_name: verdande
site_description: Self-hosted tasks and projects, shared. One binary, SQLite inside it.
site_url: https://kristianwind.github.io/verdande/
repo_url: https://github.com/kristianwind/verdande
repo_name: kristianwind/verdande
edit_uri: edit/main/docs/
# Not the default `site/` — that directory holds the landing page, which is a
# separate thing deployed to a separate place.
site_dir: site_build
theme:
name: material
language: en
# The same two-theme arrangement as the app itself, and the same accent.
palette:
- media: "(prefers-color-scheme: dark)"
scheme: slate
primary: custom
accent: custom
toggle:
icon: material/weather-sunny
name: Switch to light
- media: "(prefers-color-scheme: light)"
scheme: default
primary: custom
accent: custom
toggle:
icon: material/weather-night
name: Switch to dark
features:
- navigation.instant
- navigation.sections
- navigation.top
- content.code.copy
- search.suggest
font:
text: Inter
code: JetBrains Mono
extra_css:
- stylesheets/verdande.css
nav:
- Home: index.md
- Setting it up:
- Self-hosting: self-hosting.md
- As a Rune: rune.md
- Configuration: configuration.md
- Using it:
- Quick add: quick-add.md
- Filters: filters.md
- Importing from Todoist: import.md
- Connecting things:
- Calendar and CalDAV: caldav.md
- Mail to task: mail.md
- Claude (MCP): mcp.md
- AI features: ai.md
- Reference:
- API: api.md
markdown_extensions:
- admonition
- attr_list
- def_list
- tables
- toc:
permalink: true
- pymdownx.details
- pymdownx.superfences
- pymdownx.tabbed:
alternate_style: true
- pymdownx.highlight:
anchor_linenums: true
plugins:
- search
+9
View File
@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<!-- Wunjo, the rune Verdande's name begins with. Drawn as strokes rather than
as a filled glyph so it stays legible at 16px in a browser tab. -->
<rect width="64" height="64" rx="14" fill="#111312"/>
<g fill="none" stroke="#5fbf8f" stroke-width="4.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M22 14v36"/>
<path d="M22 17l17 8-17 8"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 447 B

+455
View File
@@ -0,0 +1,455 @@
<!doctype html>
<html lang="en" data-theme="dark">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>verdande — self-hosted tasks, shared</title>
<meta
name="description"
content="Self-hosted tasks and projects, shared. One Go binary with SQLite inside it. CalDAV, mail-to-task and an MCP connector for Claude."
/>
<meta name="theme-color" content="#111312" />
<meta property="og:title" content="verdande" />
<meta property="og:description" content="Self-hosted tasks and projects, shared. One binary, SQLite inside it." />
<meta property="og:type" content="website" />
<link rel="icon" href="icon.svg" type="image/svg+xml" />
<style>
/*
* The same tokens as the app, so the site and the thing it describes
* look like one project rather than two.
*/
:root {
--ground: #111312;
--surface: #171a19;
--surface-raised: #1d211f;
--line: #262b29;
--line-strong: #343a37;
--ink: #e8ebe9;
--ink-muted: #9aa39e;
--ink-faint: #6b736f;
--accent: #5fbf8f;
--accent-ink: #0d1411;
--radius: 8px;
--radius-lg: 12px;
--font: -apple-system, BlinkMacSystemFont, 'Segoe UI', Inter, system-ui, sans-serif;
--font-mono: ui-monospace, 'SF Mono', 'JetBrains Mono', Menlo, monospace;
}
@media (prefers-color-scheme: light) {
:root:not([data-theme='dark']) {
--ground: #faf9f7;
--surface: #ffffff;
--surface-raised: #ffffff;
--line: #e5e3de;
--line-strong: #d3d0c9;
--ink: #1c201e;
--ink-muted: #5f6763;
--ink-faint: #8b918d;
--accent: #2f8f63;
--accent-ink: #ffffff;
}
}
*,
*::before,
*::after {
box-sizing: border-box;
}
body {
margin: 0;
background: var(--ground);
color: var(--ink);
font-family: var(--font);
font-size: 16px;
line-height: 1.6;
-webkit-font-smoothing: antialiased;
}
.wrap {
max-width: 940px;
margin: 0 auto;
padding: 0 1.5rem;
}
/* --- hero --- */
header {
padding: 6rem 0 4rem;
text-align: center;
}
.mark {
font-size: 3rem;
color: var(--accent);
line-height: 1;
display: block;
margin-bottom: 1rem;
}
h1 {
margin: 0;
font-size: clamp(2.5rem, 7vw, 3.75rem);
font-weight: 560;
letter-spacing: -0.03em;
line-height: 1.05;
}
.lede {
margin: 1.25rem auto 0;
max-width: 30rem;
font-size: 1.125rem;
color: var(--ink-muted);
}
.norn {
margin: 2rem auto 0;
max-width: 34rem;
font-size: 0.9375rem;
color: var(--ink-faint);
}
.actions {
display: flex;
gap: 0.75rem;
justify-content: center;
flex-wrap: wrap;
margin-top: 2.5rem;
}
.button {
display: inline-block;
padding: 0.625rem 1.25rem;
border-radius: var(--radius);
text-decoration: none;
font-size: 0.9375rem;
font-weight: 550;
border: 1px solid var(--line-strong);
color: var(--ink);
transition: border-color 150ms ease, background 150ms ease;
}
.button:hover {
border-color: var(--accent);
}
.button.primary {
background: var(--accent);
color: var(--accent-ink);
border-color: var(--accent);
}
.button.primary:hover {
opacity: 0.9;
}
/* --- the one screenshot substitute: the thing it actually does --- */
.demo {
margin: 4rem 0;
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius-lg);
overflow: hidden;
}
.demo-bar {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--line);
font-size: 0.8125rem;
color: var(--ink-faint);
}
.demo-body {
padding: 1.5rem;
font-family: var(--font-mono);
font-size: 0.875rem;
line-height: 2;
overflow-x: auto;
}
.typed {
white-space: nowrap;
}
/* The tints match the app's own parse highlighting. */
mark {
background: color-mix(in srgb, var(--accent) 20%, transparent);
color: var(--ink);
border-radius: 3px;
padding: 1px 2px;
}
mark.p {
background: color-mix(in srgb, #cf6b6b 22%, transparent);
}
mark.proj {
background: color-mix(in srgb, #6b9ecf 22%, transparent);
}
mark.lbl {
background: color-mix(in srgb, #d69f5c 22%, transparent);
}
.arrow {
color: var(--ink-faint);
padding: 0 0.5rem;
}
.result {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
margin-top: 0.75rem;
font-family: var(--font);
font-size: 0.875rem;
}
.chip {
border: 1px solid var(--line-strong);
border-radius: 999px;
padding: 1px 0.625rem;
color: var(--ink-muted);
font-size: 0.75rem;
}
/* --- features --- */
section {
padding: 3rem 0;
}
h2 {
font-size: 1.5rem;
font-weight: 560;
letter-spacing: -0.02em;
margin: 0 0 2rem;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 1px;
background: var(--line);
border: 1px solid var(--line);
border-radius: var(--radius-lg);
overflow: hidden;
}
.cell {
background: var(--ground);
padding: 1.5rem;
}
.cell h3 {
margin: 0 0 0.5rem;
font-size: 1rem;
font-weight: 560;
}
.cell p {
margin: 0;
font-size: 0.9375rem;
color: var(--ink-muted);
}
/* --- install --- */
pre {
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 1rem 1.25rem;
overflow-x: auto;
font-family: var(--font-mono);
font-size: 0.8125rem;
line-height: 1.7;
color: var(--ink);
}
.muted {
color: var(--ink-muted);
font-size: 0.9375rem;
}
ul.plain {
list-style: none;
padding: 0;
margin: 1rem 0 0;
color: var(--ink-muted);
font-size: 0.9375rem;
}
ul.plain li {
padding-left: 1.25rem;
position: relative;
margin-bottom: 0.375rem;
}
ul.plain li::before {
content: '·';
position: absolute;
left: 0.375rem;
color: var(--accent);
}
footer {
border-top: 1px solid var(--line);
margin-top: 4rem;
padding: 2rem 0 4rem;
text-align: center;
color: var(--ink-faint);
font-size: 0.875rem;
}
footer a {
color: var(--ink-muted);
}
a {
color: var(--accent);
}
</style>
</head>
<body>
<div class="wrap">
<header>
<span class="mark" aria-hidden="true">ᚹ</span>
<h1>verdande</h1>
<p class="lede">
Self-hosted tasks and projects, shared with the people you share the work with.
One binary, SQLite inside it.
</p>
<p class="norn">
Verdande is one of the three Norns — the one who spins <em>that which is
becoming</em>. Not what has been, and not what is fated to be: the thing you are
in the middle of. Which is what a to-do list is.
</p>
<div class="actions">
<a class="button primary" href="https://kristianwind.github.io/verdande/self-hosting/">
Self-host it
</a>
<a class="button" href="https://github.com/kristianwind/verdande">Source</a>
<a class="button" href="https://kristianwind.github.io/verdande/">Documentation</a>
</div>
</header>
<!-- No screenshot: the quick-add parser is the thing worth showing, and it
shows better as the actual text than as a picture of it. -->
<div class="demo">
<div class="demo-bar">
<span aria-hidden="true">+</span>
<span>Add a task</span>
</div>
<div class="demo-body">
<div class="typed">
betal moms <mark>i morgen</mark> <mark>kl 10</mark>
<mark class="p">p1</mark> <mark class="proj">#Firma</mark>
<mark class="lbl">@regnskab</mark>
</div>
<div class="result">
<span class="arrow">→</span>
<strong>betal moms</strong>
<span class="chip">Tomorrow 10:00</span>
<span class="chip">P1</span>
<span class="chip">Firma</span>
<span class="chip">regnskab</span>
</div>
</div>
</div>
<section>
<h2>What it does</h2>
<div class="grid">
<div class="cell">
<h3>Reads what you type</h3>
<p>
Dates, times, priorities, projects, labels and repetition, parsed out of
one line — in Danish and English, mixed freely.
</p>
</div>
<div class="cell">
<h3>Shared, properly</h3>
<p>
Owner, editor and viewer roles. People outside your instance join by
invite link. Changes appear live.
</p>
</div>
<div class="cell">
<h3>A real CalDAV server</h3>
<p>
Two-way. Tick something off in Apple Reminders and it is ticked off
here. Not a read-only feed pretending.
</p>
</div>
<div class="cell">
<h3>Claude, connected</h3>
<p>
A built-in MCP server. Ask what is due this week, or have a task added,
in conversation.
</p>
</div>
<div class="cell">
<h3>Mail to task</h3>
<p>
Forward an email to your own address and it becomes a task, subject
parsed like anything else you type.
</p>
</div>
<div class="cell">
<h3>Yours to leave</h3>
<p>
Import from Todoist, export back to it, or take everything as JSON. The
round trip is tested, not promised.
</p>
</div>
</div>
</section>
<section>
<h2>Running it</h2>
<pre><code>docker run -d --name verdande \
-p 8080:8080 \
-v verdande-data:/data \
-e VERDANDE_BASE_URL=https://todo.example.dk \
ghcr.io/kristianwind/verdande:latest</code></pre>
<p class="muted" style="margin-top: 1rem">
Open the address and create the first account. That one is the administrator.
</p>
<ul class="plain">
<li>No database to provision — SQLite is inside the binary.</li>
<li>Distroless image: no shell, no package manager, one static file.</li>
<li>amd64 and arm64, so a Raspberry Pi is fine.</li>
<li>Nightly backups, fourteen kept, written with <code>VACUUM INTO</code>.</li>
</ul>
</section>
<section>
<h2>What it will not do</h2>
<p class="muted">
No karma or points. No team workspaces. No native apps. No mail integration
beyond Gmail and the forwarding address. No languages beyond Danish and English.
And no sync protocol of its own — CalDAV and the API <em>are</em> the sync.
</p>
</section>
<footer>
<p>
MIT licensed ·
<a href="https://github.com/kristianwind/verdande">github.com/kristianwind/verdande</a>
</p>
<p>Deployed as a Rune in <a href="https://github.com/kristianwind/yggdrasil">Yggdrasil</a>.</p>
</footer>
</div>
</body>
</html>