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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
6b19ed062d
commit
7c9bde2cfd
+67
@@ -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
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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: anders@example.dk` | 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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user