Docs
Commands

Commands

Every command that reads or writes Things accepts -j / --json for structured output, as do config path and config show; open and log print nothing on success. version, completions, config init, update and the skill commands print plain text whatever you pass, though their errors are JSON. Run things --help or things <subcommand> --help for the full flag list. The one flag it leaves out is --include-completed on the listings, which is retired and kept only so existing scripts keep working; it is described under --open-only below.

In JSON, status, type and start are string enums rather than the raw Things integers. status is "open", "completed" or "cancelled", and appears on tasks, projects and checklist items. type is "task" or "project", and appears on task rows only — projects, areas and tags rows carry no type. Headings are never returned by any command, so the third Things type never reaches the output.

start is "inbox", "anytime" or "someday", and appears on task and project rows. It is the list an item falls back to when it carries no date, so it does not on its own say which list the app shows the item in: a dated "anytime" row is in Today, a "someday" row dated after today is in Upcoming, and only an undated one is in Someday. Just after midnight, Things can leave an open task scheduled for the new day in Someday until it next tidies up; the app already shows it as today’s, so it reports "anytime", as it will once Things moves it. An undated open task or project in the Inbox, Anytime or Someday whose deadline is today or past is in Today and Anytime, and leaves the Inbox or Someday, unless Things took it out of Today for that deadline. It still reports the start it was filed with.

In v0.7.0 and earlier type and start were both integers, so a caller matching on .type==1 has to become .type=="project", and one matching on .start==2 has to become .start=="someday". startBucket alongside them is still an integer: 1 is the app’s This Evening section and 0 is everything else. Only the first of those has a name in Things’ own vocabulary, so naming the pair would have meant inventing a word for 0.

The type values a listing reports are not the ones an import payload takes: that payload is Things’ own JSON URL scheme, which spells a task "to-do". The payload is the one place these pages use Things’ word rather than the CLI’s, because it is passed through untouched. Do not copy .type from a listing into an import item.

Choosing fields with --fields

A JSON listing prints a default set of keys for each row: the ones needed to pick an item and act on it. Notes, the parent uuids and Things’ own bookkeeping are left out, since notes alone can be half of a full listing. --fields names the keys you want instead, in the order you want them, and --fields all prints every key. -f is the short form of --fields on the task listings, search and projects:

things anytime -a "Personal Projects" -j --fields uuid,title,projectTitle,deadline,tags
things projects -j --fields uuid,title,openCount
things today -j -f uuid,title
things anytime -j --fields all
things show <uuid> -j    # one item's full record, notes and checklist included

It works on every task listing (the views, list with filters, and search) and on things projects. things show always prints the full record, and areas and tags rows are short enough not to need it.

  • Without --fields, a task row carries these keys: uuid, title, type, status, start, startBucket, startDate, reminderTime, deadline, stopDate, projectTitle, projectTrashed, areaTitle, headingTitle, tags, repeating, checklistProgress. A project row from things projects carries these keys: uuid, title, status, start, startDate, deadline, areaTitle, tags, taskCount, openCount.
  • --fields all prints the full record. all takes no other names: all,notes fails.
  • The names are the JSON keys exactly as the full output prints them, case included: projectTitle, not project. There are no aliases.
  • A task listing takes the task keys: uuid, title, notes, type, status, start, startBucket, startDate, reminderTime, deadline, stopDate, creationDate, trashed, projectUUID, projectTitle, projectTrashed, areaUUID, areaTitle, headingUUID, headingTitle, tags, index, todayIndex, repeating, checklistProgress. things projects takes the project keys: uuid, title, status, start, startBucket, startDate, deadline, areaUUID, areaTitle, tags, taskCount, openCount.
  • A key a row would leave out of the full record, such as deadline on a task with none, is left out here too. The values are unchanged.
  • Spaces around names are ignored, and a name given twice is printed once, where it first appears.
  • An unknown name fails the whole command before anything is read, with the unknown field error: unknown lists the names it did not know and valid lists every name it takes. An empty --fields "" fails the same way, with valid and no unknown.
  • --fields needs --json; without it the command fails rather than ignore the flag. Plain output is unchanged.
  • In v0.10.0 and earlier, --json printed the full record, as --fields all does now. A script that reads notes, a parent uuid, index, todayIndex, trashed, creationDate, or startBucket on a things projects row, needs --fields all, or --fields naming those keys.

Listing

things                 # today's tasks (default view)
things list <view>     # explicit form — see views below
things <view>          # shortcut: things inbox, things today, etc.

Available views: today, inbox, upcoming, anytime, someday, repeating, logbook, trash, deadlines.

Every view above except inbox and anytime lists projects as well as tasks, marked (project) in plain output and "type": "project" in JSON; --area and --tag match a project, --project never does, and things projects is how to sweep projects on their own. The bundled agent skill states the rule and the reasoning in full — things skill show.

today, anytime and someday are arranged the way the app arranges them — items with no area first, then areas, and inside each group its own project rows and loose tasks before its projects’ tasks — so a project with no area leads the list with the unfiled tasks rather than following every area, and plain output prints each project name once as a group header above its tasks. The projects’ tasks follow project by project: an Anytime project’s first, then a project scheduled for a later day, earliest first, then a Someday project’s, each kind in the app’s project order. Inside a project the tasks are in the order of the project’s own page (see --project below), so tasks under a heading follow those under none. anytime carries no project rows of its own because every active project is trivially “anytime”: listing them all would bury the tasks, so the app uses each project as a group header instead. Like the app, it leaves out the tasks of a project in Someday or scheduled for a later date, including those under one of its headings and those Today shows; today and upcoming still list theirs. today and someday group the same way but do list their project rows, because a project put in Today or Someday has actually been put somewhere. someday reaches only the first half of the arrangement: it carries no task with a parent project, so it ends at unfiled items and then areas, each group listing its projects before its tasks. today then orders within a group the way the app does: by the day an item was last placed in Today, most recent first, so items placed today come above those carried over from an earlier day, and then by the position Things keeps for the day. An item closed today stays where it was rather than moving to the end. Items scheduled for this evening (--when evening, "startBucket": 1 in JSON) come after every other item, arranged the same way among themselves, and plain output prints a This Evening header above them, as the app does. upcoming reads by date instead, the way the app’s own Upcoming does. Like the app, it also lists an undated Anytime or Someday task or project whose deadline is still to come, under the deadline’s day, and --on/--from/--to match it on that day. today likewise lists any undated task or project once its deadline is today or past, from the Inbox, Anytime or Someday, unless it was taken out of Today for that deadline. A task of that shape also comes back from anytime, as in the app, so merge a sweep on uuid. One from the Inbox or Someday leaves inbox or someday while Today holds it, as it leaves the app’s Inbox or Someday. --on/--from/--to match it on today, the day Today shows it.

A bare --project lists the project the way its page in the app does: the tasks under no heading first, then each heading’s in heading order. Within each, Anytime tasks come first, then scheduled tasks by date, then Someday tasks. A bare --area or --tag groups like today: items with no area first, then each area. Inside an area, its rows follow the area’s page in the app: its Anytime projects, then its loose Anytime tasks, then its scheduled projects and tasks by date, then its Someday projects and tasks, projects first. Its projects’ tasks come last, project by project, each project arranged like its own page. The app’s area page does not list a project’s tasks; the CLI does, so a sweep of the area is complete.

