Menu

Integrations and internals IntegrationsVersion 0.3.0

Command-line client (ot)

ot is a small, statically linked client for the OpenTodo REST API. It is built for two audiences: people who live in a terminal (ot add "Call the plumber" --due tomorrow, ot today) and scripts, cron jobs and editor integrations that want stable JSON output and exit codes instead of hand-written curl.

On this page

It is an online client: it keeps no local copy of your tasks and makes no change when the server cannot be reached. Every write goes through the REST API, which records it in the operation log, so changes made with ot reach your phone and browser on their next sync and converge under the same per-field last-writer-wins rule as any other edit.

Install#

Release assets are published for linux, macOS and Windows on amd64 and arm64 (ot-<os>-<arch>, with SHA256SUMS); download one, make it executable and put it on your PATH. Or build from source (Go 1.26+, no other dependency):

go install github.com/pixari/opentodo/cmd/ot@latest   # into $(go env GOPATH)/bin; or @vX.Y.Z for a release
# or, from a checkout:
make cli                                              # -> bin/ot

go install works from a clean GOPATH with the module path as declared (github.com/pixari/opentodo, lower case); an older local Go downloads the required toolchain itself. There is no Homebrew tap or Scoop bucket yet: release assets and go install are the supported ways (package manager manifests may follow later).

The server container image does not include ot; it belongs on the machine you type on.

Sign in#

ot login https://todo.example.com
Email: [email protected]
Password:
Signed in to https://todo.example.com as [email protected] (profile "default").
Token "cli@laptop" stored in /home/you/.config/opentodo/config.json.

ot login signs in with your email and password, creates an API token named cli@<hostname>, signs the session out again and stores only the token. The password is never echoed and never stored. You will see the token in Settings → Integrations → API tokens in the web app, where it can also be revoked.

Other ways to get credentials in:

Command Purpose
ot auth set-token <token> --server <url> Store a token you created in Settings or with ot token create
ot auth status Show the server, the account, the token name and its scope (for an agent token also whether assistant writes are allowed right now)
ot auth logout --yes Revoke the stored token on the server and remove it locally (asks on a terminal without --yes). A read or agent token cannot revoke itself: it is removed locally and you revoke it in Settings

Configuration file and profiles#

The config file lives in the platform's user configuration directory ($XDG_CONFIG_HOME/opentodo/config.json or ~/.config/opentodo/config.json on Linux, ~/Library/Application Support/opentodo/config.json on macOS, %AppData%\opentodo\config.json on Windows). It is written with owner-only permissions (0600, directory 0700).

{
  "default": "home",
  "profiles": {
    "home": { "url": "https://todo.example.com", "token": "ot_…" },
    "work": { "url": "https://tasks.corp.example", "token": "ot_…" }
  }
}

Pick a profile with --profile work or OPENTODO_PROFILE=work; ot login --profile work <url> creates one. Precedence, highest first:

  1. Flags: --server <url>
  2. Environment: OPENTODO_URL, OPENTODO_TOKEN (handy for cron and CI; no config file needed)
  3. The selected profile

OPENTODO_CONFIG=/path/to/config.json points at another config file. No command ever prints a stored token.

Tasks#

ot today                       # due today or earlier, overdue first
ot inbox                       # tasks without a project
ot upcoming                    # due after today
ot list                        # every open task, soonest first
ot list --project Home         # one project (name or id)
ot list --completed            # most recently completed first
ot list --all --limit 20       # open and completed

Human output is a compact table: an id prefix, priority, due date and time (overdue dates are marked and shown in red on a terminal), project and title. Dates are evaluated in your local time zone. A task fixed to a time zone (see sync.md) is shown converted to your zone with the original as a hint, e.g. 2026-10-11 00:00 (09:00 Berlin); ot edit --due/--time on it takes the date and time as shown and keeps the zone, and --time "" releases it to a floating date. Tasks of archived projects stay out of the cross-project views, as in the web app.

ID        P   DUE                 PROJECT  TITLE
8c2a91f0  P2  2026-10-08 overdue  Home     Pay rent
3f1d7a22  P1  2026-10-10 09:00             Call the plumber

Add and edit#

ot add "Call the plumber" --project Home --due tomorrow --time 09:00 --priority 2
ot add "Read the sync design" --label reading --label work --note "docs/sync.md"
ot edit 8c2a --priority 1 --due friday
ot edit 8c2a --due none --project none --note ""     # clear values
Flag Meaning
--project <name-or-id> Project; none (edit) moves the task to the Inbox
--due <when> YYYY-MM-DD, today, tomorrow or an English weekday name (next occurrence; monday on a Monday means next week); none (edit) clears the date
--time HH:MM Due time, 24-hour; with ot add and no --due the date is today
--priority 1..4 1 is highest, 4 is the default
--label <l> Repeatable; a label name (case-insensitive) or id. Unknown names create the label. With ot edit the given labels replace the list
--note <text> Description; --note "" clears it
--title <text> New title (ot edit only)
--idempotency-key <key> ot add only: a retry with the same key returns the same task, see Retry-safe creates

