Todo API

franz2 · port 3700

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

NetworkBase URL
Public internet (team members)https://franz2.tailf2d239.ts.net
Tailscale network (internal)http://100.77.82.18:3700

Endpoints

List all todos

GET/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

GET/todos/:id
curl http://100.77.82.18:3700/todos/1

Create a todo

POST/todos

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

PATCH/todos/:id

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

DELETE/todos/:id

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:

ParameterExampleDescription
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

FieldTypeWritableDescription
idnumbernoAuto-assigned integer, never reused
textstringyesThe todo description. Required on create.
donebooleanyesWhether completed. On repeating tasks, setting true advances the schedule and resets to false.
createdAtISO 8601noCreation timestamp (UTC)
parentIdnumber | nullyesID of a parent todo. null = root-level.
tagsstring[]yesArbitrary labels. PATCH replaces the whole array.
dependsOnnumber[]yesIDs of todos that must be done before this one. PATCH replaces the whole array.
blockedbooleannoComputed. true if any dependsOn item is not yet done.
overduebooleannoComputed. true when not done and either dueDate is in the past, or nextDue (for habits) is before now.
repeatstring | number | nullyes"daily", "weekly", "monthly", or a number of days. null disables repeating.
nextDueISO 8601 | nullnoNext due date for repeating tasks — advanced automatically when marked done.
assigneesstring[]yesNames/identifiers of people responsible. PATCH replaces the whole array.
rolestring | nullyesName of the role responsible (e.g. "dev-team"). See Roles.
categorystring | nullyesFree-form category label (e.g. "frontend", "admin").
importance"low" | "medium" | "high" | nullyesLong-term value of the task — separate from urgency. Used to implement the Eisenhower matrix in GET /next.
energyRequired"low" | "medium" | "high" | nullyesCognitive/physical energy this task demands. Used by GET /next?energy= to match tasks to your current capacity.
contextstring | nullyesWhere or what you need to do this task. Free-form: "@computer", "@phone", "@home", "@errands". Filtered by GET /next?context=.
preferredTime"morning" | "afternoon" | "evening" | nullyesBest time of day for this task. Matched against GET /next?timeOfDay= — tasks at their preferred time get a scoring bonus.
deadlineType"hard" | "soft" | nullyesWhether the deadline is fixed (hard) or flexible (soft). Hard deadlines amplify urgency within 3 days; soft deadlines reduce it.
deferCountnumbernoHow 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.
customFieldsobjectyesArbitrary key-value metadata. PATCH merges new keys into the existing object.
startDatedate string | nullyesWhen work on this task should begin. Informational. Recommended format: YYYY-MM-DD.
dueDatedate string | nullyesDeadline for the task. Informational. Recommended format: YYYY-MM-DD.
prioritystring | nullyes"low", "medium", "high", or "urgent". Validated on write.
estimatedMinutesnumber | nullyesHow long the task is expected to take, in minutes.
colorstring | nullyesFree-form color label (e.g. "red", "#3498db"). Interpreted by the client.
completedAtISO 8601 | nullnoSet automatically when done is set to true. Cleared when unmarked. On repeating tasks, updated each time done is marked.
linksobject[]via sub-resourceAttached URLs. Each entry: {id, url, label}. Managed via /links endpoints.
commentsobject[]via sub-resourceNotes added over time. Each entry: {id, text, createdAt}. Managed via /comments endpoints.
timeLogobject[]via sub-resourceTimer sessions. Each entry: {id, startedAt, stoppedAt, minutes}.
activeTimerobject | nullvia sub-resource{startedAt} when a timer is running, otherwise null.
totalMinutesnumbernoComputed. Sum of all timeLog minutes plus elapsed time from any active timer.
completionLogISO 8601[]noTimestamps of every past completion. Managed automatically; used to compute habit strength.
habitStrengthobject | absentnoComputed. 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.

ValueRecurs 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}'
The server does not push notifications or auto-reset tasks on a schedule. 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.

GET/roles

List all roles.

POST/roles

Create a role. Body: {"name": "dev-team", "members": ["franz", "alice"]}

PATCH/roles/:id

Update a role. Accepts name, members (replace array), addMember, removeMember.

DELETE/roles/:id

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:

GET /next?exclude= also increments deferCount for the excluded IDs, so the act of skipping a recommendation is automatically tracked.

POST/todos/:id/defer

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.

FieldPurpose
startDateWhen work on the task should begin ("do date")
dueDateThe 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=.

ValueMeaning
"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.

POST/todos/:id/timer/start

Starts a timer. Returns 400 if a timer is already running.

POST/todos/:id/timer/stop

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
}

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.

POST/todos/:id/links

Adds a link. Body: {"url": "...", "label": "..."}. label is optional.