logbook is everything closed, not just everything finished. Cancelling a task or a project logs it under its stop date beside the completed ones, the way the app’s Logbook shows both, so the view returns cancelled rows too. status tells them apart — "completed" or "cancelled" in JSON, [x] and [~] in plain output — so filter on it when you mean finished rather than closed: things logbook -j | jq '.[] | select(.status=="completed")'. It lists the newest first, and plain output prints a date header (2026-10-09) above each day’s items, the local day they were closed on, as the app’s Logbook groups them by day. JSON carries the time in stopDate.

trash lists the most recently thrown-away items first, as the app’s Trash does.

An item you tick off in Today is not in logbook yet. Things keeps it under Today for the rest of the day, ticked, and files it into the Logbook when the day rolls over, or sooner if you run things log — the app’s “Log Completed Now”, which files the day’s closed items straight away. things today lists it too, in place among the open tasks, marked [x] (or [~] if cancelled) in plain output and carrying status in JSON. That is the app’s default “Move completed items to Logbook: Daily” setting, and the CLI reads the setting: set to Immediately, nothing is held and every closed item is in logbook at once; set to Manually, closed items stay where they were, whatever day they closed, until things log. inbox, anytime, upcoming and someday behave the same way, because the app goes on showing a just-closed item there too. upcoming keeps only what was in it while open: a task closed ahead of its date, or an undated one with a deadline after today.

--open-only leaves those closed items out, for when you want only the work still to do: things today --open-only. It works on every listing, things projects included, and changes nothing on the ones that list only open tasks anyway (deadlines, repeating); logbook and trash reject it. To make it the default, set open_only = true in the config file; --open-only=false then lists the closed items for one call, and logbook and trash ignore the setting. --include-completed, which used to be how to ask for the closed items, is still accepted on inbox, today, anytime, upcoming and someday, and on a --project, --area or --tag listing with no view named. It has no effect except to override open_only, as --open-only=false does. Anywhere else (deadlines, repeating, logbook, trash) it is an error.

logbook holds nothing Things has not logged yet, wherever it was closed, as the app’s Logbook does. A closed item is therefore either in logbook or still in place, never both. Still in place, it is listed by its view, by its project (things --project <uuid>), by its area (things --area <name>), by a tag it carries (things --tag <name>), and, for a project, by things projects, which marks a closed project [x] or [~] as a listing marks a closed task. The one exception is a closed Anytime project with no area: no list shows it, in the app or the CLI, and things projects is where to find it until it is logged. The lists overlap each other, since a task scheduled for today is in the Anytime bucket too and an undated one due later is in both anytime and upcoming, so sweeping them means merging on uuid. The tasks of a project closed today stay in place in these lists, struck through, until the project is logged; then they fold into its row, as the next paragraphs say. A task closed today inside a project in Someday or scheduled for later is in no list, as in the app, which shows it only on the project’s page: things --project <uuid> lists it. With a filter on a view, things today -p "Launch v2" returns the tasks of a closed “Launch v2” that closed today, rather than nothing.

A bare --project, with no view, is the app’s project page: things -p "Launch v2" lists an open project’s tasks plus every one of them closed today and not yet logged, struck through in the app’s project page until the day rolls over, whichever list it was closed out of. A project’s contents are not one of the lists above, so they overlap them.

A bare --area is the area’s page the same way: things -a Work lists the area’s tasks and projects, closed today and not yet logged among them, as the app’s area page shows them, and its projects’ tasks, closed today among them, as each project’s page does. A project closed today is one row there, as in logbook. A bare --tag sweep lists the tag’s tasks and projects, closed today and not yet logged among them, as the app’s tag listing does; --open-only leaves those out. With --area as well, the listing is the area’s page narrowed to the tag: a tagged task closed today inside a project closed today is folded into that project’s row, so the project is listed even when only its task carries the tag.

A closed project is one row in logbook, not a row plus its contents. The app folds a closed project’s tasks into the project’s own row and lists none of them separately (and so do the other lists, once the project is logged), and trash does the same for a trashed project. The fold waits for the project to be logged here too: a task logged on an earlier day inside a project closed today keeps its own logbook row until then, as in the app. To reach those tasks, name the project: things --project <uuid> on a closed or trashed project returns its contents whatever their status, which is what the app answers for the same question. Naming the project works inside a view as well as in that bare form: things anytime --project <uuid> on a trashed project lists its open tasks, and naming a closed project on today or anytime lifts the fold there too, so a slice of those contents is reachable without leaving the view. A task you threw away out of a project is the exception — it keeps its own trash row, because it is in the Trash on its own account rather than through its project. A task thrown away out of a project that is itself in the Trash is reachable nowhere, as in the app.

someday is the app’s Someday list: the deferred things you have not filed under a project. A task inside a project stays inside it however it is deferred, so someday returns Someday projects and unparented Someday tasks, not the deferred tasks of an Anytime project. Open the project to see those — things --project "Name" — or use anytime, which carries the project itself. Because nothing in someday has a parent project, --project there could never match; the CLI rejects the combination rather than print an empty list.

repeating lists repeating task and project templates. The items a template generates are ordinary tasks and projects and appear in today, upcoming, things projects and the rest; the template itself appears only here — plus trash or logbook for a template that ends up there, since those two report what the database holds. Both carry projects, so a trashed or logged project template shows there. Project templates are marked (project) in plain output and carry "type": "project" in JSON.

The tasks inside a project template are hidden along with it, since they would otherwise list against a project things projects does not report. trash and logbook still show them once they are trashed or closed. Naming a project template with --project therefore lists nothing on those views, and the CLI prints a one-line note on stderr saying so. Under --json the note stays on stderr, so stdout is still an empty array. On trash, logbook and repeating, which keep templates, there is no note: an empty listing there means nothing has been trashed or closed yet.

A task inside a project template counts as repeating: it carries "repeating": true, and edit, complete, cancel and import refuse when, deadline and status changes on it as on the template. The tasks inside a project Things generated from the template are ordinary.

things search is a lookup rather than a view, so it returns templates too. Results carry "repeating": true.

Filter any list with -p/--project, -a/--area, or -t/--tag. Names ignore case and surrounding spaces. Case is ignored the way Things ignores it, so -t STRASSE finds a tag named Straße. An accented letter matches whether it was typed as one character or as a letter plus a combining accent, as in Things, but never the bare letter: -t Cafe does not find Café. Project and area names, but not tag names, also match across Unicode compatibility forms, as in Things: -a Work (fullwidth) or -p "Q²" finds Work or Q2, and a no-break space matches a space. A project, area or tag name typed in its exact case lists only that one, even when another title differs from it only by case. On their own the filters cover everything open in the project, area, or tag, plus what a project or area closed today and Things still shows there — so things -a Work lists that area’s own projects as well as its tasks, while -p still returns a project’s contents rather than the project row. Add a view and the filter applies within it, with the view named in the output. --project given the UUID of a to-do, a heading or an area is refused as not a project, and --area (on things projects too) given the UUID of a project, a to-do or a heading as not an area, naming the command that takes it, rather than listing nothing. Space around a UUID is ignored:

things -p "Launch v2"                # every open task in the project, plus those closed today
things today -p "Launch v2"          # today's slice of it, labelled "view: today"
things upcoming -t urgent
things anytime --area "Side projects"
things --json list today | jq '.[] | .title'

Tasks filed under a project heading belong to that project, so they appear under -p and under the project’s area.

A task row’s date is its deadline (due:2026-10-02) if it has one, otherwise its start date. A task with both also shows its start date, before the deadline, when the terminal is wide enough.

A task with a checklist shows its progress after the title, as the items done out of all of them (✓ 2/5, where done means completed or cancelled). Things.app shows only a checklist icon on the row, not the counts. things show lists the items.