ot add prints the new task's id (the whole task with --json). Priority, time and date are validated locally before any request is sent.

Complete, reopen, delete#

ot done 8c2a 3f1d          # one or more references
ot reopen 8c2a
ot rm 8c2a                 # asks on a terminal
ot rm 8c2a --yes           # required off a terminal and with --json

A <ref> is a full task id or an id prefix that matches exactly one non-deleted task. An ambiguous prefix changes nothing, exits with code 2 and lists the candidates; with several references all are resolved before anything is changed. Scripts should use full ids from --json. ot rm is a soft delete: the task becomes a tombstone that still appears in the export.

A task that is already in the requested state (ot done on a completed task, ot reopen on an open one) is reported as unchanged, keeps its completion time and nothing is sent; the command still exits 0. ot edit with values equal to the current ones sends nothing either.

Projects#

ot project list [--all]                 # --all includes archived projects
ot project add "Garden" [--color "#22c55e"] [--idempotency-key <key>]
ot project rename Garden "Back garden"
ot project archive Garden [--undo]
ot project rm Garden [--yes]

Projects are referenced by name (case-insensitive), full id or unique id prefix.

Tokens and export#

--scope is full (the default), read or agent; anything else exits with code 2 before a request is sent. An agent token is for an AI assistant: it reads everything and can create and edit tasks, projects, labels and comments (through the MCP write tools or ot, see Using ot from an agent), and only while both assistant switches are on (the owner's and yours, in Settings → AI → AI assistants).

ot token list                           # with each token's scope
ot token create backup-script           # prints the secret once, with a notice on stderr
ot token create reporting --scope read  # read-only: refused on every change
ot token create desk-assistant --scope agent   # for an AI assistant that may make changes
ot token revoke backup-script --yes     # by name, id or id prefix
ot export                               # full JSON export (including tombstones) to stdout
ot export --out opentodo-$(date +%F).json

Scripting: JSON output and exit codes#

Every command accepts --json. Stdout then holds only JSON, with the same field names as the REST API: an array for lists, the entity for single results, and the error envelope on failure. The shapes are documented as JSON Schemas with an output_version (currently 1, reported by ot version --json and ot schema). Within a version fields are only added; a breaking change would need a new version you opt into.

Command --json output (schema)
list, today, inbox, upcoming array of task
add, edit task
done, reopen, rm task_result (one reference) or an array of them: the task plus ref (what you typed) and status (completed, reopened, deleted or unchanged)
project list / add, rename, archive, rm array of project / project
token list / create / revoke array of token / token_created ({token, secret}) / token
any write command with --dry-run dry_run
any failure error: {"error":{"code":"…","message":"…","exit":N}}

On failure the envelope goes to stdout as before (server envelopes keep their code and message; exit is added) and stderr gets one line starting with ot:, so harnesses that only show stderr see what went wrong. Without --json errors go to stderr only.

ot list --json --today | jq -r '.[] | "\(.priority) \(.title)"'
ot add "Nightly backup check" --json | jq -r .id

Exit codes:

Code Meaning What a script should do
0 Success, including "already in that state" continue
1 The server rejected the request or failed (its message is printed verbatim) read error.code; retry only internal or rate_limited
2 Usage or local validation error (bad flag, invalid priority or date, ambiguous reference, missing configuration, a destructive command without --yes) fix the command
3 Authentication or configuration error (wrong password, revoked token, unreadable config file) sign in again
4 The server could not be reached within the timeout (15 s) retry later
5 Not permitted: insufficient_scope, forbidden, not_permitted, writes_disabled_by_owner, writes_disabled_by_user, encryption_enabled or read_only do not retry; change the token, the switch or the role

Machine-readable help: ot schema#

ot schema (or ot help --json) prints every command with its arguments, flags and their types, whether it writes or is destructive, whether it supports --dry-run and --idempotency-key, its output schema, the exit codes, the error codes and every JSON Schema. It is generated from the same table as ot help, needs no configuration and no server, and exits 0 offline.

ot schema | jq '.commands[] | select(.writes) | .name'
ot schema | jq '.schemas.task'

Non-interactive by default#

Without a terminal on stdin and stdout, ot never prompts and colour is off (also with NO_COLOR). The destructive commands ot rm, ot project rm, ot token revoke and ot auth logout then need --yes, and so do they with --json even on a terminal; without it they exit 2 with code confirmation_required before sending any request. On a terminal without --json they ask. ot login reads the password from stdin when stdin is not a terminal.

Dry run#

Every command that writes accepts --dry-run. It resolves references and current values with reads (so an ambiguous prefix still exits 2), validates, and prints the requests it would send and the resolved targets, then exits as a real run would. Nothing that writes is sent, nothing changes on the server or on any device, and the config file is not touched. An unreachable server exits 4.

ot done 8c2a 91f0 --dry-run --json
# {"command":"done","dry_run":true,"requests":[{"method":"PATCH","path":"/api/v1/tasks/8c2a…","body":{"completed_at":1760…}},…],
#  "targets":[{"ref":"8c2a","id":"8c2a…","title_preview":"Pay rent","status":"completed"},…]}

A label that ot add --label would create appears as a planned POST /api/v1/labels, and the planned task refers to it as (planned-1). ot login --dry-run shows the three requests of a sign-in without prompting for the password.

Retry-safe creates#

ot add and ot project add take --idempotency-key <key> (1 to 128 characters of letters, digits and . _ : -), sent as the Idempotency-Key header. Running the same command again with the same key prints the same entity and creates nothing; the same key with different content exits 1 with idempotency_conflict. Because the ids derive from the key, a retry after a timeout converges to one task on every device.

ot add "Buy milk" --idempotency-key run42-milk --json    # creates
ot add "Buy milk" --idempotency-key run42-milk --json    # same task, nothing new

Read-only mode#

--read-only, or the optional OPENTODO_READ_ONLY=1, makes every write command fail before any request with code read_only and exit 5; reads work as usual. With a read token, ot refuses writes itself (exit 5, insufficient_scope): it learns the token's scope from GET /api/v1/me once per run, before its first write. With an agent token it refuses locally what the server would refuse (for example ot project archive or ot token create); the server checks again regardless.

Cron example#

A nightly export kept for 30 days, using environment variables instead of a config file:

# m h dom mon dow  command
0 3 * * *  OPENTODO_URL=https://todo.example.com OPENTODO_TOKEN=ot_… /usr/local/bin/ot export --out /backups/opentodo-$(date +\%F).json && find /backups -name 'opentodo-*.json' -mtime +30 -delete

Or add a reminder task every Monday morning:

0 8 * * 1  OPENTODO_URL=… OPENTODO_TOKEN=… /usr/local/bin/ot add "Weekly review" --due today --time 17:00 --priority 2 >/dev/null

Limits#

  • Online only: no offline queue, no local cache. Offline capture is what the web app and PWA are for.
  • --due understands a fixed set of words; the natural-language quick-add grammar lives in the web client.
  • Rate limits on auth/login apply (10 per minute per address); ot auth set-token avoids them entirely.
  • No shell completion scripts yet (ot schema has everything a completion script needs).

Using ot from an agent#

ot is a dependable tool for coding agents and automation harnesses. The short version, which the skill file below spells out for the agent itself:

  1. Give the agent an agent token, not a full one: ot token create "my assistant" --scope agent, then on the agent's machine ot auth set-token <token> --server <url>. Turn on AI assistants may make changes (owner) and Allow AI assistants to make changes (you) in Settings → AI → AI assistants. With that token ot can list everything and create and edit tasks, projects, labels and comments, complete and reopen tasks, and move to the Trash only tasks the same token created. Writes carry the device agent:<token-id>, show in history as "AI assistant (token name)", are audited as agent.write and share the token's limits with MCP. Tokens, settings, sharing, members, encryption, imports and backups stay out of reach (exit 5). ot auth status shows the scope and whether writes are allowed right now. A full token works too, with everything that implies; a read token for an agent that only looks.
  2. Use --json everywhere, and ot schema to discover commands and output shapes.
  3. Use --dry-run before a write that touches more than one task, and --idempotency-key on creates.
  4. Handle exit codes: stop and ask on 3, do not retry 5, retry 4 later.
  5. Off a terminal, destructive commands need --yes.
  6. --read-only (or OPENTODO_READ_ONLY=1) for an agent that must never change anything, whatever its token allows.

The agent skill file#

ot skill prints SKILL.md, a skill file that teaches an agent to use ot carefully: read first, always --json, preview with --dry-run, retry-safe creates, stop on exit 5, never run token, auth or export commands unless asked, and treat task titles, descriptions and comments as untrusted data rather than instructions. It is embedded in the binary, so it always matches the version you run, and every release also ships it as opentodo-agent-skill.zip (a folder opentodo-cli/ with SKILL.md) next to the ot-<os>-<arch> binaries. Unpack it where your agent harness looks for skills, or save the output of ot skill.

Migrating scripts#

Two changes in this version can affect existing scripts:

  • --yes off a terminal. ot rm, ot project rm, ot token revoke and ot auth logout used to proceed silently when stdout was not a terminal or --json was given. They now exit 2 without --yes. Add --yes to scripts that delete on purpose.
  • Exit code 5. A refusal for scope, role, switch or encryption (for example 403 insufficient_scope) used to exit 1 and now exits 5. Scripts that test for "non-zero" are unaffected; scripts that test for exactly 1 should also accept 5.

--json errors also gained the exit field and a stderr line, and ot done/ot reopen/ot rm add ref and status to each printed task; both are additive.

Edit this page on GitHub