Operations Reference
The bridge exposes 16 operations, available via both MCP tools and CLI commands.
Task Operations
| Operation | MCP Tool | CLI Command | Description |
|---|---|---|---|
task.list | list_tasks | tasks list [filters] | List tasks with optional filters |
task.get | get_task | tasks get <id> | Get a task by ID |
task.create | create_task | tasks add <title> [flags] | Create a new task |
task.update | update_task | tasks update <id> [flags] | Update a task |
task.complete | complete_task | tasks complete <id> | Mark as done |
task.uncomplete | uncomplete_task | tasks uncomplete <id> | Mark as not done |
task.start | start_task | tasks start <id> | Start time tracking |
task.stop_current | stop_current_task | tasks stop-current | Stop current tracking |
task.get_current | get_current_task | tasks current | Get currently tracked task |
task.set_current | set_current_task | tasks set-current <id> | Set current task |
task.archive | archive_task | tasks archive <id> | Archive a task |
task.restore | restore_task | tasks restore <id> | Restore from archive |
Other Operations
| Operation | MCP Tool | CLI Command | Description |
|---|---|---|---|
project.list | list_projects | projects list | List projects |
tag.list | list_tags | tags list | List tags |
status.get | get_status | status | Get app status |
bridge.health | health | health | Check connectivity |
Task Create Fields
| Field | Type | Required | Description |
|---|---|---|---|
title | string | yes | Task title |
notes | string | no | Task notes |
projectId | string | null | no | Project to assign |
tagIds | string[] | no | Tags to assign |
parentId | string | no | Parent task (creates subtask) |
plannedAt | string | int | no | Planned date (ISO or epoch ms) |
dueDay | string | null | no | Due date (YYYY-MM-DD) |
dueWithTime | int | null | no | Due timestamp (epoch ms) |
isDone | boolean | no | Initial completion state |
timeEstimate | int ≥ 0 | no | Time estimate (ms) |
timeSpent | int ≥ 0 | no | Time already spent (ms) |
WARNING
parentId cannot be combined with projectId or tagIds (subtasks inherit from parent).
Task Update Fields
Same as create except: parentId is not allowed on update.
Task List Filters
| Filter | Type | Description |
|---|---|---|
query | string | Title substring (case-insensitive) |
projectId | string | Filter by project |
tagId | string | Filter by tag (TODAY for today's tasks) |
includeDone | boolean | Include completed tasks |
limit | integer 1–100000 | Return at most this many items. Over MCP, omitting it defaults to 20; over the CLI, omitting it returns everything |
offset | integer 0–100000 | Skip this many items before applying limit |
full | boolean | Return whole entities instead of the compact field set |
source | active | archived | all | Task pool (default: active) |
Over MCP the default exists because the bridge cannot know what the host will do with a very large tool result — spill it to a file, truncate it, or refuse it. The CLI has no such constraint and keeps returning everything, so a pipe into jq is unaffected. Pass limit=100000 over MCP to lift the cap; limit=0 is an error rather than a synonym for "everything", because a host filling an integer field with its zero default would otherwise request the whole store.
limit, offset and full apply to list_projects and list_tags too, and are applied by the bridge — Super Productivity ignores them. See Response size.
TIP
source=all expands the pool to include archived tasks but does not automatically show completed tasks.
Both source=archived and source=all require includeDone=true. Super Productivity applies the done filter to the archived pool regardless of a task's own isDone value, so a task archived while still open is invisible to source=archived on its own:
$ sp-local-bridge tasks archive <id>
{ "archived": true, "id": "<id>" }
$ sp-local-bridge tasks list --source archived
[]
$ sp-local-bridge tasks list --source archived --include-done
# the task is hereAn empty result from --source archived therefore does not mean the archive failed. This is Super Productivity's own filtering — the bridge passes source and includeDone through to the REST API unchanged.
The taskCount in get_status reflects the active pool including done tasks (not all tasks across all sources).
Error Codes
| Code | Meaning |
|---|---|
SP_UNAVAILABLE | Cannot connect to SP Local REST API |
UNAUTHORIZED | SP requires an access token, and none was sent or it was rejected |
TIMEOUT | Request timed out |
UNKNOWN_OPERATION | Operation not recognized |
UNSUPPORTED_OPERATION | Operation exists but is not implemented |
INVALID_INPUT | Payload validation failed |
TASK_NOT_FOUND | Task ID not found |
NOT_FOUND | The route does not exist — not the same as a missing task |
PROJECT_NOT_FOUND | Project ID not found |
SP_ERROR | SP returned an error |
INTERNAL_ERROR | Unexpected bridge error |
Two kinds of 404
Super Productivity answers both a missing task and a missing route with HTTP 404, distinguishing them only in the body:
$ curl -s http://127.0.0.1:3876/tasks/GHOST_ID
{"ok":false,"error":{"code":"TASK_NOT_FOUND","message":"Task not found"}}
$ curl -s http://127.0.0.1:3876/definitely-not-a-route
{"ok":false,"error":{"code":"NOT_FOUND","message":"Route not found"}}The bridge reads the body and passes SP's own code through, so TASK_NOT_FOUND means the task is genuinely absent. Earlier versions reported every 404 as TASK_NOT_FOUND, which sent anyone debugging a mistyped or removed route looking for a task that was never the problem.
A 404 whose body is empty or not JSON — something a proxy might produce, but not SP — reports SP_ERROR rather than guessing which was missing. An HTTP error status carrying {"ok":true} is likewise reported as an error: believing the body over the status would turn a failed request into a success.
Codes other than the table above can appear: translateEnvelope passes through whatever error.code Super Productivity supplies, so a future SP release could introduce one the bridge does not name. Consumers should branch on the codes they know and treat anything else as a generic failure rather than assuming the set is closed.
What this means for archive. Its existence guard reports TASK_NOT_FOUND only when SP's 404 body is an envelope whose error.code is TASK_NOT_FOUND — which is what SP 18.10.0 sends. If that body ever changes shape, the guard reports the underlying error instead (SP_ERROR, say) rather than the friendly message below.
The safety property does not depend on that wording. The archive is attempted only when the probe hands back the task it asked for — a JSON object whose id matches. A successful HTTP status is not sufficient on its own, because an empty body, a non-JSON body and {"ok":true,"data":null} all translate to a success with no data; treating any of those as "the task exists" would send the archive call for an id that was never confirmed.
Intentionally Excluded
task.delete— Destructive operation excluded by design. Use archive/restore instead.
Store integrity (doctor --deep)
doctor on its own checks that the bridge can reach Super Productivity. It does not check that the data coming back is coherent — a corrupt store answers every liveness check perfectly.
doctor --deep additionally cross-references task entities against the indexes that point at them (project.taskIds, project.backlogTaskIds, tag.taskIds, task.subTaskIds) and reports three conditions:
| Condition | Meaning |
|---|---|
| dangling | A project or tag references a task that does not exist in any pool. |
| orphaned | An active task exists that nothing references; it may be invisible in the UI. |
| duplicated | A task appears in both the active and archived pools — an archive or restore was only partially applied. |
Archived tasks are exempt from the orphan check: archiving removes a task from project.taskIds, so an unreferenced archived task is normal. They are still loaded, because a project may legitimately reference a task that now lives in the archive.
$ sp-local-bridge doctor --deep
...
Store integrity... OK
active tasks : 277
archived tasks : 17
referenced by index : 277--json prints only the report (and implies --deep), for scripting:
$ sp-local-bridge doctor --json
{
"activeTasks": 277,
"archivedTasks": 17,
"clean": true,
"dangling": [],
"duplicated": [],
"orphaned": [],
"referenced": 277,
"unconfirmed": false,
"unconfirmedReason": "",
"unresolved": []
}Exit codes
| Code | Meaning |
|---|---|
| 0 | All checks passed. |
| 1 | A check failed (for example, SP is unreachable), or no verdict could be reached. Takes precedence over 3, so an unrelated failure later in the run (the MCP self-check, say) masks a confirmed anomaly in the summary line. |
| 2 | Bad usage (unknown flag or unexpected argument). |
| 3 | An anomaly survived both passes. unconfirmed may still be true if the passes disagreed about other ids — a race elsewhere does not make a twice-seen anomaly less real. |
Because 1 outranks 3, a monitoring script should key on doctor --json, which runs only the integrity check and cannot be masked by an unrelated failure. The --deep exit code is for interactive use, where the printed report is visible alongside it.
--json follows the same table, so a script can distinguish a corrupt store (3) from a connection failure (1) without parsing output. On bad usage it writes the error and help to stderr, leaving stdout empty rather than contaminating the JSON stream.
The index fields are checked the same way. project.taskIds, project.backlogTaskIds, tag.taskIds and task.subTaskIds must each be present and hold a list of ids; a missing or malformed one is an error naming the field, not an empty index. Reading a missing index as "this project references nothing" would turn every task it owns into an orphan — a healthy store reported as corrupt — and because that result is deterministic it would survive both confirmation passes and be reported as confirmed. An empty list is legal: a project with no tasks is normal.
An unreadable index degrades the run rather than failing it. The reference-derived verdicts (dangling, orphaned) are withheld, because a reference set built from a broken index cannot be trusted — every task in that project would look orphaned. Everything derived from the task pools alone stays valid and is still reported: the pool counts, and duplicated, which needs no index at all. That matters, because in a real corruption event those are the signals worth keeping.
The report comes back unconfirmed with the reason naming the entity and field, and exits 1:
Store integrity... UNCONFIRMED
active tasks : 2
archived tasks : 0
referenced by index : 0
reason : /projects: p_broken is missing "taskIds"; cannot judge integrity
No verdict: see the reason above. Re-run once it is resolved.referenced by index reads 0 on this path rather than a partial count: the loops keep going past the first bad entity, so any figure would cover only the entities that happened to parse.
Monitoring should treat any non-zero exit as needing attention rather than keying on 3 alone, since this class of problem reports as 1.
If any of the four pulls returns something that is not a list — data: null, an empty body, a non-JSON payload — the check reports an error naming that endpoint and exits 1 rather than reading it as "zero entities". Treating a degenerate response as an empty collection would make a healthy store look corrupt, and the warning it prints tells the user not to restore a backup.
3 is distinct on purpose: it separates "cannot reach SP" from "SP answered, and its data is broken", which need different responses.
Anomalies are confirmed before being reported
The four requests are not an atomic snapshot of a live app. Adding a task in the UI between the task pull and the project pull would leave the new id in project.taskIds but not in the task set, which looks exactly like a dangling reference; deleting a project in that window makes its tasks look orphaned.
So when the first pass finds anomalies, the check runs again and sorts what it saw into two groups:
dangling/orphaned/duplicated— seen in both passes. These are confirmed and reported as an inconsistency (exit 3). A race elsewhere in the store does not make them less real.unresolved— seen by only one pass. Most often the store being edited mid-check, but it could also be corruption appearing or clearing, so these are recorded rather than discarded.
Reconciliation is per (category, id), not per id, because the categories are not mutually exclusive — one task can be orphaned and duplicated at the same time. So the two groups are not disjoint id sets: the same id can appear in a confirmed list and in unresolved, meaning the passes agreed about one of its symptoms and disagreed about another. Do not sum the lists to get a task count; take the union.
unconfirmed is true when unresolved is non-empty, or when the confirmation pass could not run at all. unconfirmedReason carries the second pass's error in that case, so "SP became unreachable" is not misreported as concurrent editing. If the confirmation pass fails, nothing was seen twice, so every anomaly moves to unresolved and none are reported as confirmed.
clean is true only when neither pass saw anything. A report that is unconfirmed with no confirmed anomalies reaches no verdict and exits 1 — re-run with the app idle.
If it warns
Restart Super Productivity and re-run — an inconsistency confined to the renderer's in-memory store clears on reload, since the persisted database is usually intact.
Do not import a backup while this warning is showing. Backups are written from the same in-memory state, so a backup taken during an inconsistency captures it, and restoring one can turn a recoverable glitch into real data loss.
Archiving and existence
archive_task / tasks archive checks that the task is in the active list before archiving it, and returns TASK_NOT_FOUND when it is not:
$ sp-local-bridge tasks archive GHOST_ID
Error [TASK_NOT_FOUND]: Task is not in the active list, so it was not archived now. It may already be archived, or never have existed.
$ echo $?
1This is a bridge-side guard. Super Productivity's own endpoint answers {"ok":true,"data":{"id":"...","archived":true}} for ids that never existed, unlike get, update, start and restore, which all return TASK_NOT_FOUND.
Do not reproduce this against a real store
Posting to the archive route with an id that does not exist is what crashed Super Productivity's renderer and left its in-memory store inconsistent — see the note at the end of this section. There is deliberately no copy-pasteable command here.
To confirm the upstream behaviour, use a throwaway profile:
superproductivity --user-data-dir=/tmp/sp-scratchThe API port is a hardcoded 3876 and Super Productivity takes a single-instance lock, so the real app must be closed first.
The guard matters most for automation, where a mistaken or invented id would otherwise produce a confident success and be reported as a completed archive — the one operation that gave no signal to correct.
Note that an already-archived task also reports TASK_NOT_FOUND, since it is no longer in the active list. GET /tasks/:id resolves the active pool only — verified against SP 18.10.0, where a task confirmed present in the archive returns 404 from that route. Use --source archived --include-done to check whether it is already there before treating this as a failure.
The guard narrows the problem but does not eliminate it. If a task is archived or deleted by someone else between the check and the archive call, the call still goes out against an id no longer in the active pool. That case is not harmless — it is the one that crashed Super Productivity's renderer and left its in-memory store inconsistent — so closing the window needs a fix upstream. It is reported there as #9946 (the store collapse) and #9945 (the backup writer persisting the result).
A transport failure is never recast as a missing task: if Super Productivity is unreachable, the error is SP_UNAVAILABLE.
Verifying against a live Super Productivity
The offline suite replays committed fixtures, so it cannot notice if Super Productivity changes what it returns — or if a fixture describes a response SP never sends. Both have happened.
make test-liveThis runs a build-tagged suite (//go:build live) against a running SP and checks two things:
The fields callers depend on are present with the types expected. A required field missing from even one object fails. Not all of them are read by the bridge itself —
doctor --deepreadssubTaskIds,taskIdsandbacklogTaskIds; most of the rest pass through to the caller — but a silent rename in any of them reaches the user rather than the bridge.The response fixtures do not invent fields. Every field a fixture claims must exist in a real response with a matching type. Fixtures may be smaller than reality; they may not be fiction.
Fields SP returns only when set —
notes,dueDay,parentIdand the like — are excused when a store has none, and still type-checked on a store that has some. Request fixtures and error fixtures are not covered: they describe what the bridge sends, or a failure a read-only run cannot provoke.
It also asserts that a missing task and a missing route still report different codes, since the archive existence guard depends on telling them apart.
Every request is a GET. The suite is excluded from go test ./... and from CI, which has no Super Productivity to reach and could not start one — the API port is a hardcoded 3876.
There is no --update mode. Fixtures are committed to a public repository and live responses carry real task titles, project names and notes, so fixtures are written by hand from the shapes the failure messages report.
Response size
Super Productivity returns whole entities, and the whole entity is rarely what a caller wants. On a store of 284 tasks an unfiltered list_tasks with includeDone was about 94KB — roughly 24k tokens — and most of it was fields nothing consumes: a per-day time map that grows for the life of a task, theme colours, worklog export column lists.
SP has no server-side projection or paging: limit, offset, page and fields are all ignored by the API. So the bridge does it.
Compact by default
List responses carry a reduced field set unless you ask for more:
| Entity | Returned by default |
|---|---|
| task | id, title, isDone, projectId, tagIds, parentId, subTaskIds, timeSpent, timeEstimate, notes, dueDay, dueWithTime, plannedAt, doneOn |
| project | id, title, isArchived |
| tag | id, title |
Fields absent from a response stay absent rather than becoming null — SP distinguishes the two, and so does the rest of the bridge.
full: true (--full) returns entities untouched. It is rarely what you want: it multiplies the response and adds fields that are usually noise.
Limit and offset
sp-local-bridge tasks list --include-done --limit 20
sp-local-bridge tasks list --limit 20 --offset 20 # next pageMeasured against the same 284-task store. The token column is a bytes / 4 estimate and runs about a third low: measured against a tokenizer, this project's payloads are 2.7 bytes per token, because the opaque nanoid task IDs tokenize at 1.40 bytes each. Treat these as relative comparisons, not as absolute budgets:
| Request | Size | ≈ tokens |
|---|---|---|
list_tasks includeDone before this change | 94KB | 24,000 |
list_tasks includeDone compact | 74KB | 18,500 |
list_tasks includeDone limit=20 | 5.9KB | 1,500 |
list_projects before / compact | 15.5KB / 1.1KB | 3,900 / 270 |
Projection alone is a modest win on tasks, because task payloads are mostly title and id — irreducible. limit is the control that matters, and filtering (projectId, tagId, query) matters more still.
Truncation is reported
When limit cuts a list, the response says so rather than leaving a short list looking like a complete one:
$ sp-local-bridge tasks list --limit 3
[ ...3 tasks... ]
Note: truncated — showing 3 of 176 matching items. # stderr, so piped JSON stays cleanOver MCP the same fact arrives as a second text block and as truncated, returned and matched alongside the result. This matters because the tool description pushes callers toward passing a limit: without a signal, "how many tasks are in project X?" answered with limit: 20 returns exactly 20 and reads as a complete count. get_status.taskCount cannot stand in — it counts the whole active pool and cannot answer a filtered question.
There is deliberately no default limit. A silently capped list would be a wrong answer to "how many tasks do I have", and the count belongs to get_status.taskCount rather than to the length of a truncated array.
Durations
Time values are milliseconds in the API, which made a 1h30m estimate --time-estimate 5400000 — easy to get wrong by an order of magnitude and impossible to read back.
The CLI now accepts either form:
sp-local-bridge tasks update <id> --time-estimate 1h30m
sp-local-bridge tasks update <id> --time-estimate 90m
sp-local-bridge tasks update <id> --time-estimate 5400000 # still millisecondsA bare integer keeps its original meaning, so existing scripts are unaffected. A unit suffix (s, m, h, and a leading d for days) is parsed as a duration. MCP callers continue to send integer milliseconds, and the tool descriptions carry conversion examples.
Output formats
The data commands — health, status, tasks, projects and tags — emit JSON by default, and that has not changed: the JSON output is an interface, so anything already parsing it keeps working. --format selects a different rendering and is accepted after any of those five.
doctor, print-config and configure do not take it. doctor is a diagnostic rather than an entity view and has its own --json; the other two print a host config file, which has no table form.
$ sp-local-bridge tasks list --include-done --format table
ID DONE TITLE DUE SPENT/EST
task-abc123 no Review budget spreadsheet 2026-06-01 15m/30m
task-def456 yes Send weekly report - -/-
$ sp-local-bridge tasks list --include-done --format ids
task-abc123
task-def456| Format | Output |
|---|---|
json | Indented JSON. The default, unchanged. |
table | Aligned columns, one line per item. A single entity prints as KEY VALUE lines. |
ids | One id per line and nothing else, for xargs and shell loops. |
Columns are fixed per entity — task, project and tag each have their own set, drawn from fields the compact projection already returns, so a table costs no extra requests. Project and tag names are deliberately not columns on the task table: projectId and tagIds are ids, and resolving them to names would mean a second and third request on every render.
timeSpent and timeEstimate are rendered the way --time-estimate reads them, so a value shown as 1h30m can be typed straight back in. Zero reads as -: SP stores 0 for a task nobody has tracked or estimated, which is most of them.
Nothing is truncated. Columns are sized to their contents, so a long title makes a long line rather than a hidden one. That also keeps the output identical on every platform — measuring the real terminal width from Go's standard library would need a per-OS ioctl behind build tags.
--full shapes JSON only, and is rejected with table or ids rather than silently ignored:
$ sp-local-bridge tasks list --full --format table
Error: Flag --full applies to --format json only; table and ids have a fixed shape--format ids is refused on health and status, which return no entity and so carry no id. It is refused before the request goes out, so it costs no round trip.
The truncation note stays on stderr in every format. That is what keeps --format ids | xargs receiving ids and nothing else.