Plain task lists fit the terminal width. When a row is too wide, the checklist progress goes first, then that extra start date. If the row is still too wide, a title longer than 40 columns is cut to 40 with …, so one long title does not cost every row its tags or date. Next the tags switch to a short form, the first tag and a count of the rest ([waiting-on-pos… +2], or [waiting-on-pos…] for a lone long tag), and the dates to one relative to today (today, tomorrow, due:Fri, 3d ago, 2 Oct, with the year only when it is not this one). Only if that still does not fit do the tags go, and then the date. Then the title is cut further, down to 10. A project keeps its (project) marker; the cut comes out of the title before it. Group headers are cut to fit too. Titles and headers are cut, and tags and dates shortened, only on a terminal. Piped output never cuts or shortens anything, but when any row is wider than 120 columns, whole columns still go from every row, in the same order: the checklist progress, the extra start date, the tags, then the date. --json carries every field in full.

A list prints each task on one line: a line break or a tab in a title, tag, project, area or group header shows as a space. things show keeps a title’s own line breaks, and --json carries it exactly as written.

things projects, things areas, and things tags list the collections themselves. things projects accepts --area, which takes a name the way the list filters do, and --completed.

things projects lists the open projects and, like the app’s project list, the ones closed and not yet logged: under the app’s default Daily logging, those closed today. status tells them apart. --open-only leaves those out, and open_only = true in the config file does the same. --completed lists every closed project, logged or not.

On a terminal, things projects fits the width the same way: a title longer than 30 columns is cut first, then, if that is not enough, an area longer than 20. Then the tags go, then the area, and then the title is cut down to 10. Piped output keeps every column whole.

Projects are scheduled the same way tasks are, and things projects -j reports that with the same field names and encodings: start, startDate, deadline, and startBucket when --fields is all or names it. A caller can tell a scheduled project from an anytime one without a things show per project.

things projects -j also reports two counts per project. taskCount is every untrashed task in the project; openCount is the ones still open. The difference is the ones no longer open, which means completed or cancelled. Tasks filed under a project heading count towards both; the heading rows themselves never do, and neither do trashed tasks or checklist items. Both numbers are Things’ own bookkeeping, read straight from the database rather than recounted by the CLI.

That makes it one call to find projects whose work has landed but which are still open:

things projects -j | jq '.[] | select(.openCount == 0 and .taskCount > 0)'

taskCount > 0 keeps out empty projects, which have nothing done rather than everything done. It does not tell done from cancelled: a project whose tasks were all cancelled matches the same filter. Plain output marks the same projects with a filled ● progress icon, and under --completed that icon also marks every completed project, including empty ones. A project holding a repeating task never appears while the repeat is live: Things counts the hidden template row itself as an open task, and a template never completes. things list -p <project> hides that template, so it can report no open tasks for a project whose openCount is 1.

A task row with a checklist carries checklistProgress: total is every item on the checklist and open the ones still open, so the difference is the ones completed or cancelled. A row without a checklist leaves the field out. things show lists the items themselves.

things list today -j | jq '.[] | select(.checklistProgress.open > 0) | .title'

A task with a reminder, as --when 2026-10-09@09:00 sets one, carries reminderTime: the local "HH:MM" clock time on its startDate. A task without one leaves the field out. Plain output puts the time before the title on a listing row, where the app shows it, and on the Start: line of things show, as in Start: 2026-10-09 09:00.

Inspecting a task

things show 3                 # by index from the last plain list
things show <uuid>            # by Things3 UUID
things show "Buy milk"        # by title (interactive disambiguation)
things show 3 --agent         # Markdown brief for handing to an agent

On a terminal, long values, notes and checklist items wrap to its width, and each wrapped line keeps the indent its text started at. A note keeps its own line breaks, and a word or URL breaks only when it is wider than the space left. A line that starts with an indent or a list marker (- , * , • , 1. or 1) ) wraps under the text after it, and tabs are expanded to every 4th column. Piped output, --json and --agent are not wrapped, and keep a note’s tabs.

A title reference is matched as a substring, literally and case-insensitively. There is no wildcard syntax: % and _ are characters to find, so things show "20_30 review" finds the task spelled with an underscore and not the one spelled with a colon. A title typed in its exact case wins over titles that differ from it only by case: with to-dos Water and water, things show water finds the second, while things show WATER matches both.

A title fragment only matches open items outside the Trash, and it is only tried when no item has exactly that title. An open item with the exact title wins over closed ones that share it, so a title you have completed before still reaches the open copy. When only closed or trashed items have the exact title, show displays the one (or offers them as candidates), and complete, cancel, edit and project edit refuse it with a non-zero exit and send nothing. With “Pay rent” completed and “Pay rent deposit” open, things complete "Pay rent" fails, names the completed item (the most recent one, and how many there are, when several share the title) and leaves the deposit alone. The same holds when the title you type differs from the closed one only by case or surrounding space: "pay rent" or "Pay rent " is refused rather than matched as a fragment of the deposit. An open item whose title matches that way, such as an open “pay rent”, still wins and is the one acted on. The --json token is already closed, or trashed when that item is in the Trash, and the object carries query; when several closed items share the title they are all listed in matches. A uuid or a row number that names a closed item is not refused this way; see Completing and cancelling.

A title matching more than one item is reported rather than guessed at: an interactive run prints the candidates and asks which one, and a non-TTY run returns them as an error. That holds for an exact title too — a project and a to-do that share one are both offered, instead of the lookup picking whichever sorts first. project edit and open --project match titles against projects only, so a to-do sharing the title is not offered.

After any plain list or search, numeric indices stay valid until the next one. A listing’s order is fixed, so the same list run twice numbers the same items the same way — but the numbers still move as items are added, closed or rescheduled, so re-read the list rather than reusing an index from an earlier one.

A number that is not a row in the last list is refused, not searched for as part of a title: after a 10-row list, things complete 12 does not complete “Chapter 12 notes”. The same goes when there is no last list at all. The error says to re-run the list. A task whose title is exactly that number still resolves, as does a uuid.

Only bare digits with no leading zero are row numbers. Listings never print 07, so 07, 00 and 0 are not rows: like the shapes below, they reach a task titled exactly that and nothing else, and the error suggests 7 when the last list has a row 7.

A reference shaped like a row number is not one either, even when row 3 exists: #3, +3, -3, # 3, 3., #3., (3), (#3), and 3 led by No, Nr, Num, Number, N°, Nº or № (with or without a dot). On the command line -3 needs -- before it, as in things complete -- -3. A row-shaped reference is not searched for as part of a title either: things complete '#12' does not complete “Fix issue #12”. The error says to pass the uuid, and to use 3 when the last list has a row 3. A task whose title is exactly #3 or (3) still resolves. Anything with more than one number or another symbol, such as 2026-10-07, 12:30, $100 or 1.2.3, still matches part of a title. Space around a number or a marked ref is not part of the title: 2026 finds a task titled “2026”.

A reference shaped like a uuid (21 or 22 letters and digits, or the 36-character hex form with dashes) that is no item’s uuid is not searched for as part of a title. uuids match exactly, case included, while titles ignore case, so a uuid typed in the wrong case could otherwise reach a task whose title merely contains it. A task titled exactly that string still resolves.

An empty reference, or one that is only spaces, is refused before any lookup. Under --json its token is empty reference. Space inside or around a title is otherwise kept: "Work " and "Work" are different titles.

