awesome-things
v1.7.0
Published
Awesome swiss knife for Things3 – MCP server for AI, CLI, HTTP API, JS/TS API and Web
Maintainers
Readme
📋 awesome-things
🤖 MCP server — connect ChatGPT, Claude & Cursor to manage your tasks
⚙️ CLI — automate from the terminal, cron jobs & pipelines (thi, things)
🌐 HTTP REST API — build your own frontend; OpenAPI spec + Swagger UI included
📦 JS/TS API — use Things3 as a fully-typed library
🔗 Built-in tunnels — expose your Mac via localtunnel, ngrok or frp
🔐 Bearer-token auth — protect remote access out of the box
✅ 21 operations — todos, projects, tags, areas, move, delete & batch
🍏 AppleScript-native — talks to Things3 directly, validated with Zod
Installation
# Library / programmatic use
npm install awesome-things
yarn add awesome-things
pnpm add awesome-things
bun add awesome-things# Global CLI — exposes the `awesome-things`, `things` and `thi` binaries
npm install -g awesome-thingsOr run any command on demand without installing:
npx awesome-things <command>Basic Usage
Expose Things3 to a remote AI client (ChatGPT, Claude) in one command:
npx awesome-things server --tunnel
# ➜ MCP: https://my-awesome-things3-mcp.loca.lt/mcp/auth/uvhSdaAsd1qmtAnHa895bcjwTAnBxw- Run the command in your terminal and copy the printed MCP URL.
- Paste the URL into your AI client (ChatGPT, Claude, …).
- Ask the agent to manage your tasks.
What the AI can do once connected:
- Create a todo "Buy groceries" in Today
- Show all todos for today
- Complete "Buy groceries"
- Search for todos with "meeting"
- Create project "Q1 Planning" in area "Work"
Usage
As an MCP server
Local (stdio) — add to your MCP client configuration:
{
"mcpServers": {
"things3": {
"command": "npx -y awesome-things mcp"
}
}
}Remote (HTTP over a tunnel) — point your client at the URL printed by server --tunnel:
{
"mcpServers": {
"things3": {
"type": "http",
"url": "https://my-awesome-things3-mcp.loca.lt/mcp/auth/uvhSdaAsd1qmtAnHa895bcjwTAnBxw"
}
}
}ChatGPT — Settings → Beta Features, enable "Developer Mode" and "MCP apps", then add the server in the UI (no file editing).
Claude Desktop — ~/Library/Application Support/Claude/claude_desktop_config.json
Claude Code — .claude/settings.json (project) or ~/.claude/settings.json (global)
Cursor — .cursor/mcp.json (project) or ~/.cursor/mcp.json (global)
As a CLI
Install globally (
npm i -g awesome-things) to avoid thenpxstartup delay.thiandthingsare aliases forawesome-things.
thi list today
thi add "Buy milk" --list today
thi done "Buy cheese"
thi search "report"
thi info # which build is running, and where it came fromGlobal flags: --json for machine-readable output, --format pretty|table|plain (-f) for the rendering style.
# Create a todo
things add "Buy milk"
things add "Submit report" --notes "Q1" --due 2026-03-01 --tags work urgent --list today
things add "Sand the stool" --when 2026-09-12 # Things' "When", not the deadline
things add "Call the bank" --when tomorrow
things add "Standup" --when 2026-09-12@11:00 # with a time = phone notification
things add "Fix leak" --area Home
things add "Plan sprint" --project "Q1 Planning"
# List todos (inbox | today | anytime | upcoming | someday | logbook)
things list # today (default)
things list inbox
things list today --status open
# Finish a todo — three different things (by name or --id)
things done "Buy milk" # done → Logbook, as completed
things cancel "Buy milk" # cancelled → Logbook, will not happen
things rm "Buy milk" # deleted → Trash (alias: things trash)
# Batch: the same operation over several ids in one go
things rm --ids ABC123 DEF456 GHI789
things done --ids ABC123 DEF456
# Update a todo
things update "Submit report" --new-name "Submit Q1 report"
things update "Submit report" --new-due 2026-03-15
things update "Submit report" --new-due none # clear the deadline
things update "Submit report" --new-when 2026-09-12 # YYYY-MM-DD[@HH:MM] | today | tomorrow | evening
things update "Submit report" --new-when none # clear the When date
things update --ids ABC123 DEF456 --new-tags work # same update, several todos
# Checklists (need AWESOME_THINGS_URL_TOKEN)
things add "Pack" --checklist Passport Tickets Charger
things update "Pack" --new-checklist Passport Tickets
# Search
things search "report"
# Projects
things project add "Q1 Planning" --area Work
things project add "Trip" --todo "Book flight" --todo "Pack" # project + its todos in one call
things project update "Q1 Planning" --new-notes "See https://example.com/roadmap"
things project list
things project todos "Q1 Planning"
things project rm "Q1 Planning" # project + its todos → Trash
# Tags & areas
things tags
things areas
things area Work # todos sitting directly in an area (no project)
# Move (destination: inbox | today | evening | anytime | upcoming | someday)
things move todo "Buy milk" today
things move todo-to-project "Submit report" "Q1 Planning"
things move todo-to-area "Submit report" Work
things move project-to-area "Q1 Planning" Work
# Remove associations
things remove todo-from-project "Submit report"
things remove project-from-area "Q1 Planning"
# Environment info — version, which binary is actually running, where it came from
things info
things info --jsonAs an HTTP REST API
things server # default port 32123, random Bearer token printed on startup
things server --no-token # disable auth (local use)
things server --port 8080 # custom portThe server also serves a home page at /, a health check at /health, Swagger UI at /api, and the raw OpenAPI spec at /api/openapi.json.
| Method | Path | Description |
|---|---|---|
| GET | /api/todos?list=today&status=open | List todos |
| POST | /api/todos | Create todo |
| PUT | /api/todos | Update todo |
| POST | /api/todos/complete | Complete todo |
| POST | /api/todos/cancel | Cancel todo |
| POST | /api/todos/delete | Delete todo (→ Trash) |
| GET | /api/todos/search?q=... | Search todos |
| GET | /api/projects?area=... | List projects |
| POST | /api/projects | Create project |
| POST | /api/projects/delete | Delete project (→ Trash) |
| GET | /api/projects/:name/todos | Project todos |
| GET | /api/tags | All tags |
| GET | /api/areas | All areas |
| GET | /api/areas/:name/todos | Todos directly in an area |
| POST | /api/move/todo | Move todo to list |
| POST | /api/move/todo-to-project | Move todo to project |
| POST | /api/move/todo-to-area | Move todo to area |
| POST | /api/move/project-to-area | Move project to area |
| POST | /api/remove/todo-from-project | Remove from project |
| POST | /api/remove/project-from-area | Remove from area |
| GET | /mcp, /mcp/auth/{token} | MCP-over-HTTP transport |
curl http://localhost:32123/api/todos?list=today
curl -X POST http://localhost:32123/api/todos \
-H "Content-Type: application/json" \
-d '{"name": "Buy milk", "list": "today"}'
curl "http://localhost:32123/api/todos/search?q=report"
# When auth is enabled, pass the printed token:
curl http://localhost:32123/api/tags -H "Authorization: Bearer <token>"As a background daemon
things server dies with its terminal. things daemon installs the same server as a launchd
user agent: it starts at login, restarts itself after a crash, and writes both streams to log
files you can tail at any time.
things daemon install # write the agent, start it, print URL + token
things daemon install --port 8080 --tunnel frp --domain things.example.com
things daemon install --no-start # only write the plist
things daemon status # running? healthy? which pid, which token
things daemon restart # pick up a new build
things daemon stop # stop without uninstalling
things daemon uninstall # stop and remove the agent (--purge also drops the logs)The log holds events, not the startup banner: one started line (version, pid, URL, auth,
config file), tunnel when one opens, stopped on SIGTERM, then a line per request. The token
and the MCP client configs are only printed to a terminal.
Watching it work — the daemon has no terminal, so the logs are the way in:
things daemon logs # last 50 lines of both streams
things daemon logs -f # follow live (Ctrl+C to stop)
things daemon logs -f --err # only errors
things daemon logs -n 200 --out # last 200 request lines
things daemon logs --clear # truncate both files| What | Where |
|---|---|
| Agent | ~/Library/LaunchAgents/com.isuvorov.awesome-things.plist (mode 600) |
| Requests | ~/.local/share/awesome-things/logs/server.log |
| Errors | ~/.local/share/awesome-things/logs/server.error.log |
| Health | curl http://localhost:32123/health |
Notes:
- The bearer token is pinned into the agent at install time (taken from
--token,AWESOME_THINGS_TOKEN, the previously installed agent, or generated). A daemonized server cannot print a fresh random token anywhere you would see it. installis safe to repeat: with the same flags and environment the plist comes out identical and the running daemon is left alone. Only a real change restarts it.- launchd starts a job with an almost empty environment.
installbakes inPATH,HOMEand everyAWESOME_THINGS_*/FRP_*/NGROK_AUTHTOKENvariable from the shell you ran it in — change one of them and runthings daemon installagain. - Automation is granted to
awesome-things, not to bun or node. The daemon runs through a tiny launcher app (~/.local/share/awesome-things/awesome-things.app, built and signed on install), so macOS asks "awesome-things wants to control Things3" and the permission covers this app only — never every script on your runtime. Allow it on the first Things3 call; it is listed under System Settings → Privacy & Security → Automation. Building it needs the Command Line Tools (xcode-select --install);--no-launcherskips it. - The icon in Login Items needs a real certificate. macOS takes a background item's name and
icon from its code signature, and an ad-hoc signature has none of that — the item shows as a
generic "exec". If your keychain has a Developer ID Application or Apple Development
certificate (the latter is free: sign in to Xcode with your Apple ID),
installsigns the launcher with it automatically. Pick one withAWESOME_THINGS_SIGN_IDENTITY(SHA-1 or part of the name), or-for ad-hoc. - Logs are rotated to
.1on install/restart once they pass 10 MB — launchd rotates nothing itself. - macOS only; on other platforms the command refuses instead of pretending.
As a JS/TS library
import { createTodo, listTodos, completeTodo } from 'awesome-things';
// Create a todo
await createTodo({ name: 'Buy milk', list: 'today' });
// List today's open todos
const todos = await listTodos({ list: 'today', status: 'open' });
// Complete a todo
await completeTodo({ name: 'Buy milk' });Configuration
Tunneling
Built-in tunnel support exposes the local server for remote access — useful for connecting AI clients (ChatGPT, Claude) to Things3 on your Mac. Three providers are supported.
# localtunnel (default) — free, no signup, no config
things server --tunnel
things server --tunnel --domain myapp # custom subdomain
# ngrok — needs an auth token
things server --tunnel=ngrok --ngrok-token=YOUR_TOKEN
NGROK_AUTHTOKEN=YOUR_TOKEN things server --tunnel=ngrok
things server --tunnel=ngrok --domain myapp.ngrok-free.app
# frp — self-hosted reverse proxy; needs the `frpc` binary + your own frp server
FRP_SERVER_ADDR=frp.example.com things server --tunnel=frp
FRP_SERVER_ADDR=frp.example.com things server --tunnel=frp --domain myappConfig file
Settings live in ~/.config/awesome-things/config.json (or $XDG_CONFIG_HOME/awesome-things/,
or any path in AWESOME_THINGS_CONFIG). Every key mirrors an environment variable below:
{
"$schema": "https://unpkg.com/awesome-things/config.schema.json",
"port": 32123,
"token": "your-bearer-token",
"urlToken": "things-url-scheme-token",
"tunnel": "frp",
"domain": "things.example.com",
"frp": { "serverAddr": "frp.example.com", "serverPort": 7000, "token": "frp-token" },
"ngrok": { "authtoken": "ngrok-token" }
}- Comments: JSON has none, so any key starting with
_or//is ignored at every level —"_frp": {...}switches a section off,"_note": "..."explains a value. Any other unknown key is a typo and an error. - Precedence: CLI flag > environment variable >
config.json> default. $schemagives your editor autocompletion and validation; the schema ships in the npm package.- An invalid file stops the process with the exact field at fault — a server never starts with
half a config. Keep it
chmod 600: it holds tokens, and a looser mode prints a warning. - The daemon rereads the file on every start, so after editing it
things daemon restartis enough. Values that come from the file are never frozen into the launchd plist.
Environment variables
| Variable | Purpose | Default |
|---|---|---|
| AWESOME_THINGS_PORT / PORT | HTTP server port | 32123 |
| AWESOME_THINGS_TOKEN | Bearer token for the HTTP/MCP API | random per start |
| AWESOME_THINGS_TUNNEL | Tunnel provider (localtunnel, ngrok, frp) | — |
| AWESOME_THINGS_DOMAIN | Tunnel domain / subdomain | — |
| AWESOME_THINGS_URL_TOKEN | Things URL-scheme token — needed for evening, reminder times (--when 2026-09-12@11:00) and checklist items. Things → Settings → General | — |
| NGROK_AUTHTOKEN | ngrok auth token | — |
| FRP_SERVER_ADDR | frp server address (required for frp) | — |
| FRP_SERVER_PORT | frp server port | 7000 |
| FRP_TOKEN | frp authentication token | — |
| FRP_PROTOCOL | frp protocol (http or https) | https |
| FRP_SUBDOMAIN | frp subdomain | — |
| FRP_REMOTE_PORT | frp remote port mapping | — |
| FRP_PROXY_NAME | proxy name in frpc | things |
All
FRP_*variables also accept theAWESOME_THINGS_FRP_*prefix.
How it works
AppleScript is the only programmatic interface to Things3 on macOS. Each operation is a typed function that builds an AppleScript snippet, runs it via osascript, and parses the result. The four interfaces are thin layers over the same set of operations.
| Module | Responsibility |
|---|---|
| src/api.ts | Public JS/TS API — re-exports all functions and types |
| src/mcp.ts | MCP server (stdio transport) |
| src/server.ts | HTTP REST API + MCP-over-HTTP (Bun.serve) |
| src/cli.ts | CLI (yargs) — things / thi / awesome-things |
| src/api/*.ts | The 21 todo / project / area / list / move operations |
| src/utils/applescript.ts | execute, tellThings, quoteString helpers |
| src/utils/tunnel.ts | localtunnel / ngrok / frp providers |
| src/utils/auth.ts | Bearer-token resolution & checks |
All operations identify todos and projects by name or by id, and every input is validated with a Zod schema. Todo operations also accept ids: string[] and then run as a batch.
Done, cancelled and deleted are three different things. completeTodo puts a todo in the
Logbook as done — do not use it to get rid of a todo you created by mistake, or the Logbook ends
up full of work nobody did. Use cancelTodo when the work will not happen, and deleteTodo /
deleteProject (which move the item to the Trash) when it should never have existed.
What Things3 itself cannot do: recurring todos (repeat) and headings inside a project are
exposed by neither AppleScript nor the URL scheme, so this package cannot set them — Repeat has to
be set by hand in Things, and headings need a things:///json payload.
macOS permissions
Things3 must be running, and the app that launched the process (Terminal, your editor, an agent
runtime) needs Automation access to Things3 in System Settings → Privacy & Security →
Automation. Without it macOS withholds the scripting dictionary and every call fails with
-2740 / -2741 / -1743 — the package detects that and says so instead of forwarding raw
AppleScript noise. Sandboxed environments without Apple Events access cannot reach Things3 at all.
API Reference
Core functions
import {
createTodo,
searchTodos,
updateTodo,
createProject,
moveTodoToProject,
deleteTodo,
} from 'awesome-things';
// Create with all options
await createTodo({
name: 'Submit report',
notes: 'Q1 financials',
due_date: '2026-03-01',
tags: ['work', 'urgent'],
list: 'today',
});
// Create a todo directly in an area
await createTodo({ name: 'Fix leak', area: 'Home' });
// Search across all lists
const results = await searchTodos({ query: 'report' });
// Update a todo
await updateTodo({ name: 'Submit report', new_due_date: '2026-03-15' });
// Create a project together with its todos
await createProject({
name: 'Q1 Planning',
area: 'Work',
todos: ['Draft the roadmap', { name: 'Review with the team', when: 'tomorrow' }],
});
await moveTodoToProject({ todo_name: 'Submit report', project_name: 'Q1 Planning' });
// Trash seven todos in one call — and see which ones failed
const result = await deleteTodo({ ids: ['ABC', 'DEF', 'GHI'] });
result.ids; // the ones that worked
result.results; // [{ id, ok, message }, ...]Every todo function accepts ids: string[] instead of id to run as a batch.
createTodo({ name, notes?, due_date?, when?, tags?, list?, project?, area?, checklist? })— create a todolistTodos({ list, status? })— list todoscompleteTodo({ name?, id?, ids? })— mark as done (→ Logbook)cancelTodo({ name?, id?, ids? })— mark as cancelled (→ Logbook)deleteTodo({ name?, id?, ids? })— delete (→ Trash)updateTodo({ name?, id?, ids?, new_name?, new_notes?, new_due_date?, new_when?, new_tags?, new_checklist? })— update a todosearchTodos({ query })— search todos by namecreateProject({ name, notes?, area?, todos? })— create a project, optionally with its todosupdateProject({ project_name, new_name?, new_notes?, new_due_date?, new_tags?, new_area? })— update a projectdeleteProject({ project_name })— delete a project and its todos (→ Trash)listProjects({ area? })— list projectsgetProjectTodos({ project_name, status? })— get project todosgetAreaTodos({ area_name, status? })— todos sitting directly in an arealistTags()— list all tagslistAreas()— list all areasmoveTodo({ todo_name?, id?, ids?, destination })— move todo to a listmoveTodoToProject({ todo_name?, id?, ids?, project_name })— move todo to a projectmoveTodoToArea({ todo_name?, id?, ids?, area_name })— move todo to an areamoveProjectToArea({ project_name, area_name })— move project to an arearemoveTodoFromProject({ todo_name?, id?, ids? })— remove todo from its projectremoveProjectFromArea({ project_name })— remove project from its area
Subpath imports
import { createTodo } from 'awesome-things'; // main aggregator
import { createTodo } from 'awesome-things/api'; // direct API
import 'awesome-things/mcp'; // MCP server
import 'awesome-things/server'; // HTTP server
import 'awesome-things/cli'; // CLICustom AppleScript
import { execute, tellThings } from 'awesome-things';
const result = await execute(
tellThings('show quick entry panel with properties {name:"Hello"}'),
);Examples
Add a todo for every .log file in the current directory:
for f in *.log; do thi add "Review $f" --list today; doneList today's open todos as JSON and pipe into jq:
thi list today --status open --json | jq '.todos[].name'Tests
bun run test # lint + types + unit tests + size-limit
bun run test:unit # unit tests only (bun:test)Unit tests cover the pure functions (AppleScript helpers, Zod schemas) and the HTTP server. Integration tests require macOS with Things3 running.
Development
bun install
bun run build # build (tsdown -> lib/)
bun run dev # watch mode
bun run start # MCP server (stdio)
bun run server # HTTP API server
bun run cli # run the CLI
bun run fix # auto-fix lint & formatting (biome)Limitations
- macOS only — Things3 has no API other than AppleScript.
- Things3 must be installed and running.
- Operations identify items by name (or
id); duplicate names act on the first match.
License
MIT © Igor Suvorov — see LICENSE.
awesome-things — Manage Things3 from anywhere 📋
