Overview
A JSON REST API for managing a personal todo list, running on franz2.
Data is persisted to a flat JSON file — no database needed. All fields beyond text
are optional; start minimal and add structure as needed.
The API has two access points depending on whether you're on the Tailscale network or the public internet.
Base URL
| Network | Base URL |
|---|---|
| Public internet (team members) | https://franz2.tailf2d239.ts.net |
| Tailscale network (internal) | http://100.77.82.18:3700 |
Endpoints
List all todos
Returns a paginated result object. Accepts filter, sort, and pagination params.
curl http://100.77.82.18:3700/todos
Response shape:
{
"total": 42, // total matching items before pagination
"limit": 20, // null if no limit was set
"offset": 0, // current offset
"items": [...] // the todos for this page
}
Get a single todo
curl http://100.77.82.18:3700/todos/1
Create a todo
Creates a new todo. Only text is required. Responds 201 with the created object.
# Minimal curl -X POST http://100.77.82.18:3700/todos \ -H 'Content-Type: application/json' \ -d '{"text": "Buy groceries"}'
# All optional fields curl -X POST http://100.77.82.18:3700/todos \ -H 'Content-Type: application/json' \ -d '{ "text": "Write quarterly report", "parentId": 5, "tags": ["work", "urgent"], "dependsOn": [3, 4], "repeat": "weekly", "assignee": "franz", "startDate": "2026-06-23", "dueDate": "2026-06-30" }'
Update a todo
Updates any combination of fields. Only the fields you include are changed.
# Mark done curl -X PATCH http://100.77.82.18:3700/todos/1 \ -H 'Content-Type: application/json' \ -d '{"done": true}' # Rename, reassign, and set a due date curl -X PATCH http://100.77.82.18:3700/todos/1 \ -H 'Content-Type: application/json' \ -d '{"text": "Buy milk too", "assignee": "alice", "dueDate": "2026-07-01"}' # Replace tags curl -X PATCH http://100.77.82.18:3700/todos/1 \ -H 'Content-Type: application/json' \ -d '{"tags": ["shopping"]}'
Delete a todo
Permanently removes the todo. Responds with {"deleted": <id>}.
curl -X DELETE http://100.77.82.18:3700/todos/1
Filtering
Combine any of these query parameters on GET /todos:
| Parameter | Example | Description |
|---|---|---|
tag |
?tag=work,health |
Only todos that include at least one of the given tags. Comma-separate multiple tags for OR matching. |
parentId |
?parentId=5 |
Only sub-todos of the given parent. Use parentId=null to get root-level todos only. |
blocked |
?blocked=false |
true = only todos with unfinished dependencies. false = only unblocked todos. |
assignee |
?assignee=franz |
Only todos where assignees includes this person (exact match) |
role |
?role=dev-team |
Only todos assigned to this role (exact match) |
category |
?category=frontend |
Only todos in this category (exact match) |
importance |
?importance=high |
Only todos with this importance level: low, medium, high |
energyRequired |
?energyRequired=low |
Only todos requiring this energy level |
context |
?context=@computer |
Only todos set to this context (exact match) |
preferredTime |
?preferredTime=morning |
Only todos with this preferred time |
deadlineType |
?deadlineType=hard |
Only todos with this deadline type |
priority |
?priority=urgent |
Only todos with this priority value |
color |
?color=red |
Only todos with this color label (exact match) |
q |
?q=groceries |
Full-text search across text, tags, and comment bodies (case-insensitive) |
done |
?done=false |
true = only completed todos. false = only incomplete todos. Omit to get all. |
overdue |
?overdue=true |
true = only overdue todos. false = only non-overdue. See the overdue computed field. |
dueSoon |
?dueSoon=7 |
Todos with dueDate or nextDue within the next N days. Excludes already-overdue and done items. |
# Unblocked work tasks assigned to franz curl "http://100.77.82.18:3700/todos?tag=work&blocked=false&assignee=franz" # All root-level todos curl "http://100.77.82.18:3700/todos?parentId=null"
Schema
| Field | Type | Writable | Description |
|---|---|---|---|
id | number | no | Auto-assigned integer, never reused |
text | string | yes | The todo description. Required on create. |
done | boolean | yes | Whether completed. On repeating tasks, setting true advances the schedule and resets to false. |
createdAt | ISO 8601 | no | Creation timestamp (UTC) |
parentId | number | null | yes | ID of a parent todo. null = root-level. |
tags | string[] | yes | Arbitrary labels. PATCH replaces the whole array. |
dependsOn | number[] | yes | IDs of todos that must be done before this one. PATCH replaces the whole array. |
blocked | boolean | no | Computed. true if any dependsOn item is not yet done. |
overdue | boolean | no | Computed. true when not done and either dueDate is in the past, or nextDue (for habits) is before now. |
repeat | string | number | null | yes | "daily", "weekly", "monthly", or a number of days. null disables repeating. |
nextDue | ISO 8601 | null | no | Next due date for repeating tasks — advanced automatically when marked done. |
assignees | string[] | yes | Names/identifiers of people responsible. PATCH replaces the whole array. |
role | string | null | yes | Name of the role responsible (e.g. "dev-team"). See Roles. |
category | string | null | yes | Free-form category label (e.g. "frontend", "admin"). |
importance | "low" | "medium" | "high" | null | yes | Long-term value of the task — separate from urgency. Used to implement the Eisenhower matrix in GET /next. |
energyRequired | "low" | "medium" | "high" | null | yes | Cognitive/physical energy this task demands. Used by GET /next?energy= to match tasks to your current capacity. |
context | string | null | yes | Where or what you need to do this task. Free-form: "@computer", "@phone", "@home", "@errands". Filtered by GET /next?context=. |
preferredTime | "morning" | "afternoon" | "evening" | null | yes | Best time of day for this task. Matched against GET /next?timeOfDay= — tasks at their preferred time get a scoring bonus. |
deadlineType | "hard" | "soft" | null | yes | Whether the deadline is fixed (hard) or flexible (soft). Hard deadlines amplify urgency within 3 days; soft deadlines reduce it. |
deferCount | number | no | How many times this task has been deferred via POST /todos/:id/defer or passed to GET /next?exclude=. After 5 defers the algorithm escalates the task. |
customFields | object | yes | Arbitrary key-value metadata. PATCH merges new keys into the existing object. |
startDate | date string | null | yes | When work on this task should begin. Informational. Recommended format: YYYY-MM-DD. |
dueDate | date string | null | yes | Deadline for the task. Informational. Recommended format: YYYY-MM-DD. |
priority | string | null | yes | "low", "medium", "high", or "urgent". Validated on write. |
estimatedMinutes | number | null | yes | How long the task is expected to take, in minutes. |
color | string | null | yes | Free-form color label (e.g. "red", "#3498db"). Interpreted by the client. |
completedAt | ISO 8601 | null | no | Set automatically when done is set to true. Cleared when unmarked. On repeating tasks, updated each time done is marked. |
links | object[] | via sub-resource | Attached URLs. Each entry: {id, url, label}. Managed via /links endpoints. |
comments | object[] | via sub-resource | Notes added over time. Each entry: {id, text, createdAt}. Managed via /comments endpoints. |
timeLog | object[] | via sub-resource | Timer sessions. Each entry: {id, startedAt, stoppedAt, minutes}. |
activeTimer | object | null | via sub-resource | {startedAt} when a timer is running, otherwise null. |
totalMinutes | number | no | Computed. Sum of all timeLog minutes plus elapsed time from any active timer. |
completionLog | ISO 8601[] | no | Timestamps of every past completion. Managed automatically; used to compute habit strength. |
habitStrength | object | absent | no | Computed. Present only on repeating tasks. See Habit strength. |
Features
Sub-todos
Any todo can be a child of another by setting parentId to the parent's ID.
There is no enforced depth limit — nesting can be as deep as needed.
To move a todo back to the root, PATCH it with "parentId": null.
# Create a parent task (gets id: 1) curl -X POST http://100.77.82.18:3700/todos \ -H 'Content-Type: application/json' \ -d '{"text": "Plan holiday"}' # Add sub-todos under it curl -X POST http://100.77.82.18:3700/todos \ -H 'Content-Type: application/json' \ -d '{"text": "Book flights", "parentId": 1}' curl -X POST http://100.77.82.18:3700/todos \ -H 'Content-Type: application/json' \ -d '{"text": "Reserve hotel", "parentId": 1}' # Fetch all sub-todos of parent 1 curl "http://100.77.82.18:3700/todos?parentId=1" # Fetch only root-level todos curl "http://100.77.82.18:3700/todos?parentId=null" # Detach a sub-todo back to root curl -X PATCH http://100.77.82.18:3700/todos/2 \ -H 'Content-Type: application/json' \ -d '{"parentId": null}'
Tags
Tags are free-form strings stored as an array on each todo.
They are set on create and replaced wholesale on PATCH — to add a single tag,
read the current tags first, then send the full updated array.
Filter by a single tag with ?tag=.
# Create with tags curl -X POST http://100.77.82.18:3700/todos \ -H 'Content-Type: application/json' \ -d '{"text": "Submit invoice", "tags": ["work", "finance"]}' # Replace tags entirely curl -X PATCH http://100.77.82.18:3700/todos/2 \ -H 'Content-Type: application/json' \ -d '{"tags": ["work", "finance", "urgent"]}' # Remove all tags curl -X PATCH http://100.77.82.18:3700/todos/2 \ -H 'Content-Type: application/json' \ -d '{"tags": []}' # List all todos tagged "work" curl "http://100.77.82.18:3700/todos?tag=work"
Dependencies
dependsOn is an array of todo IDs that must all be done: true before this task
is considered unblocked. The blocked field in every response is computed live.
The API does not prevent you from completing a blocked task — it only surfaces the state so you can filter on it.
# Task 5 cannot start until tasks 3 and 4 are done curl -X PATCH http://100.77.82.18:3700/todos/5 \ -H 'Content-Type: application/json' \ -d '{"dependsOn": [3, 4]}' # See what's currently blocked curl "http://100.77.82.18:3700/todos?blocked=true" # See what's ready to work on (not blocked, not done) curl "http://100.77.82.18:3700/todos?blocked=false" # Clear all dependencies curl -X PATCH http://100.77.82.18:3700/todos/5 \ -H 'Content-Type: application/json' \ -d '{"dependsOn": []}'
Repeating tasks
Set repeat on any todo to make it recur. When you mark a repeating task done,
it automatically resets to done: false and nextDue advances by the interval
from the previous due date (not from "now"), so the schedule stays consistent even if you mark it late.
To permanently stop recurring, PATCH "repeat": null and the task becomes a normal one-off.
| Value | Recurs every… |
|---|---|
"daily" | 1 day |
"weekly" | 7 days |
"monthly" | 1 calendar month |
14 (any number) | that many days |
# Create a daily task curl -X POST http://100.77.82.18:3700/todos \ -H 'Content-Type: application/json' \ -d '{"text": "Daily standup", "repeat": "daily", "tags": ["work"]}' # Marking done advances nextDue — task stays and resets to done: false curl -X PATCH http://100.77.82.18:3700/todos/3 \ -H 'Content-Type: application/json' \ -d '{"done": true}' # Convert to a one-off (stop repeating) curl -X PATCH http://100.77.82.18:3700/todos/3 \ -H 'Content-Type: application/json' \ -d '{"repeat": null}'
nextDue is
informational — check it by reading the list and act on it yourself.
Assignees
assignees is an array of free-form names or identifiers. A task can have multiple assignees.
Filter by a specific person with ?assignee= — returns todos where that person is in the array.
Set to [] to clear all assignees.
# Single assignee curl -X POST http://100.77.82.18:3700/todos \ -H 'Content-Type: application/json' \ -d '{"text": "Fix the login bug", "assignees": ["alice"]}' # Multiple assignees curl -X POST http://100.77.82.18:3700/todos \ -H 'Content-Type: application/json' \ -d '{"text": "Code review", "assignees": ["alice", "bob"]}' # Get all tasks assigned to alice curl "http://100.77.82.18:3700/todos?assignee=alice" # Clear assignees curl -X PATCH http://100.77.82.18:3700/todos/7 \ -H 'Content-Type: application/json' \ -d '{"assignees": []}'
Roles
Roles are named groups of people. A task can be assigned to a role instead of (or alongside) individual assignees. Use roles to represent teams or functional groups.
List all roles.
Create a role. Body: {"name": "dev-team", "members": ["franz", "alice"]}
Update a role. Accepts name, members (replace array), addMember, removeMember.
Delete a role.
# Create a role curl -X POST http://100.77.82.18:3700/roles \ -H 'Content-Type: application/json' \ -d '{"name": "dev-team", "members": ["franz", "alice"]}' # Add a member to a role curl -X PATCH http://100.77.82.18:3700/roles/1 \ -H 'Content-Type: application/json' \ -d '{"addMember": "bob"}' # Assign a task to a role curl -X POST http://100.77.82.18:3700/todos \ -H 'Content-Type: application/json' \ -d '{"text": "Deploy to staging", "role": "dev-team"}' # Filter tasks by role curl "http://100.77.82.18:3700/todos?role=dev-team"
Category
A free-form string to group tasks by area of work. Unlike tags, a task has exactly one category (or none).
Good for top-level grouping: "frontend", "ops", "admin", "personal".
# Create with a category curl -X POST http://100.77.82.18:3700/todos \ -H 'Content-Type: application/json' \ -d '{"text": "Update navbar", "category": "frontend"}' # Filter by category curl "http://100.77.82.18:3700/todos?category=frontend"
Custom fields
Attach arbitrary key-value metadata to any task via customFields. Keys and values are free-form strings.
When you PATCH customFields, new keys are merged in — existing keys you don't mention are preserved.
To delete a key, set its value to null.
# Create with custom fields curl -X POST http://100.77.82.18:3700/todos \ -H 'Content-Type: application/json' \ -d '{"text": "Build login page", "customFields": {"ticket": "JIRA-42", "sprint": "Sprint 3"}}' # Add a new field without overwriting existing ones curl -X PATCH http://100.77.82.18:3700/todos/19 \ -H 'Content-Type: application/json' \ -d '{"customFields": {"reviewed": "true"}}' # Remove a field curl -X PATCH http://100.77.82.18:3700/todos/19 \ -H 'Content-Type: application/json' \ -d '{"customFields": {"sprint": null}}'
Importance
importance captures long-term value, separate from urgency.
Combined with priority (which reflects urgency), they implement the
Eisenhower Matrix: you can distinguish tasks that are urgent-but-unimportant
from tasks that are important-but-not-urgent.
Both fields feed the GET /next scoring algorithm independently.
# High-impact strategic work that isn't urgent yet curl -X POST http://100.77.82.18:3700/todos \ -H 'Content-Type: application/json' \ -d '{"text": "Refactor auth module", "importance": "high", "priority": "low"}' # Filter by importance curl "http://100.77.82.18:3700/todos?importance=high&done=false"
Energy required
energyRequired describes how much cognitive or physical energy the task demands:
"high" (deep focus, complex reasoning), "medium", or "low" (admin, errands).
Pass ?energy= to GET /next to only receive tasks you can actually do right now.
The filter is inclusive downward — ?energy=medium shows medium and low tasks, never high.
# Tag a task as requiring deep focus curl -X PATCH http://100.77.82.18:3700/todos/5 \ -H 'Content-Type: application/json' \ -d '{"energyRequired": "high"}' # When you're tired — only show tasks you can actually do curl "http://100.77.82.18:3700/next?energy=low"
Context
context describes where or what you need to perform the task — a free-form string.
Convention: prefix with @ (e.g. "@computer", "@phone",
"@home", "@errands", "@office").
Pass ?context= to GET /next to only surface tasks doable in your current situation.
Tasks with no context set always appear regardless of filter.
# Set context on a task curl -X PATCH http://100.77.82.18:3700/todos/7 \ -H 'Content-Type: application/json' \ -d '{"context": "@computer"}' # Running errands — only show relevant tasks curl "http://100.77.82.18:3700/next?context=@errands"
Preferred time
preferredTime marks when during the day a task is best suited:
"morning", "afternoon", or "evening".
Pass ?timeOfDay= to GET /next — tasks at their preferred time get a scoring bonus,
and tasks set to a different time are filtered out (tasks with no preference always appear).
This reflects chronobiology: schedule cognitively demanding work during your peak hours.
# Deep work is best in the morning curl -X PATCH http://100.77.82.18:3700/todos/5 \ -H 'Content-Type: application/json' \ -d '{"preferredTime": "morning", "energyRequired": "high"}' # Morning session — get tasks suited to now curl "http://100.77.82.18:3700/next?timeOfDay=morning&energy=high"
Deadline type
deadlineType distinguishes between "hard" deadlines (the consequence of missing
them is severe — a flight, a legal filing, a client call) and "soft" deadlines (self-imposed
targets that can slip).
The algorithm amplifies urgency for hard deadlines within 3 days (×1.4) and reduces urgency for soft
deadlines (×0.75), so the recommendations reflect the real cost of being late.
# Hard deadline — missing this has serious consequences curl -X PATCH http://100.77.82.18:3700/todos/11 \ -H 'Content-Type: application/json' \ -d '{"deadlineType": "hard"}'
Deferring tasks
POST /todos/:id/defer explicitly marks a task as "not now". It increments
deferCount and optionally snoozes the deadline.
The recommendation algorithm uses deferCount as a signal:
- 1–4 defers: small penalty (−3 points per defer) — you're avoiding it, so it ranks slightly lower
- 5+ defers: penalty flips to escalation (+4 points per defer above 4) — you've been avoiding it long enough, it needs addressing
GET /next?exclude= also increments deferCount for the excluded IDs,
so the act of skipping a recommendation is automatically tracked.
Increment deferCount. Optionally snooze with {"days": N} or {"until": "YYYY-MM-DD"}.
# Defer without snoozing curl -X POST http://100.77.82.18:3700/todos/5/defer # Defer and snooze for 3 days curl -X POST http://100.77.82.18:3700/todos/5/defer \ -H 'Content-Type: application/json' \ -d '{"days": 3}' # Skip recommendation and auto-track the defer curl "http://100.77.82.18:3700/next?exclude=5"
Dates
Two optional date fields are available on every todo. Both are informational — the server stores them as-is and does not enforce or act on them automatically.
| Field | Purpose |
|---|---|
startDate | When work on the task should begin ("do date") |
dueDate | The deadline by which the task must be complete |
Dates can be any string, but YYYY-MM-DD is recommended for consistency.
# Set both dates on create curl -X POST http://100.77.82.18:3700/todos \ -H 'Content-Type: application/json' \ -d '{"text": "Submit tax return", "startDate": "2026-07-01", "dueDate": "2026-07-31"}' # Update the due date only curl -X PATCH http://100.77.82.18:3700/todos/4 \ -H 'Content-Type: application/json' \ -d '{"dueDate": "2026-08-15"}' # Clear a date curl -X PATCH http://100.77.82.18:3700/todos/4 \ -H 'Content-Type: application/json' \ -d '{"startDate": null}'
Priority
Set priority to classify how urgent or important a task is.
Only the four values below are accepted — anything else returns a 400.
Filter by priority with ?priority=.
| Value | Meaning |
|---|---|
"low" | Nice to have; do it when there's spare time |
"medium" | Default importance; should be done soon |
"high" | Important; prioritise over medium and low |
"urgent" | Needs to happen now; drops everything else |
# Create with priority curl -X POST http://100.77.82.18:3700/todos \ -H 'Content-Type: application/json' \ -d '{"text": "Fix prod bug", "priority": "urgent"}' # Update priority curl -X PATCH http://100.77.82.18:3700/todos/1 \ -H 'Content-Type: application/json' \ -d '{"priority": "high"}' # Get all urgent tasks curl "http://100.77.82.18:3700/todos?priority=urgent" # Clear priority curl -X PATCH http://100.77.82.18:3700/todos/1 \ -H 'Content-Type: application/json' \ -d '{"priority": null}'
Estimated duration
estimatedMinutes stores how long a task is expected to take, as a plain integer number of minutes.
The server stores it as-is; it is meant for clients and UIs to use for scheduling or time-blocking.
# 45-minute task curl -X POST http://100.77.82.18:3700/todos \ -H 'Content-Type: application/json' \ -d '{"text": "Write unit tests", "estimatedMinutes": 45}' # Update the estimate curl -X PATCH http://100.77.82.18:3700/todos/4 \ -H 'Content-Type: application/json' \ -d '{"estimatedMinutes": 90}' # Clear it curl -X PATCH http://100.77.82.18:3700/todos/4 \ -H 'Content-Type: application/json' \ -d '{"estimatedMinutes": null}'
Color
color is a free-form string for visual categorisation. The server applies no constraints —
use a CSS color name, a hex code, or any label your client understands. Filter by exact value with ?color=.
# Assign a color on create curl -X POST http://100.77.82.18:3700/todos \ -H 'Content-Type: application/json' \ -d '{"text": "Team meeting", "color": "blue"}' # Change or clear color curl -X PATCH http://100.77.82.18:3700/todos/5 \ -H 'Content-Type: application/json' \ -d '{"color": "#e74c3c"}' # Filter by color curl "http://100.77.82.18:3700/todos?color=blue"
Completed-at timestamp
completedAt is set automatically to the current UTC time when a task is marked
done: true. It is cleared back to null if the task is later unmarked.
On repeating tasks, it is updated each time done is marked (recording the most recent completion time).
You cannot set this field manually.
# Mark done — completedAt is set automatically in the response curl -X PATCH http://100.77.82.18:3700/todos/1 \ -H 'Content-Type: application/json' \ -d '{"done": true}' # Unmark — completedAt is cleared curl -X PATCH http://100.77.82.18:3700/todos/1 \ -H 'Content-Type: application/json' \ -d '{"done": false}'
Time tracking
Start and stop a timer on any todo to track how long you actually spend on it.
Each stopped session is saved to timeLog. The computed totalMinutes
field sums all sessions and adds any currently-running elapsed time.
Only one timer can run per task at a time.
Starts a timer. Returns 400 if a timer is already running.
Stops the running timer and appends a session to timeLog. Returns 400 if no timer is running.
# Start the timer curl -X POST http://100.77.82.18:3700/todos/1/timer/start # Stop it and see the logged session curl -X POST http://100.77.82.18:3700/todos/1/timer/stop # Check total time spent curl http://100.77.82.18:3700/todos/1 | python3 -c "import json,sys; t=json.load(sys.stdin); print(t['totalMinutes'], 'min')"
A timeLog entry looks like:
{
"id": 1,
"startedAt": "2026-06-22T09:00:00.000Z",
"stoppedAt": "2026-06-22T09:45:00.000Z",
"minutes": 45
}
Links / attachments
Attach URLs to any todo for quick reference — tickets, documents, PRs, anything.
Each link has an auto-assigned id, a url (required), and an optional label.
Adds a link. Body: {"url": "...", "label": "..."}. label is optional.
Removes a link by its id.
# Add a link curl -X POST http://100.77.82.18:3700/todos/1/links \ -H 'Content-Type: application/json' \ -d '{"url": "https://github.com/...", "label": "PR #42"}' # Remove a link (id comes from the POST response) curl -X DELETE http://100.77.82.18:3700/todos/1/links/1 # See all links on a task curl http://100.77.82.18:3700/todos/1 | python3 -c "import json,sys; print(json.load(sys.stdin)['links'])"
Comments
Add freeform notes to a task over time — status updates, blockers, decisions.
Comments are stored in order and each has an auto-assigned id.
Returns the comment array for this task.
Adds a comment. Body: {"text": "..."}.
Deletes a comment by its id.
# Add a comment curl -X POST http://100.77.82.18:3700/todos/1/comments \ -H 'Content-Type: application/json' \ -d '{"text": "Blocked on API access — waiting for credentials"}' # Read all comments curl http://100.77.82.18:3700/todos/1/comments # Delete comment id 2 curl -X DELETE http://100.77.82.18:3700/todos/1/comments/2
GET /todos/:id response under comments, and are searched by ?q=.
Bulk update
Update multiple todos in a single request. Provide an ids array and an update
object with the fields to change. The same rules apply as a regular PATCH — only included fields are changed,
and priority is validated. IDs that don't exist are silently skipped.
Bulk update. Body: {"ids": [...], "update": {...}}. Returns the updated todos.
# Mark multiple tasks done curl -X PATCH http://100.77.82.18:3700/todos \ -H 'Content-Type: application/json' \ -d '{"ids": [1, 2, 3], "update": {"done": true}}' # Assign several tasks to someone and set priority curl -X PATCH http://100.77.82.18:3700/todos \ -H 'Content-Type: application/json' \ -d '{"ids": [4, 5], "update": {"assignee": "alice", "priority": "high"}}' # Add the same tag to a group of todos curl -X PATCH http://100.77.82.18:3700/todos \ -H 'Content-Type: application/json' \ -d '{"ids": [1, 2, 3, 4], "update": {"tags": ["sprint-7"]}}'
Search
Use ?q= on GET /todos to do a case-insensitive full-text search across
task text, tags, and comment bodies. Combine with other filters.
# Find anything mentioning "invoice" curl "http://100.77.82.18:3700/todos?q=invoice" # Search within a tag and assignee curl "http://100.77.82.18:3700/todos?q=deploy&tag=work&assignee=franz" # Find tasks where a comment mentions "blocked" curl "http://100.77.82.18:3700/todos?q=blocked"
Habit strength
Any repeating task automatically tracks a habit strength score (0–100) that
estimates how deeply the habit is formed based on your completion history.
habitStrength is included on every repeating todo in GET responses — there is no
separate endpoint; just read the task.
API access
# Get habit strength for one task curl http://100.77.82.18:3700/todos/3 # Extract just the score with jq curl http://100.77.82.18:3700/todos/3 | jq '.habitStrength' # List all habits with their scores (repeating tasks only) curl http://100.77.82.18:3700/todos | jq '[.[] | select(.repeat != null) | {id, text, habitStrength}]' # Sort all habits by score descending curl http://100.77.82.18:3700/todos | jq '[.[] | select(.repeat != null)] | sort_by(-.habitStrength.score)'
Response shape
{
"habitStrength": {
"score": 79, // 0–100
"label": "Strong", // human-readable band
"streak": 6, // consecutive completions ending with the last period
"completionRate": 83, // % of past periods completed (integer)
"totalCompletions": 26 // raw count of all logged completions
}
}
habitStrength is absent on non-repeating todos. Check for its presence before reading it.
Score bands
| Score | Label | What it means |
|---|---|---|
| 0–4 | Not started | No completions logged yet |
| 5–19 | Forming | Early repetitions; the habit is very fragile and easy to drop |
| 20–39 | Building | A pattern is emerging but it still requires conscious effort |
| 40–59 | Established | Reliably happening most of the time; misses are the exception |
| 60–79 | Strong | Consistent and resilient; survives travel, illness, or schedule disruption |
| 80–100 | Automatic | Deeply ingrained; happens with low conscious effort |
How the score is calculated
The model is inspired by Hebbian learning (synaptic reinforcement through repetition) and Ebbinghaus's forgetting curve (strength decays during disuse). It has three components:
- Period evaluation. Time is divided into fixed intervals from the task's creation date. Each completed interval period is scored 1; each missed period is scored 0. The current in-progress period is always excluded — you are never penalised for a period that hasn't ended. A completion counts for the period it falls in, regardless of time of day.
- Recency weighting. Recent periods are weighted exponentially more than older ones (half-life = 5 periods). Your last ~10 completions matter far more than what happened months ago, reflecting that current neural pathway strength is determined by recent activation, not long-term history.
- Temporal decay. If the habit hasn't been done in a while, the score decays (half-life = 3 intervals). A single missed period is forgiven — decay only begins after one full interval of inactivity. This models synaptic weakening during disuse while keeping established habits resilient to minor disruptions.
Batch create
Create multiple todos in a single request. Body is either a plain JSON array or an object with an items array. All items are validated before any are saved — if one is invalid, none are created.
Body: array of todo objects (or {"items":[...]}). Returns array of created todos.
# Plain array curl -X POST http://100.77.82.18:3700/todos/batch \ -H 'Content-Type: application/json' \ -d '[ {"text": "Buy milk", "priority": "low"}, {"text": "Call dentist","priority": "high", "dueDate": "2026-07-01"}, {"text": "Read chapter","tags": ["personal"], "estimatedMinutes": 30} ]'
Snooze & postpone
Two ways to push a task into the future without manually editing dates.
Pushes dueDate forward by days, or to a specific until date.
For habits, also pushes nextDue forward.
Body: {"days": 3} or {"until": "2026-07-01"}.
For repeating tasks: skips the current occurrence — advances nextDue by one full interval
without logging a completion (so habit strength is unaffected).
For one-off tasks: pushes dueDate forward by 1 day.
No body required.
# Snooze a task for 3 days curl -X POST http://100.77.82.18:3700/todos/4/snooze \ -H 'Content-Type: application/json' \ -d '{"days": 3}' # Snooze until a specific date curl -X POST http://100.77.82.18:3700/todos/4/snooze \ -H 'Content-Type: application/json' \ -d '{"until": "2026-08-01"}' # Skip today's occurrence of a daily habit (won't affect streak or habit strength) curl -X POST http://100.77.82.18:3700/todos/5/postpone
Duplicate
Creates an exact copy of a todo with a fresh id, createdAt, and reset runtime state
(done: false, empty timeLog, completionLog, and comments).
Copies: text, tags, priority, color, assignee, dates, repeat, links, dependsOn, estimatedMinutes.
No body required. Returns the new todo.
curl -X POST http://100.77.82.18:3700/todos/3/duplicate
Templates
Save a set of todo field defaults as a named template. Instantiate it later to create a new todo, optionally overriding any field. Useful for recurring project setups, meeting prep, or any task type you create often.
List all saved templates.
Create a template. name is required. Any todo field can be included as a default.
Delete a template.
Create a todo from a template. Body fields override the template defaults. text must come from the template or the body.
# Save a template for weekly reviews curl -X POST http://100.77.82.18:3700/templates \ -H 'Content-Type: application/json' \ -d '{ "name": "Weekly review", "text": "Weekly review", "tags": ["work"], "priority": "high", "estimatedMinutes": 60 }' # Instantiate it with a due date override curl -X POST http://100.77.82.18:3700/templates/1/instantiate \ -H 'Content-Type: application/json' \ -d '{"dueDate": "2026-06-30"}' # Template with no text (must be supplied at instantiation) curl -X POST http://100.77.82.18:3700/templates \ -H 'Content-Type: application/json' \ -d '{"name":"Bug report","priority":"high","tags":["bug"],"estimatedMinutes":30}' curl -X POST http://100.77.82.18:3700/templates/2/instantiate \ -H 'Content-Type: application/json' \ -d '{"text":"Login button broken on mobile"}'
Daily streak
Tracks how many consecutive calendar days you've completed at least one task. Updated automatically whenever any todo is marked done or a repeating task is logged. The streak breaks if a full day passes with nothing completed.
Returns the current streak state. No params.
curl http://100.77.82.18:3700/streak
{
"currentStreak": 7, // consecutive days with at least one completion
"longestStreak": 14, // all-time best
"lastCompletedDate": "2026-06-22" // YYYY-MM-DD of most recent completion
}
completedAt data.
Sorting
Add ?sort= to any GET /todos request. Combine with ?order=asc (default) or ?order=desc.
| Field | Notes |
|---|---|
priority | Sorted by rank: urgent > high > medium > low > unset. Use desc to get urgent first. |
dueDate | Chronological. Nulls sort last. |
createdAt | Chronological. |
startDate | Chronological. |
completedAt | Chronological. Nulls sort last. |
nextDue | Chronological. Useful for habit dashboards. |
estimatedMinutes | Numeric. |
totalMinutes | Numeric (computed, includes active timer). |
text | Alphabetical (locale-aware). |
# Most urgent tasks first curl "http://100.77.82.18:3700/todos?sort=priority&order=desc" # Earliest due date first curl "http://100.77.82.18:3700/todos?sort=dueDate&order=asc" # Combine with filters curl "http://100.77.82.18:3700/todos?tag=work&sort=priority&order=desc"
Pagination
GET /todos always returns a {total, limit, offset, items} envelope.
Without limit, all matching items are returned and limit is null.
| Param | Default | Description |
|---|---|---|
limit | none | Maximum number of items to return |
offset | 0 | Number of items to skip |
# First page of 10 curl "http://100.77.82.18:3700/todos?limit=10&offset=0" # Second page curl "http://100.77.82.18:3700/todos?limit=10&offset=10" # Combine with sort and filter curl "http://100.77.82.18:3700/todos?sort=dueDate&limit=5&tag=work"
total always reflects the count of
matched items before slicing — not the total todos in the database.
GET /due
Returns everything demanding attention right now — overdue items and tasks due today — sorted by priority within each group.
This is the recommended morning view: open /due, work through it top to bottom.
No parameters. Returns a structured object with two groups.
curl http://100.77.82.18:3700/due
{
"overdue": [...], // dueDate past, or nextDue past for habits — sorted by priority
"dueToday": [...], // due today but not yet overdue — sorted by priority
"total": 5 // overdue.length + dueToday.length
}
/due.
GET /stats
A single endpoint covering everything useful for a dashboard or weekly review.
No parameters. Always returns the full stats object.
curl http://100.77.82.18:3700/stats
{
"overview": {
"total": 10, // all todos
"done": 3, // currently marked done
"pending": 7, // not done
"overdue": 2 // overdue and not done
},
"streak": {
"currentStreak": 7,
"longestStreak": 14,
"lastCompletedDate": "2026-06-22"
},
"completions": {
"today": 2, // completions logged today (one-offs + habit log entries)
"thisWeek": 8, // last 7 days
"thisMonth": 30 // last 30 days
},
"time": {
"totalTrackedMinutes": 240, // sum of all timeLog sessions
"averageEstimatedMinutes": 45 // mean estimatedMinutes across todos that have it
},
"byPriority": {
"urgent": { "total": 1, "done": 0 },
"high": { "total": 3, "done": 1 },
"medium": { "total": 4, "done": 2 },
"low": { "total": 1, "done": 0 },
"none": { "total": 1, "done": 0 }
},
"byTag": {
"work": { "total": 5, "done": 2 }
},
"byAssignee": {
"franz": { "total": 6, "done": 3 }
},
"habits": [
{
"id": 3,
"text": "Morning run",
"repeat": "daily",
"score": 79,
"label": "Strong",
"streak": 6,
"completionRate": 83,
"totalCompletions": 26
}
]
}
GET /momentum
Shows whether you're speeding up or slowing down — this week vs last week, and this month vs last month. No points, no badges; just a clear signal about your trajectory so you can course-correct before a slump becomes a habit.
No parameters. Counts all completions (one-off tasks + habit log entries) across time windows.
curl http://100.77.82.18:3700/momentum
{
"thisWeek": 8, // completions in the last 7 days
"lastWeek": 5, // completions in the 7 days before that
"weeklyTrend": 60, // % change: positive = more done, negative = less done
"thisMonth": 30, // last 30 days
"lastMonth": 20, // 30 days before that
"monthlyTrend": 50, // % change
"verdict": "accelerating" // see below
}
Verdict values:
| Verdict | Meaning |
|---|---|
accelerating | Weekly completions up 20% or more vs last week |
steady | Weekly completions within ±20% of last week |
slowing | Weekly completions down 20% or more vs last week |
stalled | Zero completions this week |
weeklyTrend is 100 when last week was 0 and this week is non-zero (any progress from nothing).
It is 0 when both weeks are 0.
GET /next
Returns a ranked list of tasks to work on right now. Call it when you open your task list and don't want to think — the server does the prioritisation for you. Designed to eliminate choice paralysis.
The algorithm combines nine evidence-based signals:
| Signal | Weight | Science |
|---|---|---|
| Time criticality | 25% | Temporal Motivation Theory (Steel & König, 2006) — hyperbolic urgency decay (75 / (1 + d×0.5)). Overdue tasks escalate; far deadlines feel correctly distant. Hard deadlines get a ×1.4 multiplier within 3 days; soft deadlines get ×0.75. |
| Priority | 20% | Your explicit priority label (urgent → 100, high → 75, medium → 50, low → 25). Fast shortcut to surface what you've already decided is critical. |
| Habit health | 13% | Weak habits need more consistent reinforcement (BJ Fogg). Active streaks trigger loss aversion (Kahneman) — breaking them hurts more than missing a one-off. Score: 60% reinforcement need + 40% streak protection. |
| Risk reduction | 10% | WSJF blocking chain — BFS counts tasks transitively unblocked. One task that unblocks three others is worth doing early. |
| Importance | 10% | Long-term value separate from urgency (Eisenhower Matrix). high = 100, medium = 60, low = 25. Set importance on strategic work that might otherwise get crowded out by short-term urgency. |
| Zeigarnik effect | 7% | Zeigarnik (1927) — incomplete tasks occupy working memory. Tasks with an active timer score 100; tasks with any time logged score 50. Finishing them restores focus. |
| Staleness | 7% | Log-scale age bonus (log1p(ageDays) / log1p(90)) so deadline-free tasks eventually surface, without overriding urgent work. |
| Quick wins | 4% | Progress Principle (Amabile & Kramer, 2011) — small wins are the #1 driver of positive inner work life. ≤5 min → 100, ≤15 → 80, ≤30 → 50, ≤60 → 20. |
| Time-of-day match | 4% | Chronobiology — matching task cognitive demand to your peak hours. Tasks matching ?timeOfDay= score 100; mismatches score 0. |
A WSJF job-size efficiency modifier is applied after the weighted sum: shorter tasks get a small boost
((15 / estimatedMinutes)^0.25), so a 15-min task beats a 4-hour task of otherwise equal score.
Defer adjustment (applied last, outside the weighted sum): tasks deferred 1–4 times lose 3 points each defer. After 5 defers the penalty flips to +4 points per defer — the algorithm escalates tasks you've been systematically avoiding.
Returns ranked recommendations. All parameters are optional.
| Parameter | Example | Description |
|---|---|---|
limit | ?limit=1 | How many recommendations to return. Default 3, max 20. |
maxMinutes | ?maxMinutes=30 | Only consider tasks that fit within this time budget. |
minMinutes | ?minMinutes=60 | Only consider tasks at least this long (useful for deep-work sessions). |
tag | ?tag=work | Only consider tasks with at least one of these tags (comma-separated). |
assignee | ?assignee=franz | Only tasks assigned to this person. |
role | ?role=dev-team | Only tasks assigned to this role. |
category | ?category=frontend | Only tasks in this category. |
energy | ?energy=low | Filter by energy level you have available. Inclusive downward: ?energy=medium shows medium and low tasks, never high. Tasks with no energyRequired always appear. |
context | ?context=@computer | Filter to tasks matching this context. Tasks with no context set always appear. |
timeOfDay | ?timeOfDay=morning | Filter out tasks set to a different preferred time, and give a scoring bonus to tasks that match. Values: morning, afternoon, evening. |
exclude | ?exclude=5,12 | Skip these task IDs and increment their deferCount. Use when the top recommendation isn't suitable right now. |
Tasks that are done, blocked, or have a future startDate are automatically excluded from consideration.
# What should I work on right now? curl "http://100.77.82.18:3700/next" # I have 20 minutes — what fits? curl "http://100.77.82.18:3700/next?maxMinutes=20&limit=3" # Deep work session — work tasks only curl "http://100.77.82.18:3700/next?tag=work&minMinutes=60" # Not feeling task #3 — what else? curl "http://100.77.82.18:3700/next?exclude=3" # Low energy afternoon — filter by context and energy curl "http://100.77.82.18:3700/next?energy=low&timeOfDay=afternoon" # At computer, morning session — match context and time curl "http://100.77.82.18:3700/next?context=@computer&timeOfDay=morning&minMinutes=30"
{
"recommendations": [
{
"score": 72,
"reasons": ["Urgent priority", "Overdue by 2 days", "Unblocks 3 tasks"],
"todo": { /* full todo object */ }
},
{
"score": 51,
"reasons": ["Timer running — close the loop", "High priority"],
"todo": { /* ... */ }
}
],
"meta": {
"candidates": 14, // tasks that passed all filters
"skipped": {
"done": 5,
"blocked": 2,
"futureStart": 1,
"timeBudget": 0,
"excluded": 0
},
"algorithm": "WSJF + Temporal Motivation Theory + Habit Health + Zeigarnik Effect + Progress Principle"
}
}
score is 0–100. The reasons array tells you exactly why each task ranked where it did,
so the recommendation is always explainable, not a black box.
Webhooks
Register a URL to receive a POST request whenever something happens in the API.
Webhooks are fired asynchronously — they never delay or fail the API response.
Endpoints
List all registered webhooks.
Register a webhook. Body: {"url": "...", "events": [...]}.
Remove a webhook.
Events
| Event | Fired when |
|---|---|
todo.created | A new todo is created |
todo.updated | A todo is modified (any field change) |
todo.completed | A non-repeating todo is marked done |
todo.repeated | A repeating task is marked done (schedule advances) |
todo.deleted | A todo is deleted |
timer.started | A timer is started on a todo |
timer.stopped | A running timer is stopped |
comment.added | A comment is posted on a todo |
* | Wildcard — receive all events |
Payload
{
"event": "todo.completed",
"timestamp": "2026-06-22T09:00:00.000Z",
"data": { /* the full todo object, or {id} for deletions */ }
}
# Listen for all events curl -X POST http://100.77.82.18:3700/webhooks \ -H 'Content-Type: application/json' \ -d '{"url": "http://your-laptop:9000/hook", "events": ["*"]}' # Only completion events curl -X POST http://100.77.82.18:3700/webhooks \ -H 'Content-Type: application/json' \ -d '{"url": "http://your-laptop:9000/hook", "events": ["todo.completed", "todo.repeated"]}' # List registered webhooks curl http://100.77.82.18:3700/webhooks # Remove webhook id 1 curl -X DELETE http://100.77.82.18:3700/webhooks/1
Errors
| Status | When |
|---|---|
400 | Missing required field, or invalid JSON body |
404 | Todo with that ID doesn't exist, or unknown path |
405 | HTTP method not allowed on that path |
All errors return {"error": "message"}.
Access
There are two ways to reach the API:
| Who | URL | Notes |
|---|---|---|
| Team members (any network) | https://franz2.tailf2d239.ts.net |
Public HTTPS via Tailscale Funnel. No VPN needed. Works from anywhere. |
| Internal / Tailscale peers | http://100.77.82.18:3700 |
Direct access over Tailscale. Requires tailscale up. |
Data is stored at /home/franz/todo-server/todos.json on franz2 and persists across server restarts.