show still reaches an item in the Trash by row number, uuid or an exact title no open item has. Its status line says so, as in Status: Open (in Trash), and --json has "trashed": true. A task whose project is in the Trash is marked the same way on the status line, and --json has "projectTrashed": true for it (the field is left out otherwise). The writes refuse such an item; see Completing and cancelling.

A --json listing is the exception: it prints no numbers and records none, so it leaves your indices pointing where they did. That keeps a script or an agent running --json in another window from renumbering the list you are reading. Agents should act on the uuid rather than an index — see Working with agents.

Handing a task to an agent

things show <ref> --agent prints a Markdown brief written for an agent, with the commands that act on the item. See Working with agents.

Searching

things search "milk"
things search "release" --json
things search "release" --json --fields uuid,title

The query matches titles and notes literally and case-insensitively, so strasse finds “Straße”. There is no wildcard syntax: % and _ are characters to find, so things search "50%" returns the items that say “50%”.

Search leaves out the Trash, as the app does: a trashed item, and a task whose project is in the Trash, even if the task itself was not thrown away.

Capturing

things add "Buy milk"
things add "Ship the thing" --when today --tags work,urgent
things add "Pay invoice" --deadline 2026-06-01 --notes "Send PDF"
things add "Review PR" --project "things-cli"
things add "Plan offsite" --list "Open source"   # --list takes a project or area; it overrides --project if both are given
things add "Groceries" --checklist "Milk\nBread\nEggs"

A title that is empty or only whitespace is refused before anything is sent, since Things would create an untitled to-do; under --json the error token is blank-title, as import reports the same title. edit --title, project add and project edit --title refuse it the same way.

--when accepts a keyword (today, tomorrow, evening, anytime, someday), a date YYYY-MM-DD, a time HH:MM or a 12-hour time (21:30, 9:30PM, 6pm), a date+time YYYY-MM-DD@HH:MM, an RFC3339 timestamp, a weekday name such as friday, alone or with a time (friday@9pm), or an English phrase such as next monday, which Things reads itself. An RFC3339 timestamp keeps its wall-clock time and ignores its offset: 2026-05-01T09:00:00+05:00 sets a 09:00 reminder in your local time, not the local time of 09:00 at +05:00. --deadline accepts a YYYY-MM-DD date or an English phrase such as next friday; the --when keywords, such as today, are rejected. A 12-hour time on its own is sent to Things as HH:MM (6pm as 18:00, 9:30PM as 21:30), the form Things was measured with, and is read back like one. A date, today or tomorrow with a time after the @ is sent as the measured YYYY-MM-DD@HH:MM: today@6pm goes as today’s date at 18:00, tomorrow@9:30 as tomorrow’s date at 09:30, and 2026-10-12@9pm as 2026-10-12@21:00, and each is read back like a date and time. Today and tomorrow are the dates when the command runs, so tomorrow@6pm sent just before midnight is the next day, even if Things reads it after midnight. A weekday name, in any case, is sent as the date Things gives it, the next such day strictly after today, so on a Saturday saturday is next Saturday: friday goes as that YYYY-MM-DD, and friday@9pm as that date at 21:00, whether or not the time has passed. Each is read back like a date. evening with a time is sent as evening@HH:MM (evening@6pm as evening@18:00), since a date and time today would file the item in the day part, and is read back as this evening, today, any time included. Abbreviations such as fri are sent as typed and not checked.

Things files the to-do by matching --list (or --project) against the titles of the projects and areas it shows, and --heading against the headings of that project. Both ignore case but not surrounding space, so the CLI sends a name as you typed it when Things matches it that way, and trimmed when only the trimmed name matches: " Tools " goes as Tools, while "Errands " still reaches an area whose title really is Errands . A name neither form matches goes as typed, with the warning below. When nothing matches it does not complain: it puts the to-do in the Inbox, or adds it to the list without the heading. A trashed project does not match by title, nor does a closed one Things has moved to the Logbook; one closed but not yet logged (by default, one completed or cancelled today), which Things still shows, does, and Things reopens it. Only the default “Move completed items to Logbook” choice, daily, was measured; under the others the CLI assumes the same not-yet-logged rule its lists follow. When several projects, or with no project several areas, match the title, Things files the to-do in the one whose UUID sorts first, even when another one’s title matches exactly, and the CLI checks that one. A UUID names one directly: the CLI sends it to Things as list-id, and Things files into that project whatever its state, reopening a closed one, or into that area. The add warns on stderr when the to-do will not land where you asked, and notes when it will land somewhere you may not expect, then sends it anyway:

warning: Things finds no project or area called "Nowhere"; it will put the to-do in the Inbox
warning: "Tools" has no heading "Later"; Things will add the to-do there without a heading
warning: --heading "Later" needs --list or --project; Things will ignore it and put the to-do in the Inbox
note: several lists are called "tools"; Things will use "Tools" (4Kc9…); pass a UUID to choose
note: several lists are called "errands" (a project and an area); Things will use the project "Errands" (8TZX…); pass a UUID to choose
note: "Tools" is completed; Things will file into it and reopen it
note: "Tools" is in the Trash; Things will file into it there

things project add creates a new project with the same flag set (--notes, --when, --deadline, --tags, --area, --todos). --area takes an area name or UUID; a UUID goes to Things as area-id, since Things matches area by title only. It matches the title ignoring case and compatibility forms (fullwidth letters, superscript digits) but not surrounding space, so the CLI sends a padded name trimmed when only the trimmed name matches, as for add --list; when several areas match, Things takes the one whose UUID sorts first, as for add --list, and the CLI notes which. When nothing matches it creates the project with no area. project add warns on stderr when that will happen, then sends the project anyway:

warning: Things finds no area called "Nowhere"; it will create the project with no area

Each line of --todos, like each --checklist item, may be at most 4000 characters; a longer one is refused before anything is sent.

add and project add then find the new item in the database and print it exactly as things show would, the same object under --json. Things returns no UUID for a new item, so the CLI looks for an item of the right kind with that title, created after the write started, that was not there before it, filed where the add sent it: the project or area --list, --project or --area resolved to (and the heading, if Things has it), or no project or area when none was given or Things will not match it. A new item with the same title somewhere else, from another command adding it at the same moment, is not this add’s. It waits as long as an edit’s read-back (five seconds by default; see --verify-timeout under Editing).

  • If no such item appears, the command exits non-zero with add not confirmed: …. Things may have dropped it (check that Things3 is running) or may be slow to save. Run the things search command the error prints before retrying, so a retry does not create a duplicate. It carries --db and --config when the add was given them.
  • If more than one new item with that title is visible together where the add sent it when it is read back (the same title added to the same place at the same moment), the CLI does not guess. It prints Sent to Things, not confirmed (more than one new item has this title): "Buy oat milk" (uuid1, uuid2) and exits 0.
  • --no-verify (or no_verify = true) skips the read-back and prints Sent to Things, not confirmed (--no-verify): "Buy oat milk". If no read of the database works, the item is still sent, with one warning (the tag check’s, when --tags was given), and the line says (database unreadable). A read that fails after earlier reads worked does not count: the add is then not confirmed, as above.

Under --json the unconfirmed cases print {"title": …, "confirmed": false, "reason": "no-verify"|"unreadable"|"ambiguous"}, with "candidates": [uuids] for ambiguous. There is no uuid, because the add did not return one. A --when the read-back cannot check is reported with reason when and the new item’s uuid (below). An add that --when did not file where it said (below) is an error instead: under --json its one object has "error": "misfiled", the item’s "uuid" and where it "landed".

Editing

