A typed TypeScript library + CLI (things) for programmatic interaction with Things 3 by Cultured Code.
Status: read + write + MCP layers live and published to npm (v0.13.0 — see CHANGELOG.md). Reads go straight to the local SQLite database (UI-exact Today ordering, sidebar-grouped Anytime/Someday with container-status cascade, decoded repeat rules, occurrence projections); writes run a verified pipeline over four write vectors — two lab-validated headless surfaces (the URL scheme + AppleScript) and two opt-in surfaces (Apple Shortcuts and an Accessibility-driven GUI vector) — with hazard guards, disruption-tier policy, a JSONL audit trail, batch mode, audit-replay undo (transactional across compound operations), full project lifecycle (complete/cancel/reopen/restore), heading rename/archive/unarchive with children policies, container detach, granular stateful checklists, tag hierarchy management incl. un-nesting, completion/creation backdating, Logbook imports, tiered fuzzy name resolution (uuid prefixes, things:///show?id= share links), and ordering across eight scopes (incl. a project's headings and the top-level sidebar projects). The Apple Shortcuts surface is wired for the two headless capabilities nothing else can do: creating a heading in an existing project (things heading add) and clearing a reminder from a date-scheduled item (things todo clear-reminder). Both run through bundled proxy shortcuts (things setup shortcuts) and are gated on their presence. The fourth vector — the Accessibility-driven GUI vector — is off by default and drives the local Things app to reach transforms that exist on no scriptable surface (repeat-rule editing on existing to-dos and projects, convert-to-project, sidebar area reorder); see Accessibility GUI vector below. Single-item permanent delete stays interactive-only (its macOS consent has no always-allow) and is out of the headless pipeline. See docs/design/ for the architecture and VM-lab design, docs/lab/ for the probe harness and campaign results the write layer is grounded in, and docs/atlas/ for the database↔UI map.
things today --json # read: your Today list, Evening split, UI order
things legend # the symbols & colors the list views use
things upcoming --horizon 5 # date plan incl. projected repeat occurrences
things todo add "Buy milk" --when today --tags errands --dry-run # plan without executing
things undo --dry-run # inverse plan for the last mutation (audit replay)
things capabilities --op todo.delete # what's possible, per vector, with evidenceMost ways to script Things fire a URL or an AppleScript command and hope. This package is built on four commitments instead, and each is a checkable property of the response, not a claim in the docs:
- Verified writes —
okmeans observed, not submitted. Every mutation runs a read-after-write pipeline: it re-reads the database and confirms the expected change is actually present before returningok: true. The app accepting a command and silently doing nothing (a real Things behavior) is reported as a failure (verify-failed:silent-noop), never as success. A caller can act onok: truewithout a confirming read of its own. - Safety rails. Writes go exclusively through official app surfaces — URL scheme, AppleScript, Shortcuts, and the opt-in Accessibility GUI vector — never direct SQLite writes (which corrupt Things Cloud sync). Every operation carries a disruption tier (0 invisible → 3 drives the live UI) and passes hazard guards before dispatch; disruptive and destructive actions require explicit acknowledgement flags, never an interactive prompt. A process can be jailed to one container with
--scope(an area or project), and an out-of-scope item is byte-indistinguishable from a nonexistent one. - A written contract, not a vibe. Every
--jsonresponse is one instance of a small, stable envelope grammar (apiVersion,ok,kind,data,meta), with a frozen exit-code family, a one-word-one-meaning glossary, and a machine-readable error-code registry. The full contract — including the compatibility covenant that activates at v1.0 — is in docs/contract.md; the design rationale is in docs/design/api-doctrine.md. - Agent-native by design. Errors are self-correcting: an ambiguous name comes back with uuid-bearing candidates, a bare verb with the exact command to run instead, so an agent fixes its own mistake without another round-trip. Reads never cap silently —
meta.truncation.truncatedis the universal "did I see everything" check. And the wire is kept dense on purpose (empty fields pruned, no redundant discriminators), because when the consumer pays per token, information density is a correctness concern. Consumer-facing copy follows a strict behavior-not-mechanism contract (docs/design/surface-copy.md).
Things 3 installed and launched once, Node ≥ 24, and a handful of one-time macOS consents / Things settings depending on what you use (file-access consent for reads; "Enable Things URLs" + Automation consents for writes; an Accessibility grant for the optional GUI vector). See docs/setup.md — including the dedicated-automation-Mac checklist. things doctor validates your setup and prints remediation for anything missing.
A few operations exist on no other app surface: creating a heading in an existing project, clearing a reminder from a date-scheduled item, and permanently deleting a single item. These run through six bundled Apple Shortcuts (signed .shortcut files shipped with the package). Install them with:
things setup shortcuts # opens an install sheet per missing shortcut — click "Add Shortcut"On each shortcut's first run macOS asks for permission — choose Always Allow so later runs are unattended (the two delete shortcuts re-ask on every run by design; Apple offers no always-allow for deletion). things setup shortcuts --check and things doctor report installation state.
Once installed, these Shortcuts-only operations become available as ordinary commands: things heading add <project> <title> (create a heading in an existing project) and things todo clear-reminder <uuid> (clear a date-scheduled to-do's reminder while keeping its date). Both are also exposed over MCP (the heading tool's create action, and clear_reminder). If a required shortcut is missing, the command is blocked up front with a pointer back to things setup shortcuts — nothing is dispatched.
A handful of transforms exist on no scriptable surface at all — the URL scheme, AppleScript, and Shortcuts cannot express them. The fourth write vector (the "ui" vector) reaches them by driving the local Things app's real interface through macOS Accessibility. It unlocks: making an existing to-do or project repeat and rescheduling/pausing/resuming its rule; converting a to-do into a project; and moving an area to a new position in the global area order (the sidebar). See docs/design/ui-vector.md for the full model.
Because it drives the live GUI it is fail-closed and two-key gated: enable it once with things config set ui-enabled true, then acknowledge each individual call with --dangerously-drive-gui (dangerously_drive_gui over MCP). It carries the H-UI-DRIVE hazard, sits at the top disruption tier (3), and is intended for a dedicated, always-on Mac pinned to the English app language (a non-English UI fails the vector closed). Its recipes are fragile — an app-layout change can break them — so each op carries a per-op certification status (uncertified → lab-certified → on-device certified) recorded in the manifest at src/write/vectors/ui-certification.ts and surfaced by things capabilities and the things doctor ui-vector section; a successful drive of a not-yet-certified op returns a note saying so.
Setup — granting Accessibility to the driving process and verifying it with things doctor --probe-accessibility — is in docs/setup.md.
To get a global things command that runs the live TypeScript source (no build step — Node ≥ 24 strips types natively):
npm link # symlink this checkout as the global things-api package
asdf reshim nodejs # once, if you use asdf: expose the new `things` shimEdits under src/ take effect immediately. The bin launcher (bin/things.js) prefers src/ when present and falls back to dist/, so published installs (npm i -g things-api, npx things-api) run the compiled output with identical behavior.
npm run explore starts a localhost-only debug view (default 127.0.0.1:5711, --port <n> to change, --no-open to skip the browser): pick a preset or type any things … command and read the emitted envelope as a collapsible JSON tree, live against your default database. Reads run verbatim; every other command gets --dry-run force-appended server-side (the response echoes the exact argv that ran, forced flags included), so mutations can only ever produce plans. THINGS_DB / THINGS_NOW / THINGS_TZ set before launching apply to every command the view runs. Add --public to bind 0.0.0.0 for token-gated LAN access (phones/tablets on the same network): startup prints each LAN URL with a random per-launch ?key= that plants a signed-in cookie on first open.
- Reads go directly to Things' local SQLite database (read-only, WAL-aware). Writes go exclusively through official app surfaces — URL scheme, AppleScript, Shortcuts, and (opt-in) the Accessibility-driven GUI — never direct DB writes (sync corruption).
- Every mutation is verified: pre-read → hazard guards → execute → poll re-read until the expected delta appears. Silent no-ops are failures.
- Every mutation is audited: JSONL trail (
~/.local/state/things-api/audit/) with requested vs. observed deltas; auth tokens structurally redacted. - Schema drift is detected: table/column fingerprints keyed by Things' database version; writes hard-block on mismatch, and reads surface a non-blocking
meta.warningsnote rather than failing (drift runbook). - Disruption is explicit: every operation×vector combination carries a disruption tier (0 = invisible → 3 = drives the live UI); disruptive operations require explicit opt-in flags.
- Nothing is developed against production data: probing and integration tests run in disposable Tart macOS VMs.
The CLI is designed to be driven by coding agents with no out-of-band knowledge. The contract:
- Discovery:
things --help(a grouped one-line-per-command index; orientation detail lives behindthings help <topic>—agent,filters,ids,output,writes), per-commandthings <command> --help(behavior, side effects, and the exact acknowledgement flag names a write needs — regression-tested as API; by design it does not carry vector/tier/hazard vocabulary, which is banned from help text by docs/design/surface-copy.md — that classification lives incapabilities), andthings capabilities [--op <op>] --json(the lab-validated operation × vector support matrix with disruption tiers, hazards, per-op certification status, and probe-evidence ids). Command invocation follows one grammar (things <view>·things <type> <verb> <subject>· loosethings <verb> <subject>· barethings <subject>) with a single precedence chain — registered command/alias → view keyword → reference resolution — specified in docs/design/cli-grammar.md. - Structured output: every command takes
--json→ versioned envelope{ apiVersion, ok, kind, data|error, meta }on stdout; human chatter goes to stderr only. List views are bounded by default: the flat/chronological views (inbox,today,upcoming,logbook,trash,search,changes) return at most 50 items — raise with--limit <n>or lift with--all— and carry exact truncation counts inmeta.truncation { shown, total, limit, truncated }(the splittodayview also breaks the counts down per render section undersections). The grouped catalogues (anytime,someday) always show every area and project row and cap per block instead (no--limit):--area-limit <n>(default 30) per area block on both,--project-limit <n>(default 3) per project block on anytime,--show-active-project-items [n]for someday's trailing active-projects section — reporting the samemeta.truncationshape with a per-blockblocks[]breakdown (aggregateshown/total,limit: null), where each block is identity-carrying (kind,ref,title,shown,total) and project blocks nest inside their area block underchildren. A read whose database schema no longer matches this build's validated fingerprint carries a non-blockingmeta.warningsnote (the read still returns best-effort; the same drift hard-blocks writes). Same defaults and metadata apply over MCP. - Stable exit codes:
0ok ·2usage ·3verify-failed (mutation executed, expected delta never appeared) ·4blocked (hazard guard or disruption policy; error carriesremediation) ·5drift-blocked ·6unsupported (op has no supported vector —things batchalso aggregates to 6 when its only failures are unsupported ops) ·7environment. - Plan before executing: every write supports
--dry-run— compiled invocation (token-redacted), chosen vector, tier, hazards checked, expected delta. Nothing runs, nothing is audited. - No prompts, ever: risky semantics are explicit flags —
--children require-resolved|auto-complete(project completion cascades),--acknowledge-checklist-reset(checklist replacement destroys per-item state),--acknowledge-project-reopen(open child reopens a resolved project),--dangerously-permanent(area/tag delete and empty-trash skip the Trash),--dangerously-drive-gui(each Accessibility GUI-vector call). - Experimental surfaces are opt-in:
things reorder(ordering within Today, the Inbox, Someday, a project's to-dos, a project's headings, or an area) rides an undocumented AppleScript command that any Things update may remove. It requiresthings config set allow-experimental trueand re-checks the app's sdef declaration before every dispatch;things doctorreports both gates. Two scopes never touch it: This Evening and the top-level sidebar projects use verifiedwhen=round-trips (the "bounce", ≤10 items) instead. Reordering the areas themselves (the sidebar order) is a separate operation on the Accessibility GUI vector (things area reorder, MCPreorderwithscope=areas). - Consumer timezone (optional): date boundaries (today / evening / upcoming / logbook sweep / overdue / since-until) evaluate in the host's zone by default. Set
THINGS_TZ(an IANA zone) so they render for another calendar — e.g. an always-on Mac queried three zones away; over MCP the date-sensitive read tools and thewhen-bearing write tools also take a per-calltzthat overrides it.THINGS_NOW(an ISO instant) pins "now" for determinism. When either is in effect a response carriesmeta.clock { timezone, today }(absent on the host clock); invalid values fail closed (exit 2). Writingwhen= today resolves to that zone's date before dispatch;when= evening is refused when the consumer's day differs from the app machine's (This Evening exists only for the app machine's current day). Reminder times are wall-clock and never shifted. For a dedicated single-consumer host, aligning the system timezone (sudo systemsetup -settimezone <zone>) is the simpler path — see docs/design/contracts.md.
A typical mutation flow:
things capabilities --op todo.move --json # is it possible, which vector, what tier?
things search "Buy milk" --json # resolve the uuid (open items; scope with --project/--area/--tag, widen with --logged/--trashed/--all)
things todo move <uuid> --project "Errands" --dry-run --json # inspect the plan
things todo move <uuid> --project "Errands" --json # execute, verified
things changes --since 2026-07-05T08:00 --json # what changed since the agent last looked
things batch inbox-triage.jsonl # N ops, each guarded+verified, JSONL results
things undo --dry-run # inverse plan for the last mutation (audit replay)
things undo # execute it — verified like any mutationFailure modes are first-class: a verify-failed:silent-noop means the app accepted the command and did nothing (a real Things behavior the guards mostly prevent — see docs/things-app-oddities.md); blocked:* responses include machine-readable remediation.
An agent skill ships in the package: a compact orientation an agent loads once — the data model, how to refer to items, and the stable machine contracts — pointing at things --help and things help <topic> for the fast-moving mechanics. Install the skill and the rest self-maintains: CLI access is via whatever things (or npx) resolves, mechanics come from that binary's own help, and version nudges flow both ways.
Install (or update) it with one command:
things install-skill # global: covers every agent harness the skills CLI detects
things install-skill --project # into the current project's .agents instead
things install-skill --check # compare installed vs this binary's version, write nothingUnder the hood it hands the bundled skill to the skills CLI (npx -y skills add …), which keeps one canonical copy under ~/.agents/skills/ and materializes it into each detected agent's directory (~/.claude/skills/, ~/.codex/skills/, …). When that tool or the network is unavailable it falls back to a plain copy into ~/.agents and ~/.claude. Re-running things install-skill IS the update — it replaces the skill wholesale — so prefer it over a generic skills update for this skill (a single, unambiguous update path).
Three entry angles, all converging on the same working setup:
- CLI installed (
npm i -g things-api) →things --helpsuggeststhings install-skill. - No install →
npx things-api install-skillplaces the skill; the skill itself falls back tonpx -y things-api@latestfor every command, so it works with no global binary on PATH. - Skill first, zero npm knowledge →
npx skills add mikegreiling/things-apipulls the skill straight from GitHub (whateverskills/things-cli/currently holds).
To keep the two sides from drifting, install-skill stamps the copy it places with the running binary's own version, and (on human/--help output only, never --json) the binary notes when the installed skill is well behind that version. That notice reads the well-known skill directories under ~/.agents and ~/.claude; disable it with THINGS_API_NO_SKILL_CHECK=1.
The TypeScript library (import { openThings } from "things-api") is the product; the CLI and the MCP server are thin presentation layers over the same ThingsClient — every read view and every verified mutation is a client method first. Shared machine contracts (JSON envelope, exit codes) live in the core (contracts.ts), and diagnose() / capabilitiesTable() are library functions the surfaces merely render.
things mcp serves the Model Context Protocol over stdio. Configure it in any MCP client:
{ "mcpServers": { "things": { "command": "things", "args": ["mcp"] } } }The server instructions carry the user's live inventory (areas, tag hierarchy, open projects — read at server start) plus the reference/scheduling vocabulary, so models can name real destinations without a discovery round-trip. Tools mirror the client surface, grouped by area:
The catalog is consolidated around a few verb-parameterized tools (a discriminator — kind, scope, or action — selects the variant, with per-variant requirements validated at call time). This keeps every capability reachable while shrinking the static tool surface a model must load.
- Reads:
read_view(today / inbox / anytime / upcoming with occurrence horizon / someday / logbook / trash),search,changes_since,get_item,get_project,get_area,list_collections. - Editing:
update(kindtodo / project / area / tag),set_status(scopetodo / project — completed / canceled / open, with the project children policy),delete(kinditem / area / tag — Trash for items, guarded permanent delete for areas/tags incl. tag subtrees). - To-do writes:
add_todo,move_todo,set_tags,edit_checklist, plus completion/creation backdating (backdate_todo) and Logbook import (add_logged_todo). - Project writes:
add_project,move_project. - To-dos and projects alike:
restore_item,duplicate_item. - Create:
add_area,add_tag. - Headings & reminders (Shortcuts-backed where headless-impossible):
heading(actioncreate / rename / archive / unarchive / convert_to_project),clear_reminder. - Recurrence via the Accessibility GUI ("ui") vector (two-key gated —
ui-enabledconfig +dangerously_drive_guiper call):repeat(scopetodo / project;actionstart / reschedule / pause / resume / create). Theheadingconvert_to_project action and thereorderareas scope ride the same gated GUI surface. - Generic & discovery:
run_operation(the full 52-op catalog),batch,reorder(item ordering across scopes, plusscope=areasfor the sidebar area order),undo,capabilities,doctor.
Every write tool takes dry_run; the GUI-driven variants additionally require dangerously_drive_gui; tools carry read-only/destructive annotations; hazard blocks come back as structured tool errors carrying the same remediation text the CLI prints. Tool descriptions follow the consumer-voice contract in docs/design/surface-copy.md.