DELETE/todos/:id/links/:linkId

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.

GET/todos/:id/comments

Returns the comment array for this task.

POST/todos/:id/comments

Adds a comment. Body: {"text": "..."}.

DELETE/todos/:id/comments/:commentId

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
Comments are also included in the main 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.

PATCH/todos

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"]}}'

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

ScoreLabelWhat it means
0–4Not startedNo completions logged yet
5–19FormingEarly repetitions; the habit is very fragile and easy to drop
20–39BuildingA pattern is emerging but it still requires conscious effort
40–59EstablishedReliably happening most of the time; misses are the exception
60–79StrongConsistent and resilient; survives travel, illness, or schedule disruption
80–100AutomaticDeeply 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:

  1. 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.
  2. 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.
  3. 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.

POST/todos/batch

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.

POST/todos/:id/snooze

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"}.

POST/todos/:id/postpone

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.

POST/todos/:id/duplicate

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.

GET/templates

List all saved templates.

POST/templates

Create a template. name is required. Any todo field can be included as a default.

DELETE/templates/:id

Delete a template.

POST/templates/:id/instantiate

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.

GET/streak

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
}
The streak is only tracked from the moment you start completing tasks — it does not backfill from historical completedAt data.

Sorting

Add ?sort= to any GET /todos request. Combine with ?order=asc (default) or ?order=desc.

FieldNotes
prioritySorted by rank: urgent > high > medium > low > unset. Use desc to get urgent first.
dueDateChronological. Nulls sort last.
createdAtChronological.
startDateChronological.
completedAtChronological. Nulls sort last.
nextDueChronological. Useful for habit dashboards.
estimatedMinutesNumeric.
totalMinutesNumeric (computed, includes active timer).
textAlphabetical (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.

ParamDefaultDescription
limitnoneMaximum number of items to return
offset0Number 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"
Pagination is applied after filtering and sorting, so 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.

GET/due

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
}
Within each group, urgent tasks appear first, then high, medium, low, then unset. Done tasks never appear in /due.

GET /stats

A single endpoint covering everything useful for a dashboard or weekly review.

GET/stats

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.

GET/momentum

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:

VerdictMeaning
acceleratingWeekly completions up 20% or more vs last week
steadyWeekly completions within ±20% of last week
slowingWeekly completions down 20% or more vs last week
stalledZero 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:

SignalWeightScience
Time criticality25%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.
Priority20%Your explicit priority label (urgent → 100, high → 75, medium → 50, low → 25). Fast shortcut to surface what you've already decided is critical.
Habit health13%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 reduction10%WSJF blocking chain — BFS counts tasks transitively unblocked. One task that unblocks three others is worth doing early.
Importance10%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 effect7%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.
Staleness7%Log-scale age bonus (log1p(ageDays) / log1p(90)) so deadline-free tasks eventually surface, without overriding urgent work.
Quick wins4%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 match4%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.

GET/next

Returns ranked recommendations. All parameters are optional.

ParameterExampleDescription
limit?limit=1How many recommendations to return. Default 3, max 20.
maxMinutes?maxMinutes=30Only consider tasks that fit within this time budget.
minMinutes?minMinutes=60Only consider tasks at least this long (useful for deep-work sessions).
tag?tag=workOnly consider tasks with at least one of these tags (comma-separated).
assignee?assignee=franzOnly tasks assigned to this person.
role?role=dev-teamOnly tasks assigned to this role.
category?category=frontendOnly tasks in this category.
energy?energy=lowFilter 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=@computerFilter to tasks matching this context. Tasks with no context set always appear.
timeOfDay?timeOfDay=morningFilter out tasks set to a different preferred time, and give a scoring bonus to tasks that match. Values: morning, afternoon, evening.
exclude?exclude=5,12Skip 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

GET/webhooks

List all registered webhooks.

POST/webhooks

Register a webhook. Body: {"url": "...", "events": [...]}.

DELETE/webhooks/:id

Remove a webhook.

Events

EventFired when
todo.createdA new todo is created
todo.updatedA todo is modified (any field change)
todo.completedA non-repeating todo is marked done
todo.repeatedA repeating task is marked done (schedule advances)
todo.deletedA todo is deleted
timer.startedA timer is started on a todo
timer.stoppedA running timer is stopped
comment.addedA 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
The server does not retry failed webhook deliveries. If your endpoint is down, the event is lost. Webhook failures are silently ignored so they never affect API responses.

Errors

StatusWhen
400Missing required field, or invalid JSON body
404Todo with that ID doesn't exist, or unknown path
405HTTP method not allowed on that path

All errors return {"error": "message"}.

Access

There are two ways to reach the API:

WhoURLNotes
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.