things edit 3 --title "Buy oat milk"
things edit 3 --tags shopping            # replace all tags
things edit 3 --add-tags urgent          # additive
things edit 3 --deadline 2026-05-15
things edit 3 --when tomorrow
things edit 3 --notes "From Holland & Barrett"
things edit 3 --append-checklist "Almond too"
things edit 3 --complete                 # also: --cancel, --duplicate, --reveal
things edit 3 --list Tools --heading Setup

--list and --heading move the to-do. Things matches them the way it does for add: ignoring case and compatibility forms but not surrounding space (the CLI sends a padded name trimmed when only that form matches, as add does, and project edit --area does the same), and only against the projects (including one closed but not yet logged) and areas add matches; when several share the title, the one whose UUID sorts first is the one the to-do moves to. --list also takes a UUID of a project or area, which the CLI sends to Things as list-id; --list-id and --heading-id take UUIDs directly. --heading on its own looks in the project the to-do is already in. When nothing matches, Things does not complain: an unknown list is ignored, so the to-do stays where it is or, with --heading, the heading is looked up as if given on its own; an unknown heading in a known list moves the to-do to that list with no heading; an unknown heading on its own is ignored; and an unknown --list-id or --heading-id is ignored too. An empty --list "" takes the to-do out of its project, heading and area and files it in Anytime, and an empty --heading "" on its own takes it out of its heading and leaves it in its project; both are read back like any other move. edit warns on stderr when a move will not happen, then sends the edit anyway:

warning: Things finds no project or area called "Nowhere"; the to-do will stay where it is
warning: Things finds no project or area called "Nowhere"; it will look for --heading "Setup" in the to-do's own project
warning: Things finds no project or area with id "abc123"; the to-do will stay where it is
warning: Things has no heading with id "abc123"; the to-do will stay where it is
warning: Things has no heading with id "abc123"; it will ignore --heading-id
warning: "Tools" has no heading "Later"; Things will move the to-do there without a heading
warning: "Tools" has no heading "Later"; Things will leave the to-do where it is
warning: --heading "Later" needs --list or --list-id, as the to-do is not in a project; Things will ignore it and leave the to-do where it is

When the move files the to-do somewhere new, edit notes the same things add does: several lists sharing the title and which one Things will use, or a closed or trashed project. Things moves a to-do into a closed project, logged or not, and reopens it, and into a trashed one by UUID, which stays in the Trash. A project wins over an area of the same title. --heading on its own moves the to-do under that heading even when its project is logged or trashed, and leaves the project closed.

A move Things will drop counts as no change, and so does a move to the list, area or heading the to-do is already in, since Things records no change for it. An edit made only of such moves prints the item at once instead of waiting for a change. When a project has headings whose titles differ only in case, Things files the to-do under the same one of them whichever case you send, and the check follows it.

Things reports nothing back from an edit, so the CLI waits for the item’s modification date to change (and, with --complete or --cancel, for the status to change too; an edit with only one of those is checked on the status alone). Things does not change the modification date when only the checklist changes, so an edit with a checklist flag also counts once the checklist differs from what it was before. It then prints the item the way things show does — the same object under --json:

things edit 3 --title "Buy oat milk"
# Title:    Buy oat milk
# UUID:     8QK2xgV1m3C9p7RfT4hLwe
# Status:   Open
# …

If the item never changes within the read-back wait, edit exits non-zero with edit did not apply: … — Things accepted the command and dropped it, so check that Things3 is running. The wait is five seconds by default; the global --verify-timeout DURATION flag changes it for one run (--verify-timeout 2500ms, --verify-timeout 10s), and verify_timeout in the config file changes it for every run. It must be above zero: --no-verify is how to skip the wait. The same wait applies to add, project add, complete, cancel, tag add and the new items and status changes in an import. An edit that sets every field to the value it already has is detected before the wait when every flag is --title, --notes, --tags, --add-tags (tags compared case-insensitively), a --deadline date, an empty --append-notes or --prepend-notes, or a --when of anytime, someday, today, evening, tomorrow, empty, a date, an HH:MM time or a YYYY-MM-DD@HH:MM date and time (today@6pm and tomorrow@18:00 included, judged as the date and time sent). A past date files the item under today. --when today, evening, today’s date or a past date clears a reminder, so on an item with one it counts as a change and waits; a later day keeps the reminder. A time sets the reminder, for today if the time is still to come and tomorrow if it has passed, so it is no change only on an item with that reminder on that day. A to-do Things has not moved into today yet (as just after midnight, or when Things has not run since its date) that is dated today, in the day part, stays where it is for --when today or today’s date, so those are no change for it too. Other such rows (with a reminder, dated before today, a project, or in the evening) were not measured: --when today, today’s date, a past date, or evening on an evening row is sent and read back, and the edit is confirmed whether Things moves the row or leaves it where it was. When Things leaves it, that takes the full read-back wait. An open item in Today from an earlier day keeps that day as its start date, since Things does not move it forward, and it counts as filed today. --when today on such a to-do in the day part, with no reminder, is no change. Today’s date, a past date, today on an item with a reminder, and evening on an evening item are sent and read back, and the edit is confirmed whether Things moves the date to today or keeps the earlier one. Those values clear a reminder, so an item that still has its reminder with nothing recorded is a dropped edit. Any other value, such as tomorrow, or evening on an item in the day part, must move the item, as for any other item. Tags that do not exist in Things count as no change, since Things drops them, unless --create-tags creates them first. So does a move the warnings above say will leave the item where it is. It then prints the item straight away. Any other re-set value still waits and reports the same error (an English --when phrase on its own is not waited for; see below), so on that error check the item with things show before retrying.

With --when, the read-back also checks that Things filed the item where the value puts it: Anytime, Someday, today, this evening or the date. If it was modified but filed somewhere else, edit exits non-zero with edit did not apply as sent: … and says where the item is. add and project add check the same, and exit non-zero with add did not apply as sent: … naming the item they found; search for the title before adding it again, since another add of the same title at the same moment can be the one found. Under --json either error is one object with "error": "misfiled", the item’s "uuid" and where it "landed". A value the CLI cannot place, an abbreviated weekday (fri) or an English phrase that Things understood, is not checked against a place. Once Things has applied the write, add, project add and edit warn on stderr with where the item is, print Created, not confirmed (--when not checked): "…" (uuid) (Applied, not confirmed … for an edit) and exit 0; under --json, {"uuid", "title", "confirmed": false, "reason": "when"}. Check the item with things show. A value shaped like a date or time that names none, such as 2026-13-01, 25:00, tomorrow@25:00 or 2026-10-09T25:00:00Z, or a time after anytime@ or someday@, is refused before anything is sent; a date and time with no offset, such as 2026-10-09T10:00, is read as that wall-clock time. A free phrase, anything but a keyword, a weekday name, a date or a time, is not read back: add warns Things did not understand --when "…" on stderr when the new item has no start date, and still prints the item and exits 0; and an edit whose only change is a free phrase returns at once with a warning, unconfirmed (under --json, {"uuid", "title", "confirmed": false, "reason": "when"}), since Things records nothing when it ignores one; check it with things show. With other changes the edit waits as usual, and if nothing was modified its error names the phrase as the likely cause. A write sent just before midnight counts as filed for either day. An edit with no field flags and no status to change prints the item without waiting. --complete or --cancel on an open item waits for the status, as things complete and things cancel do.

--complete and --cancel follow the same rule as things complete and things cancel. On an item already in that state, the status is left out and the rest of the edit is handled as above, with a note. When nothing else would change the item and there is no --reveal, nothing is sent and the item is printed. Completing a cancelled item, or cancelling a completed one, refuses the whole edit and sends nothing; under --json the error token is already closed. With --duplicate the status is sent as asked, since it applies to the copy and the item stays as it is.

Two cases are not read back, and say so instead of printing the item:

  • --no-verify (or no_verify = true): Sent to Things, not confirmed (--no-verify): "Buy oat milk" (…).
  • --duplicate: Things applies the edit to a new copy whose UUID the CLI cannot learn, and leaves the original as it was.

Under --json both print {"uuid": …, "title": …, "confirmed": false, "reason": "no-verify"} (or "duplicate"), so a script can tell an unconfirmed edit from a confirmed one.

edit is for tasks only. A reference that resolves to a project is refused before anything is written, because things:///update cannot address one — use things project edit instead. project edit refuses a task the same way, pointing back at things edit. Its titles match projects only, so a to-do sharing a project’s title, or the part of it typed, does not make the lookup ambiguous. When no project matches, a title or fragment that only one to-do matches is refused as not a project, even when that to-do is in the Trash; several to-dos, or none, are a project not found.

things project edit takes most of the same flags (--title, --notes, --prepend-notes/--append-notes, --when, --deadline, --tags, --add-tags, --complete, --cancel, --duplicate, --reveal) plus --area/--area-id to move the project. It has no checklist or heading flags. --area takes an area name or UUID, matched as for project add; a UUID goes to Things as area-id. An area Things cannot match, by name or by --area-id, leaves the project where it is, and project edit warns. When several areas share the title, it notes which one Things will use, as project add does, unless the project is already there. A move to the area the project is already in counts as no change.

warning: Things finds no area called "Nowhere"; the project will stay where it is
warning: Things finds no area with id "abc123"; the project will stay where it is

Tags must already exist

Things applies only tags that already exist and drops the rest without saying so. Every write that carries tags (add, project add, edit, project edit, import) checks them against the database first and warns:

things add "Review the flags" --tags "Work,cifas-auto-reject"
# warning: these tags do not exist in Things and will be ignored: cifas-auto-reject

edit --tags and project edit --tags replace the item’s tags, and Things replaces them with the named tags that exist. An unknown name is not just skipped: the item ends up with only the known tags, or with no tags at all when none of them exists, and the warning says which:

warning: these --tags do not exist in Things: Nope; --tags replaces the task's tags with the ones that exist, and none does, so Things will leave the task with no tags

The write still happens. Add --create-tags to create the missing tags first so the write applies them all, or --strict-tags to fail and write nothing instead. The two contradict each other and are rejected together.

Creating tags

things tag add focus "deep work"
things tag add Work                 # skipped, it already exists
things tag add focus --json         # {"created": [...], "skipped": [...]}

Names that already exist are skipped rather than duplicated, matched case-insensitively as Things matches them. A name that differs only in case from one given earlier in the same command is the same tag, so it is reported as skipped too. Creation goes through AppleScript, so Things3 must be running; the tag list is read back afterwards to confirm it landed, which --no-verify skips.

Completing and cancelling

things complete 3
things cancel 3
things complete "Launch v2" --yes    # skip the project confirmation

Completing or cancelling a project also completes or cancels every task in it, so it asks first. A run that cannot prompt — piped stdin, or --json — declines instead of guessing; --yes (-y) answers the question up front, which is how project completion works from a script. assume_yes = true in the config file sets it every time, and --yes still decides each run.

On success both print the item as things show would, the same object under --json, once the read-back sees the new status. With --no-verify nothing is read back, and they print a line saying the change was sent but not confirmed (under --json, {"uuid", "title", "confirmed": false, "reason": "no-verify"}).

Listings number the items closed today alongside the open ones, so a ref can land on one. Completing an item that is already completed, or cancelling one already cancelled, sends nothing and exits 0 with a note; under --json it prints the item, as edit --complete does. Completing a cancelled item, or cancelling a completed one, is refused and sends nothing; under --json the error token is already closed.

An item in the Trash is refused and nothing is sent. A row number can point at one when the item was trashed in Things after the listing was printed, and a uuid can name one. Its exact title, when no open item has it, is refused the same way, and a title fragment never matches it. The error names the item and says it is in the Trash; under --json its token is trashed. edit and project edit refuse a trashed item the same way.

A task whose project is in the Trash, directly or through a heading, is refused the same way, with the same trashed token, and the error names the project, which --json gives as project. Things shows such a task only in the Trash, so the CLI treats it as trashed: a title fragment never matches it, and a uuid or its exact title reaches it only to be refused. show marks it (in Trash) on the status line, and --json gives "projectTrashed": true. open refuses it with the same trashed token, since Things has nowhere to show it. Restore the project in Things first.

Both go through AppleScript so Things3 records the change in its activity log. Task creation (add) and edits go through the things:/// URL scheme; the CLI never writes to the database directly.

Logbook and import

things log                    # move all of Today's completed items into the Logbook
things import < payload.json  # batch create/update via the Things JSON URL scheme

import payload is the array documented by Cultured Code.

Items with "operation": "update" go through the same repeating check as edit: if any of them carries when, deadline, completed or canceled for a repeating task or project, the whole import is refused before anything is sent, and the error names every offending item. The status fields are two-way, so false is refused as readily as true. Update items that set completed or canceled are read back from the database afterwards, and any that Things dropped are reported one per line with a non-zero exit.

A creation-date or completion-date on a to-do or project the payload creates or updates, or on a heading or checklist item, must be a date and time with seconds and a UTC offset, such as 2026-10-05T10:30:00Z or 2026-10-05T10:30:00+02:00 (+0200, +02 and +01:00:00 work too, as do a lowercase z and trailing spaces). Things rejects the whole payload over a date on its own, a time with no seconds or no offset, a lowercase t, a comma before the fraction of a second, a leading space, a month outside 1 to 12, or an offset such as +200, so import refuses these before anything is sent (tags included) and names each item, with the id of an update item. Reason invalid-date.

import is stricter than Things on malformed dates. Things takes +012, ZZ, a five-digit year, T100:00:00 and +100 by guessing at them or dropping the date, so the item is saved with a date the payload did not give, and nothing says so. import refuses them, along with a day outside 1 to 31, an offset wider than 18 hours, and any year of more than four digits or other date or time field of more than two. A null creation-date is no date: Things saves the item as created now.

A creation-date or completion-date in the future is refused too, with reason future-date: Things saves it as now, so the item would not get the date the payload gives. A date up to a minute ahead is allowed. An hour past 23 rolls into the next day, so T25:00:00 on today’s date is in the future and refused, while the same hour on an earlier day is taken.

A when or deadline that Things would read as something else is refused with reason invalid-date. Things saves these with no warning: a when of 2026-13-01 or 18:00 lands in Today with no reminder, a deadline of 2026-13-01 on 1 January, and a deadline of someday as no deadline. import makes the checks add makes for --when and --deadline, so a misspelt keyword such as tommorow and a keyword as a deadline are refused. The Things JSON format is narrower than the URL add uses: its documentation names only the keywords, a date and a date and time. So a value that starts with a digit must be a real date, YYYY-MM-DD, and a deadline has no time. A when with an @ must have today, tomorrow, evening, a weekday name or a real YYYY-MM-DD before it and a real time of day, such as 18:00, 9:30PM or 6pm, after it: Things files tomorrow@25:00 tomorrow with no reminder, and ignores the time in someday@18:00 and anytime@…, so these are refused, as is a phrase with a time such as next friday@18:00. A date, today, tomorrow or a weekday name with a time is sent as add sends it, as YYYY-MM-DD@HH:MM: today@6pm goes as today’s date at 18:00. A weekday name alone goes as its date, and evening with a time as evening@HH:MM. A bare time and an RFC 3339 timestamp are refused. The keywords and English phrases such as next week are sent as they are, as add sends them; how Things reads a phrase in a payload was not measured. Update items are checked too.

Things also rejects the whole payload, showing an error and creating nothing, over an item it does not take, so import refuses these too:

  • An item that is not an object, or whose type is not exactly to-do, project, heading or checklist-item (Things does not trim it, so "to-do " is rejected), or whose operation is not exactly create or update. Reason invalid-item.
  • A created item in a place Things does not allow it: only a to-do or project at the top level, a to-do or heading in a project’s items, and a checklist item in a to-do’s checklist-items. Reason invalid-item.
  • A created item with no attributes. Reason invalid-item.
  • A created item whose attributes is not an object, or with an attribute of the wrong JSON type: title, notes, when and deadline must be strings, tags an array of strings, completed and canceled true or false, and items and checklist-items arrays. Each may be null. Reason invalid-type. The attributes of update items are not checked.

Things takes some payloads without an error but saves something other than what they say, so import refuses these too:

  • A created to-do with items, or a created project with checklist-items, whatever their value. Things takes items only on a project and checklist-items only on a to-do, ignores them elsewhere, and creates the item without them. Reason invalid-item.
  • An object that gives a key twice, such as two titles. Things keeps the first and the CLI would read back the last. Reason duplicate-key, with the key in blocked. This is checked in every object of the payload, update items included.
  • A created item with a title over 4000 characters, which Things cuts to 4000, or notes over 10000 characters. Notes that long were not measured; the limit is the one add enforces. Reason too-long.

A refusal names each item by a title of at most 200 characters: a longer one is cut to its first 200 and an ellipsis, and the item carries titleLength, the full length in characters (absent when the title is whole). A payload value the message repeats, such as a when Things would misread, is cut the same way, with its length in brackets.

A to-do or project created with no title, or one that is only whitespace, is refused with reason blank-title. Things would create it as an untitled item, which is almost never what was meant.

A payload that creates more than 200 items is refused before anything is sent. Every to-do, project and heading it creates counts, nested ones included. Update items and checklist items do not count. With 300 to-dos Things stops to ask “Is this what you intended?” and creates nothing until someone answers, long after the read-back has given up; 200 went through without the question. Split a bigger payload into several imports, and leave a pause between them: Things’ URL scheme documentation is believed to allow about 250 items per 10 seconds (not verified). Under --json the error carries "reason": "too-many-items" beside "error": "import refused". A refusal for size alone has no items.

All of these refusals are one: a run names every refused item, whichever the reason. Under --json it is import refused, with one entry per item: blocked names every attribute refused on it, and reason says why, repeating, invalid-date, future-date, invalid-type, invalid-item, duplicate-key, too-long or blank-title, several separated by a space. An item that is not an object has no blocked.

Every to-do and project the payload creates is read back too, the way add finds its item: a new item of that kind with that title (surrounding whitespace trimmed) that was not there before the import, filed where the payload puts it, as Things files it. The title is reported as Things saves it, padding included:

  • A to-do with a heading-id goes under that heading, in its project, whatever list it names. Any heading-id, even an empty or unknown one, makes Things ignore heading; an unknown one is otherwise ignored.
  • Otherwise a list-id wins over list. An empty or unknown list-id puts the to-do in the Inbox.
  • A list-id or heading-id Things has files the to-do there whatever state its project is in: a closed or logged project is reopened, and a trashed one takes the to-do into the Trash.
  • A list title goes to the list Things picks for that title, as for add, under its heading when it has one. Things does not trim it, so "Work " does not match Work. Unlike add and edit, import does not trim list, area or heading: the payload is Things’ own format and goes as given, so fix the name in the payload when the CLI warns. A title that matches nothing, or a uuid given as list, puts the to-do in the Inbox. When a project created earlier in the same payload, or renamed to that title by an earlier update item, has that title, the to-do may go to either, so any list with the title counts. A project created later in the payload is not there yet. A to-do sent to the old title of a project an earlier update item renames is not checked. A heading the list does not have is left out, unless the payload creates a heading with that title earlier, which the to-do may go under.
  • A to-do with none of these goes to the Inbox.
  • A project goes to the area its area-id names, or else its area. An empty or unknown area-id, or an area that matches nothing, leaves it in no area.

Things does not trim an id, so a heading-id, list-id or area-id with surrounding space matches nothing, and a null one counts as not given. A list, list-id, heading, heading-id, area or area-id that is not a string or null, such as a number, makes Things reject the whole payload, so import refuses it before anything is sent, with reason invalid-type.

import prints a warning for each item that Things will not file where the payload asks, as add does, and the same notes as add when a list or area title is shared or a project is closed or in the Trash, once per list or area.

A to-do in a project’s items must be in that project. Things files a to-do in the items of a project the payload creates there, under the heading before it, and ignores any list, list-id, heading-id or heading it gives, which import warns about. A to-do inside the items of a project the payload updates is checked too: Things drops those without saying so, and the read-back reports it as not-found. Headings and checklist items are not read back. Every to-do and project the payload creates gets a line, since one Things would not read is refused first. import prints one line per created item:

Created and confirmed: [0] "Buy oat milk" (8QK2xgV1m3C9p7RfT4hLwe)
Created and confirmed: [1] "Launch" (Hs5TqPz2aN8wYc1Lk3RmVd)
Created and confirmed: [1].attributes.items[0] "Book venue" (Ft7…)

Under --json it prints an array, one object per created item: {"path", "kind": "task"|"project", "title", "uuid", "confirmed": true}. When several created items share a kind and title, they are confirmed once that many new items appear, and paired with the new items in the order Things saved them. If two of those new items have the same creation time, the order says nothing, so they are all reported ambiguous instead.

An item that is not confirmed has "confirmed": false and a reason, as an unconfirmed add does, and no uuid unless the reason is completion-date-dropped, misfiled or when:

  • no-verify: --no-verify (or no_verify = true) skipped the read-back.
  • unreadable: the database could not be read, so a warning is printed.
  • ambiguous: more new items with that title appeared than the payload created, or they cannot be paired (see above), or a new item fits two created items because one of them has a destination that is not checked. candidates lists their uuids.
  • creation-date: the payload sets the item’s creation-date. Things saves the item with that date, so the read-back cannot tell it from older items and does not look for it. The line says not checked (creation-date set).
  • shares-dated-title: another item in the payload with the same kind and title sets a creation-date within the last minute or later, and a new item filed where this one goes is also filed where that one goes. It could be either one, so this item is not confirmed. candidates lists the new items with that kind and title filed where this item goes. When the database cannot be read, any recent dated item with the same kind and title counts. The line says not confirmed (a dated item has the same title). An older creation-date cannot be mistaken for a new item, so it does not stop the read-back. When a new item appeared for this item and for each dated item that could hold one of them, every one of them is there, so the import exits 0. When fewer appeared, the item may still exist, but one of them has not appeared, so the import fails. Under --json the item carries "present": true in the first case and "present": false in the second. No other item carries present.
  • not-found: no new item with that title appeared within the read-back wait, or fewer than the payload created, or fewer than the created items that could each be filed where they appeared (when one of them has a destination that is not checked). candidates lists the ones that did appear.
  • completion-date-dropped: the payload completes or cancels the item with a completion-date, and the new item was saved with another completion date. It carries its uuid, since it is there. The import exits non-zero with import partially applied, and under --json the item is in items with its id; set the date in the Things app rather than importing the item again. A completion-date on an item the payload does not complete or cancel is ignored by Things and not checked.
  • misfiled: the item’s when is a keyword, a date, or a date and time (judged as sent, so today@6pm as today’s date at 18:00), and Things filed the new item somewhere else, as add reports a --when that did not land. A to-do sent with when today and a deadline already past is one case: Things files it in the Inbox with no start date. It carries its uuid and landed, where Things filed it. The import exits non-zero with import partially applied, and under --json the item is in items with its id and landed. Move it with things edit (things project edit for a project) rather than importing it again. An item the payload completes or cancels, or one inside a project it completes or cancels, is not checked against its when.
  • when: the item’s when is a free phrase Things did not understand, so the new item has no start date, or one the CLI cannot place, such as fri or next week, so where it filed the item is not checked (the line says Created, not confirmed (when not checked)). It carries its uuid and landed, and the import exits 0, as add does with such a --when.

The first four, and when, exit 0 with the list printed. Any not-found item, or a shares-dated-title item with too few new items to account for it, makes the import exit non-zero with import partially applied, the same error a dropped status change gives. The error names those items, and under --json they are in items. When none of the created items appeared at all, the error says Things may have rejected the whole payload: look for an error in the Things window before running anything again. Search for each of them with things search before running the import again with only those items. Otherwise a retry can create duplicates. The verdict on every other created item follows under “The other created items”, and under --json the error’s created array carries every created item in the shape above, so the confirmed ones keep their uuids. The same happens when only a status change failed. The created items and the status changes share one read-back wait.

Opening in the app

things open today              # built-in views
things open inbox
things open <uuid>             # specific task or project
things open "Weekly Review"    # task or project by title
things open --project "Launch"     # a project by title, UUID or index; titles match projects only, and a to-do is refused ("not a project")
things open --area "Side projects"   # an area (bare titles never resolve areas; names match case-insensitively)
things open --tag urgent             # a tag (names match case-insensitively)

Agent skill

things skill install <agent> installs the bundled skill for Claude Code, Codex or Pi. See Working with agents.

Shell completions

things completions <shell> prints a completion script for bash, zsh, or fish:

things completions zsh > ~/.things-completions.zsh   # then source it from ~/.zshrc

Homebrew-cask installs wire these up automatically.

Doctor

things doctor
things --json doctor

Checks that the Things database can be found and opened read-only. It reads no task data. Run it when another command says the database is missing or cannot be opened.

The report gives:

  • status: ok, not_found, permission_denied, multiple (more than one ThingsData-* folder holds a database) or error.
  • source: where the path came from, automatic, flag (--db) or config (the db key).
  • container, pattern and matches: what automatic discovery looked at. Left out when the path came from --db or the config file.
  • database: the path it opened, and error: why it failed.

permission_denied means macOS refused access: the database may well be there. This happens when things runs under an app that has not been given access to other apps’ data, such as some desktop agents. Giving that app Full Disk Access in System Settings › Privacy & Security, or running things from a terminal that has it, usually fixes it.

The whole report goes to stdout either way. When status is not ok, doctor exits 1, so things doctor && ... works as expected. Under --json stdout holds the report only, never a second error object.

Version

things version       # or: things --version / things -v

Prints the version, commit, and build date.

Update

things update            # update to the latest release
things update --dry-run  # check for a release, print the command, and stop

Updates things the same way it was installed. It looks at where the running binary lives and how it was built:

  • In the Homebrew Caskroom: runs brew upgrade --cask ryanlewis/tap/things.
  • A release binary from the install script or a tarball: re-runs the install script with INSTALL_DIR set to the binary’s own directory. If that directory isn’t writable, it stops and prints the command for you to run, since the script asks for sudo.
  • Built by go install ...@latest: runs go install github.com/ryanlewis/things-cli/cmd/things@<tag> for the latest release tag, with GOBIN set to the binary’s own directory.
  • A local build (make install, go build): refuses, because there is no release to update it from.

Before updating, it asks GitHub for the latest release, with or without --dry-run. It stops if you already have it, or if yours is newer. Only MAJOR.MINOR.PATCH is compared for “newer”, so 1.0.0-rc1 is not newer than 1.0.0 and updates to it. The install script is then fetched from that release’s tag and told to install that version, and go install is pinned to the same tag. The check uses the GitHub API, with a token from GH_TOKEN or GITHUB_TOKEN when one is set (sent to api.github.com only), which avoids the API’s rate limit for unauthenticated calls. If the API call fails, it reads the tag from where https://github.com/ryanlewis/things-cli/releases/latest redirects, and prints a note on stderr naming the API error, so a bad or expired token shows up. If neither works (offline, say), or the latest tag isn’t a vMAJOR.MINOR.PATCH version it can compare and pin, the install script and go install methods stop with an error and print the command for you to run yourself: both pick the latest release on their own and could replace a newer binary with an older one. Homebrew carries on, since brew upgrade makes its own check. It prints the command before running it.

Configuration

A TOML file at ~/.config/things-cli/config.toml supplies defaults for the flags above, so you can set them once instead of typing them every run. Precedence is flag > config file > built-in default.

things config init     # write a commented template
things config path     # the file in use, and whether it exists
things config show     # the defaults it establishes

See Configuration for the full key table, an annotated example file, and what happens when the file is wrong. The three config subcommands keep working against a file the CLI cannot use — they are how you find out what is wrong with it.

Caching

things caches the last list it printed in $HOME/Library/Caches/things-cli/last-list so that numeric indices (things show 3) work across invocations. Clear it by deleting that file or by running any plain list command, which overwrites it.

The cache records when the listing ran and which command printed it. A row number is good for four hours; past that a numeric reference is refused rather than acted on, because the rows behind it have probably moved. The error names the listing to re-run:

$ things complete 2
Error: task #2 comes from a stale list cache: the rows were listed over 2 days ago, older than the 4 hours a row number is good for. Re-run `things today` and use the new row number, or pass the task's uuid.

Re-running that listing renumbers the rows and clears the refusal. A UUID is never refused, and neither is a title. A cache file written by a version before 0.8.0 records no time, so the first numeric reference after upgrading is refused until you list again. The named listing carries --db when the flag supplied one, and --config when the flag named the config file, so it re-reads the database the rows came from.

The cache also records which database the listing read, by its resolved path. A numeric reference made against a different database is refused too, however recent the listing, since its rows describe the other one:

$ things --db ~/backup/main.sqlite today
$ things complete 2
Error: task #2 comes from a listing of a different database: `things --db /Users/me/backup/main.sqlite today` read /Users/me/backup/main.sqlite, and this command reads /Users/me/Library/Group Containers/…/main.sqlite. Re-run the listing against this database and use the new row number, or pass the task's uuid.

A relative path, a symlink, or a hard link to the same file, or the same path typed in a different case, counts as the same database. A cache file written before the database was recorded backs a row number only when no --db is in play (from the flag or the config file); with one, the reference is refused until you list again.

A cache file that is there but cannot be read, because it is empty, truncated or garbled, refuses a row number rather than treating it as a title. Run a listing again to rewrite it, or use the task’s title or uuid. Under --json the token is stale list cache. With no cache file at all, a number still resolves as an exact title, as described above.

A --json listing never writes the cache. JSON output carries no row numbers, so it has nothing to record, and the file is one shared cache per machine rather than one per shell — writing it from a scripted run would move the numbers a person is reading from another window.