New: AI spend limits now cover the agent builder and the project planner →
Product
AI Teammates
Solutions
Resources
PricingEnterprise
Start free Book a demo Log in
For developers

REST API

Every endpoint the app uses, with personal API tokens for scripts and integrations. JSON in, JSON out.

Base URL: <PUBLIC_API_URL>/api (local: http://localhost:4000/api). JSON in, JSON out. AI agents usually prefer the MCP server (the MCP docs), which wraps the same operations with friendly names.

Authentication#

WhoHow
The web apphttpOnly session cookie tc_session (or Authorization: Bearer <session JWT>).
People signing in through their company (SAML)The same session cookie, confined to that workspace — see Single sign-on and provisioning.
Scripts, integrations, external agentsPersonal API token: Authorization: Bearer tc_…
An identity provider provisioning people (SCIM)Provisioning token: Authorization: Bearer tcscim_… on /api/scim/v2/* only.
The agent runnerx-internal-token: <INTERNAL_API_TOKEN> on /api/internal/* only.

Create tokens in Settings → Developers & AI agents, or with a signed-in session:

POST /api/workspaces/:wid/tokens      { "name": "CI", "expiresInDays": 90 }   → 201 { id, name, prefix, expiresAt, …, "token": "tc_…" }
GET  /api/workspaces/:wid/tokens      → your tokens in that workspace (never the secret)
DELETE /api/tokens/:id                → 204 (revoke; owner only)

The full token is returned once; only its SHA-256 hash and a short prefix are stored. expiresInDays may be omitted/null for no expiry. lastUsedAt is updated (at most once a minute).

A token acts as its owner, confined to its workspace: any request touching another workspace answers 404, and GET /api/workspaces / GET /api/auth/me only list that workspace. Tokens are refused (403) on token management, workspace members and invites, project/list membership changes, workspace creation, calendar subscribe links, the access review, every account route (/api/auth/* except /api/auth/me), approving or rejecting an AI teammate's action (a person must decide), filling in forms (/api/f/*) and the inbound email webhook (/api/inbound/*, which has its own secret).

Accounts#

POST /api/auth/register            { name, email, password }   → 201 { user, token } + cookie; emails a confirmation link
                                   409 EMAIL_TAKEN when the address has an account
POST /api/auth/login               { email, password }         → 200 { user, token } + cookie
GET  /api/auth/me                  → { user: { …, emailVerified, hasPassword }, workspaces, mailEnabled, ssoSession }
                                   (`ssoSession`: a single sign-on session confined to its workspace)
POST /api/auth/forgot-password     { email }                   → 202 { delivery: "email" | "admin" } (same answer for every address)
POST /api/auth/reset-password      { token, password }         → 200 { user, token } + cookie; 400 RESET_LINK_INVALID
POST /api/auth/verify-email        { token }                   → 200 { verified: true }; 400 VERIFY_LINK_INVALID
POST /api/auth/verify-email/resend → 204 (signed in)
POST /api/auth/change-password     { currentPassword, newPassword } → 200 { token } + cookie; 403 WRONG_PASSWORD
POST /api/workspaces/:wid/invites  { email, role }  → { status: "added" | "invited", email, mail?: "sent" | "failed" | "off", invite? }
POST /api/workspaces/:wid/invites/:iid/resend       → { mail } (admins)

Links in emails point to the first WEB_ORIGIN entry (/reset-password#t=… valid 1 hour and once, /verify-email#t=… valid 3 days, /invite/<token> valid 7 days). Sessions carry a session version: a password reset or change signs out every other session at once (401, the cookie is cleared). An invite adds an existing account directly only when its email is confirmed; otherwise the invite is emailed (inviting the same address again reuses the pending invite). mail: "off" = this server sends no email (share the link). Too many attempts answer 429 RATE_LIMITED (see the security notes).

Errors#

Every error has the shape { "statusCode": 400, "code": "INVALID_STATE", "message": "…", "details": … }. 401 = missing/invalid/expired token, 403 = not allowed, 404 = not found or not visible to you.

Main endpoints#

Workspace → projects → items (task lists TASKS, wikis WIKI, canvases CANVAS). Task lists own their workflow states, labels and custom fields.

Structure#

GET  /api/workspaces/:wid                    workspace
GET  /api/workspaces/:wid/tree               projects with their items and folders (everything you can see): each project has
                                             `folders: [{ id, parentId, name, position }]` and each item a `folderId` (null = top
                                             level). A folder whose contents are all hidden from you is left out
GET  /api/workspaces/:wid/projects           projects
POST /api/workspaces/:wid/projects           { name, key?, description?, color?, icon?, visibility?, templateKey? } (projects are
                                             flat: each one has its own members and access)
GET  /api/projects/:pid                      a project with its items, folders and members
DELETE /api/projects/:pid                    archives the project (managers)
GET  /api/projects/:pid/status-pdf           "Status report (PDF)", as you see the project (anyone who can open it): progress
                                             and each list you can open, overdue / due in 14 days / done in 7 days (≤ 10 each
                                             and how many), what changed this week and who works on what; A4, 6 a minute per
                                             person (else 429)
GET  /api/projects/:pid/evidence-pack/estimate   (managers) { lists, tasks, comments, files, bytes, over: ('tasks' |
                                             'files' | 'bytes')[], limits: { packTasks, packFiles, packBytes }, inProgress:
                                             'yours' | 'others' | null } — the app checks it before starting a download
GET  /api/projects/:pid/evidence-pack        (managers) a ZIP streamed as it is made: README.txt, history.csv (every change,
                                             task by task, with the full before/after), and per task list (archived ones too)
                                             tasks.csv and a folder per task with task.md (details, description, comment
                                             threads, files, history) and files/; the Trash is left out. ?files=skip leaves the
                                             files out (README lists them). 413 EXPORT_TOO_LARGE (details: the estimate) over
                                             10,000 tasks, or with files over 20,000 files / 1 GiB; 429 while your own pack, or
                                             two others on this API instance, are downloading
GET  /api/workspaces/:wid/portfolio          "All projects": per project you can see (not My space), counted over the lists
                                             you can open, in your time zone: tasks, done, open (top-level, canceled left out),
                                             overdue and due in 7 days (open tasks and subtasks, like the project overview),
                                             last task change, AI spend and runs of the period (`aiCostUsd`, `aiRunCount`:
                                             owners and admins only, left out for everyone else);
                                             `nextMilestone: { title, date } | null`: the earliest open milestone due today
                                             or later in the lists you can open
                                             ?ai=this_month (default) | last_month | last_30_days | last_90_days | last_7_days | last_week
GET  /api/workspaces/:wid/workload           "Workload": open tasks per member, AI teammate, placeholder person (active ones, and
                                             archived ones with open work; a placeholder counts as assigned) and Unassigned
                                             (+ `all`, each task once),
                                             over the lists you can open (not My space), subtasks included, in your time zone:
                                             overdue, one count per week from this week (a task counts in each week of its
                                             start → due span), later, no date, open. { today, weekStarts, rows, all }
                                             ?weeks=4|6 (default)|8|12 &projects=<ids> &lists=<ids> (comma lists, ≤ 200; ids you
                                             cannot open only narrow)
GET  /api/workspaces/:wid/workload/tasks     the tasks behind one number (≤ 200 by due date, + total): the same params and
                                             ?who=user:<id>|agent:<id>|placeholder:<id>|none|all &col=overdue|week:<a Monday shown>|later|nodate|open
     ?mode=hours (both)                      Workload in hours (a task is its estimateMinutes, else its list's settings.hoursPerTask,
                                             else the typical task's minutes, spread over the working days of its span and split
                                             between its people and placeholder people; overdue counts this week; undated tasks
                                             are left out). Rows add hours:
                                             { planned[], overdue, unestimated[] } (minutes per week; unestimated: tasks a week
                                             counted as a typical task, overdue ones in this week); people also capacity[] (weekly hours less
                                             holidays and time off), offDays[], timeOff [{ startDate, endDate }] (never the note),
                                             logged (minutes this week on the lists counted) and, for admins,
                                             weeklyMinutes; placeholder people capacity[] only (the typical week less holidays);
                                             AI teammates add limit (tasks a week, null = none); all.hours; top
                                             hours { weeklyMinutes, taskMinutes, workWeek, holidays[][], canManage, estimated
                                             (a task in these hours has an estimate) }. The drill
                                             adds each task's hours (minutes; not for AI teammates), and its week:<this Monday>
                                             also holds the overdue tasks. 400 TOO_MUCH_WORK over 100,000 rows
GET  /api/workspaces/:wid/calendar           "Calendar": over the lists you can open (not My space), for ?from=&to= (YYYY-MM-DD,
                                             ≤ 62 days): tasks on their due date (open, + done with &done=1; never canceled;
                                             subtasks too; `overdue` in your time zone), planned occurrences of enabled recurring
                                             rules (computed from the rule's next occurrence not yet created, never before today,
                                             once a day, ≤ 2000) and, with &ai=1, the next run of AI teammate schedules.
                                             { today, from, to, entries: [task | planned | ai-run], dayTotals, projects, lists,
                                             truncated }. ?perDay=1–200 (default 30: tasks per day, most urgent first; dayTotals
                                             counts every entry) &projects= &lists= &people=<ids|me> &agents= (comma lists ≤ 200,
                                             ids you cannot open only narrow; people/agents also match whom a rule assigns)
                                             &recurring=1 (tasks made by rules + planned). A task entry's `milestone` marks
                                             a milestone (a diamond in the app; the subscribe link is unchanged)
GET  /api/workspaces/:wid/calendar-feed      your subscribe link: { feed: { prefix, createdAt, lastUsedAt } | null }
POST /api/workspaces/:wid/calendar-feed      makes your link, or replaces it (the old one stops working at once)
                                             → 201 { feed, url, webcalUrl } — the link is returned only now
DELETE /api/workspaces/:wid/calendar-feed    turns it off → 204
GET  /api/feeds/calendar/<secret>.ics        no sign-in (the link is the secret): text/calendar of what the owner can open at
                                             fetch time — open tasks due 30 days ago … 180 days ahead (≤ 3000) and planned
                                             repeats up to 180 days ahead (STATUS:TENTATIVE, "(planned)"); UIDs
                                             task-<id>@taskcrew / rule-<ruleId>-<yyyymmdd>@taskcrew; ETag → 304; 404 once the
                                             link is replaced, turned off or its owner left (removing a member deletes their
                                             link); 429 + Retry-After over 60 fetches of a link an hour, or for unknown links
                                             after 300 failed lookups an hour from one network (working links never count)
GET  /api/items/:iid                         a task list with states, labels, fields
GET  /api/workspaces/:wid/agents             AI teammates
GET  /api/workspaces/:wid/my-space           your private project (created on first use)
PATCH /api/items/:iid                        { name?, icon?, folderId? }  the project folder it sits in (null = top level; item
                                             editors; 400 INVALID_FOLDER for a folder of another project); into another
                                             folder it goes to the end of it. Also on POST /api/projects/:pid/items.
                                             Moving an item to another project clears it
PUT  /api/projects/:pid/items/order          { ids }  sidebar order of a project's items
POST /api/projects/:pid/folders              { name, parentId? } → 201 folder (project editors and managers). Folders only group
                                             items in the sidebar and go three levels deep: 400 FOLDER_TOO_DEEP; 409
                                             TOO_MANY_FOLDERS over 200 per project. In a folder, its items come first, then its
                                             subfolders, each in `position` order
PATCH /api/folders/:fid                      { name }  rename (1–60 characters)
POST /api/folders/:fid/move                  { parentId: uuid | null, afterId? | beforeId? }  into another folder or the top
                                             level, with everything inside it: right after `afterId` (null = first) or right
                                             before `beforeId` (null = last), default last; a neighbour, so folders hidden
                                             from you never shift it. 400 FOLDER_CYCLE (into itself or below), 400
                                             FOLDER_TOO_DEEP (past level 3, counting its subfolders), 400 INVALID_ANCHOR (the
                                             neighbour isn't a subfolder there)
DELETE /api/folders/:fid                     → 204; its items and subfolders move up one level (into its place); nothing
                                             inside is deleted. Folder changes send `tree.updated` to the workspace
GET  /api/workspaces/:wid/favorites          your stars (projects, items, pages, tasks) with their cards
PUT  /api/workspaces/:wid/favorites/:kind/:id    star (kind = project | item | page | task)
DELETE /api/workspaces/:wid/favorites/:kind/:id  unstar

Tasks#

GET    /api/items/:iid/tasks?stateId=&assigneeId=&labelId=&q=&includeArchived=true   active tasks only unless includeArchived
GET    /api/workspaces/:wid/tasks?assigneeId=me&open=true&from=YYYY-MM-DD&to=YYYY-MM-DD&q=&limit=&includeArchived=true
GET    /api/tasks/:tid
POST   /api/items/:iid/tasks           { title, description?, stateId?, priority?, assigneeIds?, placeholderIds?, agentId?,
                                         startDate?, dueDate?, typeId?, sprintId?, estimateMinutes?, labelIds?, customValues?,
                                         parentId?, runAgent?, templateId? }   estimateMinutes: whole minutes 0–59,940
                                         (999 h), null = not estimated
PATCH  /api/tasks/:tid                 same fields (all optional), except: assignees change with
                                         { addAssigneeIds?, removeAssigneeIds? } instead of assigneeIds, placeholder
                                         people with { addPlaceholderIds?, removePlaceholderIds? } instead of placeholderIds
                                         With runAgent: true ("Start now"), POST and PATCH add agentRun { started, runId? |
                                         code?, message? }: whether the AI teammate really started (e.g. code AI_PAUSED at
                                         the monthly AI limit, RUN_ALREADY_ACTIVE) — the task is saved either way
POST   /api/tasks/:tid/move            { stateId, position?, confirmAgentRun? }
POST   /api/tasks/:tid/decision        { decision: APPROVED | CHANGES_REQUESTED | REJECTED, note? (≤ 1000; needed to
                                         reject) } → the task: answers an Approval task (its assignees, or a workspace
                                         owner or admin)
DELETE /api/tasks/:tid/decision        → the task: undoes the caller's own decision within 30 s (the toast's Undo)
POST   /api/tasks/:tid/move-to-list    { itemId, stateId? }   to another task list (edit rights on both)
POST   /api/tasks/:tid/copy            { itemId?, title?, subtasks? = true, people? = true, dates? = false } → 201 the copy
POST   /api/items/:iid/tasks/bulk      { taskIds (≤ 200), action } → { tasks, removedIds, skipped: [{ id, key, code, message }] }
POST   /api/items/:iid/tasks/from-lines { lines: [{ title, assigneeIds?, dueDate? }] (1–50; titles cut at 300), pageId? }
                                       → { created: [task], skipped: [{ index, code, message }] }   one task per line of a
                                         wiki page or note, in order (editors of the list; the page must be one you can open
                                         in this workspace, else 404; people must be able to open the list, else 400
                                         INVALID_ASSIGNEE; 20 requests a minute per person, else 429). Each task's history
                                         starts with CREATED_FROM_PAGE { pageId, kind: page|note }; one summary notification
                                         per person assigned
GET    /api/workspaces/:wid/task-templates          [{ id, name, createdBy, subtaskCount, checklistCount, fieldCount,
                                                       dueInDays, updatedAt, canManage }]   (any member)
POST   /api/workspaces/:wid/task-templates          { taskId, name, dueInDays? }   save a task as a template (201)
GET    /api/task-templates/:id                      the same with `data` (its content)
PATCH  /api/task-templates/:id                      { name?, taskId?, dueInDays? }   rename / replace (creator or admin)
DELETE /api/task-templates/:id                      (creator or admin; 204)
GET    /api/tasks/:tid/watchers        [{ id, name, email, avatarColor, source, since }]
POST   /api/tasks/:tid/watchers        { userId? }   watch (no userId) or add someone (needs edit rights)
DELETE /api/tasks/:tid/watchers/:uid   unwatch (`me`) or remove someone (needs edit rights)
DELETE /api/tasks/:tid                 move to the list's Trash (with its subtasks; editors)
GET    /api/items/:iid/trash?cursor=   the list's Trash, newest first, 50 per page (editors):
                                         { items: [{ id, key, title, parentKey, deletedAt, deletedBy, subtaskCount }], nextCursor }
POST   /api/tasks/:tid/restore         bring it back with the subtasks trashed with it (editors; 409 NOT_IN_TRASH)
DELETE /api/tasks/:tid/permanent       delete a task in the Trash for good (managers; 409 NOT_IN_TRASH)
POST   /api/tasks/:tid/archive         archive a top-level task with its subtasks (editors; 409 SUBTASK_ARCHIVE for a
                                         subtask). Archived tasks leave every active view (boards, My tasks, Home, Workload,
                                         calendars, search unless archived=true, automations, AI tools) but stay in reports;
                                         opened by id they carry archivedAt, archivedBy, archiveReason (MANUAL | AUTO)
POST   /api/tasks/:tid/unarchive       bring it back with the subtasks archived with it (a subtask brings its parent);
                                         moving an archived task out of done does the same
GET    /api/items/:iid/archived-tasks?cursor=&q=&reason=auto|manual   newest first, 50 per page (anyone who can open the
                                         list; q: title, description or key): { items: [{ id, key, title, state, assignees,
                                         completedAt, archivedAt, archivedBy, reason, subtaskCount }], nextCursor }
POST   /api/items/:iid/tasks/archive-completed { stateId? } → { archived }   every top-level task in that done or canceled
                                         state (or in any completed state) of the list (editors)
                                       Bulk actions `archive` and `unarchive` do the same for a selection. Auto-archive: items
                                         settings.archiveCompletedAfterDays (null = Off, 0, 1, 7 or 30 days; 'workspace' to
                                         follow PATCH /api/workspaces/:wid { archiveCompletedAfterDays }, default 7) — a
                                         background pass archives tasks completed that long (POST /api/internal/tasks/
                                         auto-archive { at? } on test stacks)
GET    /api/tasks/:tid/subtasks
POST   /api/tasks/:tid/dependencies    { dependsOnId }   the task waits for another one (400 DEPENDENCY_CYCLE, INVALID_DEPENDENCY)
DELETE /api/tasks/:tid/dependencies/:did
GET    /api/tasks/:tid/links           linked files [{ id, taskId, url, title, provider, addedBy: { id, name } | null, createdAt }]
                                         (provider: google_drive | google_docs | google_sheets | google_slides | onedrive | sharepoint)
POST   /api/tasks/:tid/links           { url, title? } (editors; 201) — https links on docs.google.com, drive.google.com,
                                         onedrive.live.com, 1drv.ms or <tenant>.sharepoint.com only (400 LINK_INVALID); the
                                         server never fetches them; no title = the app's name ("Google Sheet"); ≤ 50 per task
                                         (400 TOO_MANY_LINKS), the same link once (409 LINK_EXISTS)
DELETE /api/tasks/:tid/links/:lid      (the person who added it, or an admin; 204)
GET    /api/tasks/:tid/activity        every change, oldest first: [{ id, type, data, actorType, actorUser, actorAgent, createdAt }]
GET    /api/items/:iid/export?format=csv|xlsx&comments=true&closed=true
                                       a task list (editors) as CSV — the default, as before (UTF-8 with a byte order
                                         mark, comments in a column) — or Excel with format=xlsx (a Tasks sheet and a
                                         Comments sheet: task key, when, who, comment, reply to, thread status; the app's
                                         Download dialog picks Excel and always sends the format): Key, Title, Type
                                         (task type; "Approval (approved)" once answered), State, Priority, Assignees (placeholder people after the people: "Priya, Alex
                                         (placeholder)"), AI teammate, Start, Due, Estimate (h) (a number of hours, e.g. 1.5), Labels, Parent, Subtasks,
                                         Created/Completed (UTC), a column per custom field (money says its currency:
                                         "Budget (USD)"), Description. Subtasks are rows;
                                         the Trash is left out; references to what you cannot open read "(restricted
                                         item)"; text a spreadsheet would run as a formula stays text
GET    /api/workspaces/:wid/audit-log?cursor=&limit=&actorId=&types=&projectId=&itemId=&from=&to=
                                       the workspace's audit log, newest first (owners and admins):
                                         { items: [{ …activity, task: { id, key, title, inTrash, list, project } }], nextCursor }
GET    /api/workspaces/:wid/audit-log/export?(same filters)   the same as CSV (newest 10,000): Time (UTC), Who,
                                         Done by type, Kind of change, What, Details (full before/after), Task key,
                                         Task title, List, Project, Now in Trash
GET    /api/workspaces/:wid/audit-log/former-members   people who changed something but left: [{ id, name, avatarColor }]
GET    /api/workspaces/:wid/audit-log/workspace-events?cursor=&limit=&types=&from=&to=
                                       Workspace & sign-in (owners and admins, in the app — never API tokens): members
                                         added/removed (invite, SSO, SCIM), role changes, invites sent/revoked, renames,
                                         SSO/SCIM settings, SSO sign-ins (and refusals), owners' password use under Require
                                         SSO, API tokens created/revoked; newest first, 50 a page (≤ 100):
                                         { items: [{ id, createdAt, type, actorType: USER|SCIM|SYSTEM, actor, target:
                                         { id, name, email }, data, ip }], nextCursor }; `types` = WORKSPACE_AUDIT_TYPES
GET    /api/workspaces/:wid/audit-log/workspace-events/export?(same filters)   CSV (newest 10,000): Time (UTC), Who,
                                         Done by type, Kind of change, What, Person affected, Email affected, IP address
GET    /api/workspaces/:wid/activity?week=&projectId=&itemId=&who=&kinds=&cursor=&limit=
                                       "What changed" (members): one week (Monday–Sunday in your time zone; ?week = any
                                         day of it, this week by default) of what happened to work you can open — never
                                         restricted lists, private projects or My space you cannot open, archived work or
                                         the Trash — newest first, 50 a page (≤ 100): { week: { start, end }, today,
                                         items: [{ kind: 'activity', type, data, task } | { kind: 'comment', body (Markdown
                                         cut to ~400 characters, never inside a mention), truncated, reply, task }],
                                         nextCursor }; each item has id, createdAt, actorType, actorUser, actorAgent and
                                         task = { id, key, title, list, project }. Dependency entries naming a task you
                                         cannot open keep only their direction (as in the task's history).
                                         ?who=people|ai|automations|<person or AI teammate id> &kinds=<comma list of
                                         lifecycle,status,people,dates,details,files,ai,automations,comments>;
                                         a project or list you cannot open → 404
GET    /api/workspaces/:wid/activity/summary?week=&projectId=&itemId=
                                       the week's numbers: { week, created, completed (tasks), comments, aiFinished,
                                         aiFailed, playbooksFinished, people[≤5] / agents[≤3]: { id, name, …, count } }
GET    /api/workspaces/:wid/access-review   Settings → Access review (owners and admins in the app: members 403, others 404,
                                       API tokens 403, AI teammates 403 APP_ONLY): { people: [{ id, name, email, role, joinedAt }],
                                         invites: [{ email, role, expiresAt, invitedBy }] (no links), projects: [{ id, name,
                                         visibility, everyone: role | null, grants: [{ userId, role, via }] }], items: [restricted or
                                         with people added: { id, projectId, type, name, restricted, openToAdmins, grants }] (≤ 2000),
                                         itemsTruncated } — owners/admins open everything (not repeated) except members-only vaults
                                       (openToAdmins: false; grants = exactly the vault's members); via: admin | project_member | everyone |
                                       item_member | project_manager; never anyone's My space or archived projects
GET    /api/workspaces/:wid/access-review/vaults?olderThanDays=90   per team vault, from stored facts only (nothing decrypted):
                                         { olderThanDays, revealedDays: 30, vaults: [{ id, name, projectName, restricted, canOpen,
                                         people, rotateAfterDays, logins, weak, old, exposed, revealed, revealedBy, flagged }] (≤ 500),
                                         truncated };
                                         flagged = [{ id, title, username, host, flags: weak|old|exposed }] only where you can open it
GET    /api/workspaces/:wid/access-review/tokens   every member's API tokens and calendar links: { tokens: [{ id, kind: token|feed,
                                         user, name, prefix, createdAt, lastUsedAt, expiresAt, flags: never_used|unused|no_expiry|
                                         expires_soon|expired }], truncated, scim: { prefix, createdAt, lastUsedAt, flags } | null }
                                         (never the secret; `scim` is the workspace's provisioning token)
DELETE /api/workspaces/:wid/access-review/tokens/:id   revoke anyone's token in the workspace → 204
DELETE /api/workspaces/:wid/access-review/feeds/:id    turn off anyone's calendar link → 204
GET    /api/tasks/:tid/comments
POST   /api/tasks/:tid/comments        { body, parentId? }   (Markdown; mention with @[Name](user:<id>) / @[Name](agent:<id>);
                                       parentId: reply in that comment's thread — to a reply, it joins that reply's thread)
PATCH  /api/comments/:cid              { body }   (your own)
DELETE /api/comments/:cid              (your own; 409 COMMENT_HAS_REPLIES when people replied — edit it instead)
PUT    /api/comments/:cid/assignee     { userId | null }   (thread author or editor; 400 INVALID_ASSIGNEE unless the person can open the task)
POST   /api/comments/:cid/resolve      POST /api/comments/:cid/reopen   (thread author, its assignee or an editor)
PUT    /api/comments/:cid/reactions/:emoji    DELETE …   (anyone who can open the task; emoji: thumbs_up, heart, tada, eyes, check, pray)

Comment threads. A comment returns parentId (the thread's first comment, or null), resolvedAt, resolvedBy, assignee, assignedBy, assignedAt and reactions: [{ emoji, count, userIds }]. Threads are one level deep; only a thread's first comment can be assigned (to one person, not an AI teammate) or resolved (400 NOT_A_THREAD), and both are written to the task's activity (COMMENT_ASSIGNED, COMMENT_RESOLVED, COMMENT_REOPENED). An assigned thread is an open action item for that person (Home's assignedComments: { items ≤ 10, total }) until it is resolved; resolving keeps the assignee. A person's reply to a resolved, assigned thread reopens it, unless they resolved it themselves. Every change is pushed as comment.updated with the whole comment, and assigned-comments.updated { workspaceId } goes to the user: room of the (previous) assignee. Reactions never notify.

priority: NONE | LOW | MEDIUM | HIGH | URGENT. Dates are YYYY-MM-DD. customValues is { [fieldId]: value }; a LOCATION field (task lists only) holds { lat (−90…90), lng (−180…180), label? (≤ 200 characters) } and nothing else (400 INVALID_FIELD_VALUE), shown in exports and to AI teammates and MCP clients as label (lat, lng). Mentioning an AI teammate in a comment starts it on the task (see below).

People on a task. A task has up to 20 assignees (people, in the order they were added) and at most one AI teammate (agentId). Tasks return assignees: [{ id, name, email, avatarColor }], assigneeIds and watcherIds. A PATCH never replaces the whole list: send who to add and who to remove, so two people editing the assignees at the same time do not overwrite each other. Every assignee must be a workspace member who can open the list (400 INVALID_ASSIGNEE, with the person in details). assigneeId=<id>|me filters return tasks the person is one of the assignees of; assigneeId=none returns tasks nobody is assigned to (no person and no placeholder person).

Placeholder people (planning names, see below) travel apart from the people: tasks return placeholders: [{ id, name, color, archived }] and placeholderIds, after the people. People and placeholders share the limit of 20 (400 TOO_MANY_ASSIGNEES). A placeholder must belong to the workspace (400 INVALID_PLACEHOLDER) and an archived one cannot be added (409 PLACEHOLDER_ARCHIVED; taking it off works). They open nothing, so there is no list check, a move keeps them all and a copy with people keeps the ones not archived. They are never watchers and never notified; the history entry is ASSIGNEE_CHANGED with { id, name, placeholder: true } entries ("assigned Alex (placeholder)"), and task.updated lists them as placeholders, never assignees. A placeholder counts as assigned: Home's "mine", the unassigned automation condition and assigneeId=none leave its tasks out.

Blocked by. Tasks return blockedBy and blockingCount (how many open tasks wait for this one). A blocker in the same list is { id, key, title, done }; one in another list (it may be restricted, private or in someone's My space) is only { id, done, otherList: true } for everyone — people who can open it get its name from POST /api/workspaces/:wid/refs/resolve. Dependency entries in the activity and the audit log keep only their direction for readers who cannot open the other task. A task in My space can only be linked with other tasks in that My space (400 INVALID_DEPENDENCY). When a task is done or reopened, the tasks linked to it are sent again (task.updated).

Subtasks are one level deep (MAX_SUBTASK_DEPTH = 1): a task can have subtasks, a subtask cannot have its own (its checklist breaks it down). A subtask returns parent: { id, key, title }. Creating with parentId, or a PATCH of parentId, answers 400 INVALID_PARENT when the parent is in another list or is the task itself, and 400 SUBTASK_TOO_DEEP when the parent is a subtask ("A subtask cannot have subtasks of its own — use its checklist instead") or the task has subtasks, the Trash included ("A task with subtasks cannot become a subtask"); parentId: null moves it to the top level. A task and its subtasks move, go to the Trash, come back and are copied together.

Subtask progress. Tasks return subtaskCount (direct subtasks not in the Trash), subtasksDone and subtasksCanceled (by their state's category); progress leaves canceled subtasks out: done ÷ (count − canceled). Subtasks always share their parent's list, so the web adds up their number and money fields (and checklists) from the list's tasks — roll-ups are never stored nor written into the parent, and count a task's direct subtasks only.

GET /api/tasks/:tid/dependency-chain   { taskId, nodes, edges: [[waitingId, blockerId]], truncated }

The whole chain around a task (viewers): what it waits for (side: 'up'), the task ('self') and what waits for it ('down'), up to 25 steps and 200 tasks each way (truncated when it goes on), upstream farthest first. A node is { id, key, title, state, done, assignees, agent, dueDate, listName, depth, side, blocked, holdingUp } (blocked: it waits for an open task, holdingUp: an open task waits for it). A task the viewer cannot open is only { id: 'hidden-N', hidden: true, done, depth, side } and is not followed further; tasks in the Trash are left out. dateClashes, longestOpenChain and isReadyToStart in @taskcrew/shared read it. Home (GET /api/workspaces/:wid/home) adds waiting (my open tasks waiting for an open task not assigned to me) and blockingOthers (my open tasks an open task not assigned to me waits for) to counts and tasks; dashboard task sources take filters.dependency: blocked | blocking.

Trash. Deleting a task moves it and its subtasks to its list's Trash (one entry; subtaskCount counts the subtasks that went with it) and stops its active AI runs. A subtask trashed earlier on its own keeps its own entry; restored while its parent is gone (or is a subtask now) it comes back at the top level. Nothing is lost: comments, files, dependencies and AI run history (including cost records) stay, and a restore brings back the same key. A task in the Trash is left out of every list, search, count and MCP tool; any other request for it answers 404 TASK_IN_TRASH with details: { itemId, deletedAt, deletedBy, canRestore } to people who can see its list (others get 404 NOT_FOUND). Only managers can delete for good, and only from the Trash — that also deletes its subtasks, their runs and cost records, and the links to and stars on any of them. There is no automatic purge.

Bulk edits change up to 200 tasks of one list (editors) with one action: { type: 'state', stateId, confirmAgentRun? }, { type: 'priority', priority }, { type: 'assignees', add, remove }, { type: 'placeholders', add, remove } (a task that would go over 20 people and placeholders is skipped: TOO_MANY_ASSIGNEES), { type: 'labels', add, remove }, { type: 'dates', dueDate } (a date or null; a later start moves to it) or { type: 'dates', shiftDays } (start and due together, ±365; undated tasks are skipped), { type: 'field', fieldId, value } (null clears), { type: 'sprint', sprintId } (an open sprint of the list, or null), { type: 'estimate', estimateMinutes } (0–59,940, or null to clear), { type: 'moveToList', itemId }, { type: 'trash' } and { type: 'restore' } (Undo of trash). Each task changes fully or is listed in skipped with the reason (ids of other lists or the Trash: NOT_FOUND); the batch is not one transaction. Every task gets its own history, automations and realtime update, as for a single edit; people get one summary notification ("Priya assigned you 4 tasks in Deliverables (Lotus Cafe)") instead of one per task. The 200 count the subtasks that come along on a move, to the Trash or back, and a selected subtask whose parent is selected comes along with it (400 TOO_MANY_TASKS). A state that starts AI teammates answers 409 AGENT_RUN_CONFIRMATION_REQUIRED with details: { count, agent, state } until confirmAgentRun: true. 120 requests a minute per person (429).

Task types. Every task has a typeId (see Task types); without one a new task gets its list's default type. A type the list does not allow is refused (400 TYPE_NOT_ALLOWED; another workspace's: 400 INVALID_TYPE); tasks that already have a type the list stopped allowing keep it, and moves keep the type. A copy keeps its type when the target list allows it, else it gets that list's default. A change is recorded as TYPE_CHANGED { from: { id, name }, to: { id, name } }.

Estimates. estimateMinutes is a task's optional estimate (null = not estimated; 0 is an estimate), set on create, PATCH, bulk edit or a spreadsheet import (an "Estimate (h)" column). A change is recorded as ESTIMATE_CHANGED { from, to } (minutes or null); copies and moves keep it. Workload in hours counts it instead of a typical task. One estimate per task (a shared task's splits between its people), no points.

Milestones. typeId = the built-in Milestone type (00000000-0000-4000-8000-000000000002; it replaced the isMilestone flag) makes a task a milestone: one day, its due date. Its start date is cleared on every write (create, update, copy; a lone start date becomes the due date), recorded with START_DATE_CHANGED when a start date was cleared. Older history keeps its MILESTONE_CHANGED { from, to } lines.

Approvals. A task of the built-in Approval type (…000003) is answered with POST /tasks/:tid/decision by one of its assignees (whatever their access to the list) or a workspace owner or admin (403 NOT_APPROVER otherwise; 400 NOT_AN_APPROVAL for other types; 409 ALREADY_DECIDED once answered). The outcome is stored in approvalOutcome, the history gets APPROVAL_DECIDED { decision, note? }, the task moves to the list's first done state (every outcome completes it; nobody earns points for a decision) and its creator gets an approval_decided notification (never the person deciding). Rejecting needs a note (400). Moving it out of a done state, or changing its type, clears the outcome so it can be decided again. DELETE /tasks/:tid/decision takes a decision back: only by the person who made it (403 NOT_DECIDER) and within 30 s (409 UNDO_EXPIRED; 409 NOT_DECIDED when there is none); the task goes back to the state it was in without an outcome, and the creator's notification, if still unread, is withdrawn. API tokens may not decide or undo (403). Assigning an Approval that waits for an answer notifies its new assignees as "Priya asked you to approve …" (assigned, with data.approval: true).

Move and copy across lists. A task keeps what fits its new list: the state by name (else category, else the default), labels by name, custom field values by name and type (choices by option name, money in the same currency), and only the people who can open that list — the others are taken off as assignee and watcher, with the reason moved_to_list in the activity. Labels and fields are never created. What a move loses is recorded (FIELD_CHANGED with reason: moved_to_list). A copy (VIEWER on the task, EDITOR on the target) keeps the title, description, priority, what fits of labels and fields, the checklist unchecked and the live subtasks (≤ 100, 400 TOO_MANY_SUBTASKS); people keeps assignees who can open the target and the AI teammate (never started), dates the start and due dates. It starts open and records TASK_COPIED { fromTaskId, fromKey, fromTitle }; comments, files, runs, dependencies and schedules are never copied. A subtask duplicated in its own list keeps its parent.

Task templates are shared with the whole workspace: title, description, priority, label names, checklist, custom values by field name (never person fields), dueInDays and up to 50 subtasks (title, description, priority, labels, checklist) — no people, AI teammate, subtask dates or files. Saving needs edit rights on the task's list; names are unique per workspace (409 TEMPLATE_NAME_TAKEN with details: { id, canManage }); up to 500 per workspace. Creating a task with templateId (edit rights on the list; another workspace's template is a 404) uses the body for what it sends and adds the template's checklist (unchecked), the field values that fit (body values win) and its subtasks (a template with subtasks used with a parentId answers 400 SUBTASK_TOO_DEEP before anything is made); without a dueDate the due date is today (UTC) + dueInDays. The task records CREATED_FROM_TEMPLATE { templateId, name }. A template can also be written by hand (Workspace settings): POST with content (title, description, priority, labels, checklist, subtasks) instead of taskId — any member, no list rights needed — and PATCH with content replaces it but keeps its custom field values. PATCH { archived: true | false } archives or restores it (creator or admin): archived templates stay in the list with archivedAt and are refused when creating a task (400 TEMPLATE_ARCHIVED).

Watchers hear about comments, state changes and completion. The creator, assignees, commenters and mentioned people start watching automatically; assignees are notified even if they unwatch. Removing someone from a workspace, project or list takes them off (assignee and watcher) every task they can no longer open, and each task records it in its activity. So does any other change that takes access away : a project made private, a list restricted or moved to another project, an admin made a member, a project manager given a lower role. Each task gets one ASSIGNEE_CHANGED { added: [], removed, reason } (project_private, list_restricted, list_moved, role_changed; a WATCHERS_CHANGED only for people who just watched); nobody is notified, and AI teammates stay. PATCH /projects/:pid, PUT and DELETE /projects/:pid/members/:uid, PATCH /items/:iid, DELETE /items/:iid/members/:uid, POST /items/:iid/move and PATCH /workspaces/:wid/members/:uid then add unassigned: { people, tasks } to their answer (only when someone was unassigned from a task outside the Trash: tasks in the Trash are cleaned up too but not counted). Realtime updates of those tasks skip the sockets of the people taken off, even before their rooms are re-checked.

Activity and audit log. Every change to a task is recorded with who, when and what: data keeps the old and new values with the names of the moment, so history stays readable after fields, options, states or people are renamed or deleted. FIELD_CHANGED has changes: [{ fieldId, name, type, from, to, fromLabel?, toLabel?, currency? }] (and reason: field_deleted | moved_to_list when the system cleared values); DESCRIPTION_CHANGED keeps only the changed span (at, removed, added, contextBefore, contextAfter, fromLength, toLength, truncated?, each side capped at 2,000 characters); LABELS_CHANGED has added/removed names, PARENT_CHANGED fromRef/toRef: { key, title }, STATE_CHANGED fromCategory/toCategory; ATTACHMENT_REMOVED { fileName } records deleted files. Older rows may lack these fields. The audit log (limit ≤ 100, default 50; types comma-separated activity types; actorId a person or AI teammate; from/to ISO date-times) covers every task in the workspace, including tasks in the Trash and archived projects, except other people's My space; members get 403.

Placeholder people#

GET    /api/workspaces/:wid/placeholders?archived=include   → [{ id, name, color, note, archived, createdBy: { id, name } | null,
                                                              canManage, openTasks, createdAt }]   active first, by name
POST   /api/workspaces/:wid/placeholders  { name (1–60), color? (#rrggbb), note? (≤ 500) } → 201
PATCH  /api/placeholders/:pid             { name?, color?, note? }
DELETE /api/placeholders/:pid             archive (204): hidden from pickers, kept on its tasks
POST   /api/placeholders/:pid/restore     → the placeholder
GET    /api/placeholders/:pid/replace?userId=   preview → { placeholder, member, tasks, willMove, alreadyAssigned, stay,
                                                  limit, lists: [{ itemId, name, projectName, tasks, memberCanOpen }] }
POST   /api/placeholders/:pid/replace     { userId } → { replaced, alreadyAssigned, stayed, remaining, skipped }

Names to plan with ("Freelance designer") that are never accounts: no email, sign-in, seat, access or notifications. Every member lists, creates and edits them (names are visible to the whole workspace); their creator or a workspace owner or admin archives, restores and replaces them (canManage; others 403). Names are unique among the active ones, any case (409 NAME_TAKEN, also on restore); up to 100 active and 1,000 in all per workspace (409 TOO_MANY_PLACEHOLDERS). Without a colour the next one of PLACEHOLDER_COLORS is used. openTasks counts open tasks in the lists the viewer can open. Another workspace's placeholder is a 404. Changes send placeholders.changed { workspaceId } to the workspace:<id> room.

Replace with a real person moves the placeholder's open tasks (not done, canceled or in the Trash) in lists the caller can edit to a member (400 INVALID_ASSIGNEE for anyone else): the placeholder comes off and the member goes on (a task they are already on only loses the placeholder). Tasks in lists the member cannot open stay with the placeholder (the preview lists those lists first). It is planned again when applied, changes at most 200 tasks per call (remaining says how many are left), records "replaced Alex (placeholder) with Maya" on each task (replacedWith) and sends the member one notification. API tokens may not apply it (403).

Task types#

GET    /api/workspaces/:wid/task-types?archived=include   → [{ id, name, icon, color, builtIn, archived }]   the built-in
                                                              Task, Milestone and Approval first, then the workspace's own
POST   /api/workspaces/:wid/task-types  { name (1–40), icon (TASK_TYPE_ICONS), color (#rrggbb) } → 201   owners and admins
PATCH  /api/task-types/:id               { name?, icon?, color? }   owners and admins
DELETE /api/task-types/:id               archive (204): out of pickers and lists' allowed types, kept on its tasks
POST   /api/task-types/:id/restore       → the type
PATCH  /api/items/:iid                   { settings: { taskTypes: { allowedIds: [typeId] | null, defaultId } } }   list managers

Every member lists the types; the built-in ones have fixed ids (TASK_TYPE_IDS: Task …000001, Milestone …000002, Approval …000003, all 00000000-0000-4000-8000-…) and are read-only (400 BUILT_IN_TYPE). Their icons (circle-check, diamond, stamp) are not in TASK_TYPE_ICONS: a custom type saved with one earlier reads tag. Names are unique among the workspace's active types and the built-in names, any case (409 NAME_TAKEN, also on restore); up to 50 active and 200 in all per workspace (409 TOO_MANY_TYPES). Another workspace's type is a 404. Changes send task-types.changed { workspaceId } to the workspace:<id> room. A task list's settings.taskTypes (managers of the list) says which types new tasks may have and their default: allowedIds: null (or every active type) means every type, new ones included; missing settings mean every type with Task as the default; an empty set, a default outside it or an unknown or archived type is 400 INVALID_TASK_TYPES. An archived default falls back to Task (or the first allowed type).

File versions#

POST   /api/attachments/:aid/versions           editors: multipart `file` → 201 a newer version of that file (`versionOfId`,
                                                  `version`); the same size limit
GET    /api/attachments/:aid/download           with `Range: bytes=…` (one range) → 206 + Content-Range; 416 when invalid

A newer version is one more attachment row of the series (versionOfId = the first version, version 2, 3…); tasks count files, not versions. Deleting the first version deletes the series and its files; a newer version can be deleted alone (its uploader or an admin). Downloads send Accept-Ranges: bytes, Content-Length and Cache-Control: private, max-age=86400, immutable (a version never changes), sandboxed as before (never SVG inline).

Saved views#

GET    /api/items/:iid/views           team views and your own: { views: [{ id, itemId, name, shared, mine, config, updatedAt }] }
POST   /api/items/:iid/views           { name, shared?, config }   personal (anyone who can open the list) or team (editors;
                                         never a guest: 403 GUEST_NOT_ALLOWED)
PATCH  /api/views/:vid                 { name?, config?, shared?: true }   your own view, or a team view (editors, not guests)
DELETE /api/views/:vid                 same rights as PATCH (204)
PATCH  /api/items/:iid                 { settings: { defaultViewId } }   the list's team default (a team view, or null; editors)
PATCH  /api/items/:iid                 { settings: { hoursPerTask } }   typical hours per task: 0.25–80 or null (Workload in hours; editors)

A view saves a task list's setup (layout: board | list | table | calendar | timeline | mindmap | map): config = { v: 1, layout?, filters: { states?, priorities?, assignees? (me | none | <userId> | agent:<id> | placeholder:<id>), labels? (<id> | none), due?: { buckets?, from?, to? }, fields?: [{ fieldId, op, values?, text?, from?, to? }], dependency? (blocked | blocking), sprints? (current | none | <id>), milestone?, types? (<typeId>), subtasks? }, groupBy (also type), sort: { key, dir } | null, columns: { hidden, order } }. Rows are ANDed, the values in a row ORed; filters run in the browser on the loaded tasks (dependency — open tasks waiting for an open task / that an open task waits for — matches subtasks too). The text search is never saved. On every write the API drops states, labels, fields and options the list no longer has (400 VALIDATION_ERROR for a config that does not fit). Someone else's personal view is a 404; viewers get 403 on team views; shared: true (personal → team) needs edit rights and a team view never becomes personal again (400 CANNOT_UNSHARE). Up to 50 team views per list and 50 personal views per person and list (400 TOO_MANY_VIEWS); 400 INVALID_DEFAULT_VIEW for a default that is not a team view of the list. Deleting the team default clears it. Changes send views.updated { itemId } to item:<id> (team views) or the owner's user:<id> room (personal views).

Sprints#

GET    /api/items/:iid/sprints         { today, settings, unit: points|tasks, canEdit, sprints: [{ id, itemId, number, name, goal,
                                         startDate, endDate, status: upcoming|active|ended|closed, closedAt, closedBy, taskCount,
                                         doneCount, points, donePoints, summary }] (newest start first, ≤ 100), velocity: { value,
                                         sprints } | null }
POST   /api/items/:iid/sprints         { name?, goal?, startDate, endDate }   plan one (editors; 201; "Sprint <n>" without a name)
PATCH  /api/sprints/:sid               { name?, goal?, startDate?, endDate? }   (editors; a closed sprint keeps its dates)
DELETE /api/sprints/:sid               a sprint that has not started or holds no tasks (editors; 204; 409 SPRINT_STARTED, SPRINT_CLOSED)
POST   /api/sprints/:sid/close         { carryTo: <sprintId> | next | none = next }   (editors) → the closed sprint
GET    /api/sprints/:sid/burndown      { sprintId, unit, days: [{ date, remaining | null, ideal }], scope, added, completedTasks,
                                         completed, remaining, unpointed, truncated }
PATCH  /api/items/:iid                 { settings: { sprints: { enabled, weeks: 1–8 = 2, pointsFieldId? } } }   (editors)

Sprints are turned on per task list (400 SPRINTS_OFF otherwise; 400 INVALID_SPRINT_SETTINGS, INVALID_POINTS_FIELD for a field that is not a Number field of the list). A sprint lasts 1–60 days and never overlaps another sprint of its list (409 SPRINT_OVERLAP); up to 200 per list. Points come from the list's Number field when it still is one, else tasks are counted. A task is in at most one sprint of its own list: sprintId on create, PATCH /api/tasks/:tid or the bulk sprint action (400 SPRINT_OTHER_LIST, 409 SPRINT_CLOSED for a closed sprint), recorded as SPRINT_CHANGED { from, to } ({ id, name } or null). Moving a task to another list takes it out of its sprint; copies, templates and recurring tasks start without one; the Trash keeps it. Closing (a sprint that has started, else 400 SPRINT_NOT_STARTED; a second close gets 409 SPRINT_CLOSED) is one transaction: its numbers and burn-down are frozen in summary ({ unit, planned, added, completed, carriedOver, points, burndown, carriedTo }) and its open tasks (not done or canceled) go to carryTo — next: the next upcoming open sprint, else a new one of the same planned length starting the day after the list's last sprint — each with SPRINT_CHANGED { …, reason: carried_over }. Closed before its last day, a sprint ends that day (in your time zone: endDate and its burn-down stop there), so a new sprint starts the next day. A deleted sprint's tasks get reason: sprint_deleted. The burn-down counts, per day in your time zone, what is still open among the sprint's tasks (canceled ones left out, ≤ 500), each from the day it last joined it (else its creation) until it was done, with an ideal line from the start's scope to 0; a closed sprint's is the frozen one. Velocity is the average finished by the last 3 closed sprints. Viewers read sprints; people who cannot open the list get a 404. Changes send sprints.changed { itemId } to item:<id>. Saved views filter by sprints (current | none | ids) and milestone: true.

Timeline baselines#

GET    /api/items/:iid/baselines       newest first, no dates: { baselines: [{ id, itemId, name, createdAt, createdBy, taskCount }] }
POST   /api/items/:iid/baselines       { name? }   save today's plan (editors; 201 the baseline with its dates)
GET    /api/baselines/:bid             { …, dates: { [taskId]: [start, due] | [start, due, 1] }, removed }
PATCH  /api/baselines/:bid             { name }   rename (editors)
DELETE /api/baselines/:bid             (editors; 204)

A baseline is a snapshot of a task list's plan for the timeline's "Compare with": the start and due dates (YYYY-MM-DD or null; 1 marks a milestone) of every live task of the list with a date, subtasks included — ids and dates only, never titles. Everyone who can open the list reads its baselines; someone who cannot gets a 404. Without a name it is "Baseline Oct 7, 2026" (the day in the person's time zone; 1–60 characters). Up to 20 per list (409 TOO_MANY_BASELINES). A read gives the dates of the baseline's tasks still in the list (live or in its Trash); tasks moved to another list since, or deleted for good, are only counted in removed. Saving, renaming and deleting send baselines.updated { itemId } to item:<id>.

AI teammate runs#

POST /api/tasks/:tid/runs              start the task's AI teammate
GET  /api/tasks/:tid/runs
GET  /api/runs/:rid                    status, output, failure reason
POST /api/runs/:rid/cancel
POST /api/runs/:rid/retry
GET  /api/agents/:aid/runs             the AI teammate's latest 100 runs
GET  /api/agents/:aid/tool-calls       its latest 200 tool calls, with arguments and results
PATCH /api/agents/:aid                 { weeklyTaskLimit: 1–500 | null } tasks a week on Workload in hours (alone: no new version)

The AI teammate's history lists only runs on tasks you can open (never restricted lists or private projects you are not part of, nor someone else's My space); a run on a task in the Trash shows task: null. Starting, stopping (cancel) and retrying a run, and adding, changing or deleting its schedules (POST /api/tasks/:tid/schedules, PATCH/DELETE /api/schedules/:sid) need Edit on the task's list (403 for viewers and commenters, who can read runs and GET /api/tasks/:tid/schedules). A run's usage carries its tokens for everyone and its cost (usage.costUsd) for owners and admins only, in every answer above; realtime run.updated events never carry the cost (they go to everyone on the task), and neither do the model calls on a run's timeline (llm events: tokens only; older ones keep their cost for owners and admins in GET /api/runs/:rid/events).

Approvals. An AI teammate with askBeforeExternalActions (on for new teammates) waits for a person before each connector action its MCP server does not mark read-only. The run stays RUNNING; the waiting tool call has approvalStatus: PENDING and approvalExpiresAt (then APPROVED, REJECTED, EXPIRED or CANCELED), and tool calls from /runs/:rid/tool-calls and /agents/:aid/tool-calls add decidedBy { id, name }, connectorName and actionTitle (the MCP title, if any).

GET  /api/workspaces/:wid/approvals    requests waiting for me (I started the run, else I am on the task, else I created it), on lists I can edit; oldest first, ≤ 20
POST /api/approvals/:id/approve        → the tool call
POST /api/approvals/:id/reject         { reason? (≤ 500) } → the tool call

Deciding needs Editor access to the task (viewers 403, others 404) and a signed-in session (API tokens get 403). It happens once: the same person repeating it gets 200; anyone else, or a change of mind, gets 409 APPROVAL_ALREADY_DECIDED; an expired request or one whose run ended gets 409 APPROVAL_CLOSED. A decision is logged on the task (RUN_ACTION_APPROVED / RUN_ACTION_DECLINED), marks the approval_requested notifications read and sends approval.updated { workspaceId } to the people asked. Unanswered requests are rejected automatically after AGENT_APPROVAL_TIMEOUT_SECONDS (default 1800; GET /api/agent-templates returns it as approvalTimeoutSeconds), and Home (GET /api/workspaces/:wid/home) lists the tasks waiting for my approval in waitingApproval.

AI usage and the monthly limit. What AI teammates cost, in US dollars as the model provider reports it for each run (usage.costUsd), with tokens (usage.totalTokens) and runs. A run counts in the UTC month it ended, once its usage is recorded (runs stopped before they started and runs lost by the runner record none). A workspace may have a monthly limit: at or over it, every new run — the button, Retry, "Start now", @mentions, schedules, state triggers, automations, recurring tasks, playbook steps, delegation and Ask — and the agent builder chat and the AI project builder answer 409 AI_PAUSED ("AI is paused: this month's limit is reached"); background triggers record it instead (a skipped schedule or recurring task: a RUN_FAILED line on the task; an automation: its lastError; a mention: the teammate's reply; delegation: the tool's answer). Runs already queued or running finish, so a month can end a little over. AI resumes on the 1st of next month or as soon as the limit is raised or removed.

GET /api/workspaces/:wid/ai-usage?month=YYYY-MM  → AiUsageReport (owners and admins; default this month, up to this month)
GET /api/workspaces/:wid/ai-usage/status         → { paused, resumesOn }  (everyone in the workspace; no amounts)
PUT /api/workspaces/:wid/ai-usage/cap            { capUsd: 1–1000000 (whole cents) | null } → AiCapView (owners and admins; not with API tokens)

AiUsageReport is { month, totals, byTeammate, byProject, byPerson, months, cap }: totals and each row carry { costUsd, tokens, runs }; rows have key, label and an optional note ("Removed" for a deleted teammate, which keeps the name its runs were made with; "Archived"; "Left the workspace"). Projects are the one each run was made in (a run does not move with its task), and every personal space is one row, "My spaces" (my-spaces), never whose; people are who started the run, "Automatic" ("") for schedules and rules. Runs on tasks deleted since still count in the month: a "Tasks deleted since" row (deleted) is added to each breakdown. months is the last 12 months, this one first. cap is { capUsd, spentUsd, month, percent, paused, resumesOn, alerted } for this month. Owners and admins get an ai_limit notification at 80% and at 100% of the limit, once each a month; changing the limit lets the thresholds the month is no longer at go out again. ai-usage.changed { workspaceId } goes to the workspace: room when the limit changes and when an alert goes out (80% or 100%). Only owners and admins get the ai_limit group in their notification settings. Money amounts for AI are theirs everywhere: All projects' AI spend and a run's usage.costUsd are left out for everyone else (above).

References and connections#

GET /api/workspaces/:wid/refs/:kind/:id/graph?depth=1|2   { root, nodes, edges: [{ from, to }], truncated }

"Connections" of a task, page, note, record… (anything a reference can point at; 404 when you can't open it): what it mentions and what mentions it (depth: 1 on nodes), and with depth=2 what those link with (depth: 2, via the neighbour, as "kind:id"). Edges read "from mentions to". Only entities you can open, never reached through one you can't; nothing hints at hidden ones. Newest links first, at most 30 direct neighbours, 8 new entities per neighbour and 60 in all (truncated when cut; LINK_GRAPH_LIMITS in @taskcrew/shared). Cards carry no content preview.

Wiki#

GET  /api/items/:iid/pages             pages of a wiki
POST /api/items/:iid/pages             { title, parentId?, markdown? }
GET  /api/pages/:pid

Page Markdown (and markdown above) may hold live embeds of a task list, table or dashboard, each on a line of its own: !@[Task list](item:<id>), optionally with settings {view=<team view id>,rows=5|10|25|50}. The word is the item's type (Task list, Table, Dashboard), never its name; the editor shows the live item only to people who can open it, and the page counts as linking to the item (backlinks).

Tables#

Cell values are JSON keyed by column id; null clears a cell. Types: text, long_text, number, currency, select, multi_select (choice ids), date (YYYY-MM-DD), checkbox, url, email, phone, person (member id), rating (1–5), link ({ kind, id } of any task, page, note, record…), and file (media ids: the files sent with a form's responses — made only by forms; such columns and cells can't be created, changed or typed in, 400; deleting a row, the column or the table removes those files). A table holds up to 50,000 rows (400 TABLE_FULL).

GET    /api/items/:iid/columns
POST   /api/items/:iid/columns          { name, type, options?, width?, afterId? }
PATCH  /api/columns/:cid                { name?, type?, options?, width?, isPrimary?, force? }
DELETE /api/columns/:cid
PUT    /api/items/:iid/columns/order    { ids }
POST   /api/items/:iid/columns/template { template: contacts|inventory|budget|starter } (empty tables; not a form's
                                        Responses table: 400 FORM_RESPONSES_TABLE)
GET    /api/items/:iid/rows?q=&sort=<columnId>:asc|desc&filter=<json>&scope=<json>&limit=&cursor=
                                        → { rows, total, nextCursor }
GET    /api/items/:iid/rows/groups?by=<selectColumnId>&per=&q=&sort=&filter=
                                        → { total, groups: [{ value, total, rows, nextCursor }] } (kanban lanes)
POST   /api/items/:iid/rows             { values, afterId? } → row  |  { rows: [{ values }] } (≤ 500) → { rows }
GET    /api/rows/:rid
PATCH  /api/rows/:rid                   { values } — only the given cells change
DELETE /api/rows/:rid
POST   /api/items/:iid/rows/delete      { ids } (≤ 1000)
PUT    /api/items/:iid/rows/order       { ids, afterId }  (null = to the top)
POST   /api/items/:iid/import           CSV as multipart `file`, `{ "csv": "…" }` or a text/csv body
GET    /api/items/:iid/export           CSV

filter is {"mode":"and"|"or","conditions":[{"columnId","op","value"}]} with op one of contains, not_contains, eq, neq, starts_with, gt, gte, lt, lte, empty, not_empty, any_of, none_of, all_of, checked, unchecked (dates accept "today", people "me"). Changing a column's type converts values; when some cannot be kept the API answers 409 CONVERSION_LOSES_DATA (with the count and examples) — send force: true to convert and clear them. CSV import matches the header row to columns by name and creates missing ones with guessed types.

scope has the same format as filter and is AND-ed with it (a kanban lane, a calendar month: views add it without touching an "any of" filter). /rows/groups returns one group per option of a single choice column in option order, then value: null (no option, or one that no longer exists); each has the lane's total, its first per rows (default 50, lowered so all lanes together return ≤ 2,000) and a nextCursor that continues the lane through GET …/rows with the same q/sort/filter, scope = that option (eq, or none_of every option for the null lane) and cursor. Grouping by another column type → 400 INVALID_GROUP.

Automations#

An automation belongs to the item where its trigger happens: a task list (task, date and playbook-run triggers) or a table (row_added, row_changed); both can run on a schedule (daily / weekly / monthly / yearly at a time, in an IANA time zone — no custom cron). Rules are JSON (automationInputSchema in @taskcrew/shared); a rule whose conditions or actions do not fit its trigger (add_label on a schedule, start_playbook on a schedule — use the playbook's schedule —, a row condition on a task trigger) answers 400 INVALID_AUTOMATION with the problem in words.

GET    /api/items/:iid/automations          → [{ …, summary, valid, nextFireAt, runsAs: { id, name } | null }]  (viewer)
POST   /api/items/:iid/automations          { name, enabled?, trigger, conditions, actions } → 201  (editor of the list or table)
PATCH  /api/automations/:id                 any of those fields
DELETE /api/automations/:id                 → 204
GET    /api/automation-templates?itemId=    one-click recipes for that list or table

Actions that reach other items (create_task, create_row, start_playbook, move_to_list) run with the access of the person the rule runs as: whoever last saved its trigger, conditions or actions (a rename or turning it on/off changes nothing). Their access is checked when saving and again every time it runs; without it the action stops and lastError says why. Texts may hold {{task.title}}, {{task.key}}, {{task.due}}, {{assignees}}, {{list}}, {{row.<Column name>}} and {{today}}. Row triggers fire for rows added or changed through the API, MCP and other automations; CSV imports, table templates and column conversions never fire. A row counts as added once its first (primary) column has a value — when it is created with one (the first 100 rows of a batch), or later when that column is first filled in (a row started in a kanban lane, on a calendar day or with "+ Row"); every other change is a row_changed. A rule that runs more than 200 times in 10 minutes is turned off and the person it runs as is notified.

Health (Settings → Automation & connector health). Runs on tasks are read from their AUTOMATION_RAN history; table and scheduled rules keep only runCount, lastRunAt and lastError (period: null). Tool calls count under the dashboard history rule (runs on tasks in lists the admin can see now, not in the Trash); calls that waited for an approval and were not approved, and calls still running, are left out. Shares and times need ≥ 5 calls (null below). No tool arguments or results are returned — the last failure links to its run (taskId, runId).

GET /api/workspaces/:wid/automation-health?days=7|30   → { days, since, rows: AutomationHealthRow[] }  (owners and admins; problems first)
GET /api/automations/:id/runs                         → AutomationRunEntry[]  last 20 runs on tasks, 30 days (viewer of its list or table;
                                                        `task: null` when you can't open it now; `[]` for table and scheduled rules)
GET /api/workspaces/:wid/connector-health?days=7|30   → { days, since, rows: ConnectorHealthRow[] }  (owners and admins; per connector and
                                                        "TaskCrew tools": calls, failed, failureRate %, typicalMs / slowestMs (median / p95),
                                                        lastFailure, top 5 tools, connection status)

Dashboards#

Widgets live on a dashboard item; their data is always computed for the person asking (their access, time zone and "me"). Sources: tasks, table, and the history sources activity (changes to tasks), ai_runs and playbook_runs (see widgetConfigSchema in @taskcrew/shared).

tasks groups by state, assignee, priority, label, list, project, due, completed and created (assignee: each person and placeholder person — key placeholder:<id>, label "Alex (placeholder)" — else the AI teammate, else Unassigned; the unassigned filter also leaves out tasks with a placeholder, and the people filters stay people only; completed: the task's completion date — a task in a Done state counts once, on the day it was last marked done, so a reopened task leaves its old week and one reopened since is in none). "Done per week" (the starter dashboard, the gallery's question and the flow set) is { source: { type: "tasks", scope: "project", filters: { status: "done" } }, groupBy: { by: "completed", interval: "week", periods: 12 } }. Date groups are labelled "Oct 2026", "Oct 5" (a day) or "Week of Oct 5" (also in the CSV). A sum/avg/min/max of a task field reads the number and money fields of that name in the lists read (subtasks only with filters.includeSubtasks); money fields of that name in different currencies are never added together — the widget answers { status: "invalid", message: "“Cost” uses EUR and USD in these lists — choose lists with one currency (EUR: Paris pop-up · USD: Spring campaign, Trade show)" } (lists of the widget's scope, at most three per currency, then "and N more").

Flow measures on activity, from status changes dated by the day of the change: a finish is a STATE_CHANGED into a Done category from one that is not (older rows without categories are read by the state's primary key; an unknown category is left out). { distinct, finished } counts the different tasks finished (count = the finishes behind it; "Done per week" reads the tasks' completion date instead, see above); { avg|max, cycle } the seconds from the first move into an In progress or AI working state at or before it (tasks never started are left out); { avg|max, in_state } the seconds a task stayed in a state, counted when it leaves a state that is not Done or Canceled, from the change before (a status change or a move to another list) or else its creation. Group from_state: the state it left (status changes only). A drill-down (group) of a max or min measure lists only the record(s) with that value. display.cumulative on a line of a count or a total (any source) shows a running total from the start of the dates; a running count of different tasks counts each task once, in its first period, and a drill-down of a point lists everything up to the end of its period. Changed on 2026-10-08: deadline slippage (moved, slip), lead time (lead) and the reopen rate (reopened) are gone; a stored widget that still reads a removed measure or grouping answers { status: "invalid", message: "This widget is not set up yet" } (the migration one_flow_report deletes them). The done_per_week migration turns the set's stored Done per week (from status changes) into the tasks chart above and renames default titles ("Completed per week", "Cycle time per week").

ai_runs reads runs on tasks the viewer can see, dated by the day they were queued (Trash, deleted tasks, archived projects and other people's My space are left out — also for admins). Measures: count, { distinct, task }, duration and wait (seconds), { avg, success } — Done ÷ (Done + Failed) in percent, stopped and unfinished runs left out — and { avg|max, review } — seconds from a succeeded run's end to the first move out of "AI done" after it, counted only when a person made it to a non-AI state. Both keep count as the runs behind the value. Groups: time, agent, status, trigger, failure (in words), person (who started it, else "Automatic"), list, project (the task's current one). The cost and tokens measures and the ai_usage set moved to Settings → AI usage (above); stored widgets that read them were deleted by the ai_usage migration, and any left answer { status: "invalid" }.

playbook_runs reads runs of the playbooks the viewer can see, dated by when they started (when: "finished": by when they finished). Measures: count (runs) and { avg|min|max, duration } (seconds from start to finish of completed runs). Groups: time, playbook, status, owner, list, project; other groupings answer { status: "invalid" } (the step and phase measures and groupings were removed). The run page's GET /api/playbook-runs/:id returns startStateId, the target list's first In progress state, where "Start" moves a ready step (POST /api/tasks/:tid/move).

GET    /api/items/:iid/widgets                → { widgets, role, settings: { range?, filters? } }
POST   /api/items/:iid/widgets                { kind, title?, config, w?, h? }
PATCH  /api/widgets/:wid                      { kind?, title?, config?, w?, h? }
DELETE /api/widgets/:wid
PUT    /api/items/:iid/widgets/layout         { widgets: [{ id, w, h }] }
PUT    /api/items/:iid/widgets/settings       { range?, filters?: { projects?, lists?, people?, ai? } }  default dates and filters (editors)
POST   /api/items/:iid/widgets/preview        { kind, config, group?, view? }  data of an unsaved widget (editors)
POST   /api/items/:iid/widgets/template       { key: team|sales|personal|flow, replace? }  add a ready-made set (editors; flow — "How work flows": done per week, time from start to done, where work waits; without replace it skips the charts the dashboard already has, unless it has them all)
GET    /api/items/:iid/widgets/sources        lists, tables, people, AI teammates, projects, playbooks
GET    /api/widgets/:wid/data?group=&<view>   numbers, groups, points, pivot or cards
GET    /api/widgets/:wid/csv?<view>           the same data as CSV (UTF-8 with BOM)
GET    /api/items/:iid/widgets/pdf?<view>     the whole dashboard as an A4 PDF (6 a minute per person, else 429)
GET    /api/items/:iid/widgets/schedules      → { schedules, canEdit, mailEnabled, timezone, people }
POST   /api/items/:iid/widgets/schedules      { frequency: weekly|monthly, weekday? (1 = Monday), monthDay? (1–28),
                                                hour, timezone?, range?: { preset } | null, recipientIds }  (editors)
PATCH  /api/dashboard-schedules/:sid          same fields, all optional, + enabled  (editors)
DELETE /api/dashboard-schedules/:sid          (editors)
DELETE /api/dashboard-schedules/:sid/recipients/me   "Stop sending me this" (anyone on the list)
POST   /api/dashboard-schedules/unsubscribe?t=       one-click unsubscribe from the email (RFC 8058, no sign-in)

<view> is the viewer's own choice, which wins over the dashboard's defaults part by part: dates=last_7_days|last_30_days|last_90_days|last_12_months|this_month|last_month|last_week|custom (custom with from=YYYY-MM-DD&to=YYYY-MM-DD, at most 366 days), and comma-separated ids in projects, lists, people, ai (any = no filter). Dates apply to history widgets and charts over time (history without dates covers the last 30 days); filters only narrow, and never apply to tables or to widgets with config.ignoreFilters; config.ownDates gives a widget its own dates. The drill-down group of a pivot cell is ["<row key>","<column key>"] (null = a total). Slow history queries stop after 8 seconds and the widget answers { status: "invalid", message }.

Email snapshots. A schedule mails the dashboard as a PDF every week or month at a local hour in its time zone (the creator's by default), with its own preset dates (null = the dashboard's) and the dashboard's filters. Up to 5 schedules per dashboard and 20 recipients each; recipients must be workspace members who can open the dashboard (400 RECIPIENT_CANNOT_OPEN), and are checked again at every send (still a member, can still open it, email confirmed). Each PDF is computed as its recipient. Editors see every schedule and the people they can choose; everyone else sees only the schedules they are on. Without email on the server (mailEnabled: false) schedules cannot be added (400 MAIL_OFF) and nothing is sent. Test stacks send what is due with POST /api/internal/dashboards/snapshots/send { at? } (internal token; at counts with the memory mail transport only).

Vaults#

Vault endpoints only work for people signed in to the app. API tokens, MCP and AI teammates get 403 VAULT_APP_ONLY (see the security notes). No GET returns a secret.

GET    /api/items/:iid/vault-entries[?archived=true]  { vault, entries } — no secrets (archived: editors)
POST   /api/items/:iid/vault-entries     { type?, title, username?, url?, tags?, meta?, password?, notes?, totp?, fields?, key?, files? }
PATCH  /api/vault-entries/:id            same fields except type (an entry keeps it); a new password or key moves rotatedAt
DELETE /api/vault-entries/:id            to the trash (restorable for 30 days)
POST   /api/vault-entries/:id/restore
POST   /api/vault-entries/:id/reveal     { fields?, purpose?: view|copy|edit|download, password? } → the secret (logged, 30/min)
POST   /api/vault-entries/:id/copied     logs a copy of a secret already on screen
GET    /api/items/:iid/vault-log[?entryId=&before=]   access log (managers)
PATCH  /api/items/:iid/vault-settings    { requireReauth?, rotateAfterDays? } (managers)
POST   /api/vault/unlock                 { password } → unlocks vaults for 5 minutes (httpOnly cookie)
POST   /api/vault/lock

Entry types (type, default login): login, totp (2FA code), ssh, api (API key), note (secure note), file and database. Secret parts, encrypted together: password (login/database password, SSH passphrase, API secret), notes (markdown), totp (base32 seed or otpauth://totp/… link), fields ([{ label, value, kind?: text|hidden|url }]), key (SSH private key, API key or token) and files ([{ name, type, data: base64 }], up to 5 files and 1 MB in total). meta holds the facts that are not secret, each cleared with null: host, port, database, engine (databases, SSH), publicKey, keyFile (SSH), expiresAt (YYYY-MM-DD; API keys, files), wiki ({ id, title } of a wiki page with instructions) and twoFactorFor (a 2FA entry's login in the same vault). The server derives an SSH key's publicKey, fingerprint and keyType from the private key (OpenSSH keys always, PEM keys when not encrypted), a 2FA entry's issuer and account from its link, and the names and sizes of files. Listed entries also carry hasKey, fileCount, expiry (expired | soon within 30 days | null) and twoFactorId (the 2FA entry attached to a login). Reveal fields may name any secret part (password, notes, totp, fields, key, files); purpose: download is logged as downloaded.

When a vault asks to re-confirm the password and it is locked, reveal answers 403 REAUTH_REQUIRED. Send the call again with password (a wrong one gets REAUTH_FAILED).

Forms#

A form is an item (type: "FORM", created like any item) with its own Responses table: a TABLE item made when the form is first opened and made again if it is deleted for good or moved to another project. It is the form's own hidden table (ownerItemId = the form, named "<form> › Responses"): left out of the sidebar, item search and references, shown in the form's Responses tab, renamed, moved, archived, restored and deleted for good with the form; its own URL opens that tab. POST /api/items/:tableId/show-in-sidebar makes it a normal table, still "<form> › Responses", the form keeps writing to. It follows the form's sharing: made private or open again with the form, and people added to the form, given another role or taken off it get the same on the table (people shared on the table alone stay). Its item (GET /api/items/:iid) has responsesOf: { id, name } (both null for people who can't open the form). A "Submitted" date column comes first (the day it came in), then each question is a column, kept in step when the questions are saved (renamed with its question, added, reordered among the form's columns; a column that is gone or no longer fits its question's answers is replaced by a new one; a removed question's column goes when it holds no answers and otherwise stays with its data). A response that leaves the (text) title column empty is named by an email's subject or by when it came in ("Response on Oct 9, 2026, 2:32 PM"), and every response fires row_added automations. Every response is a row at once — read, filter, export and delete responses with the table routes above; there is no queue. Viewers of the form see it; saving and sharing need edit rights on the form.

GET  /api/items/:iid/form                        { fields, settings, slug, access, published, problems, open,
                                                   responses: { id, name, rows, inside } | null, responsesHidden, taskList: { id, name } | null,
                                                   email: { available, enabled, address, allow } }
PUT  /api/items/:iid/form                        { access?: public|members, published?, fields?, settings? } (whole lists)
POST /api/items/:iid/form/link                   new public link (the old one stops working)
POST /api/items/:iid/form/email                  { enabled?, rotate?, allow?: ["ana@client.com", "@client.com"] }
PUT  /api/items/:iid/form/responses-table        { tableId: uuid | null, clearSamples? } — send responses to an existing table, or keep them inside
GET  /api/items/:iid/form/responses-tables       { tables: [{ id, name, unavailable: string | null, samples }] } — where they could go

responses-table (edit rights on the form and the table): a table of the form's project that no other form uses and that is no other item's own table (400 INVALID_RESPONSES_TABLE); the form adds its columns there and, when the form is private, makes the table private with the form's people. null gives the form a new own table. The own table it leaves is deleted when it holds no response, otherwise shown in the sidebar as "<form> › Responses". clearSamples deletes the untouched example rows a new table starts with (samples in the list). Every response fills "Submitted" (its day), "Submitted at" ("2026-10-10 14:32", the form owner's time zone) and "Submitted by" ("Public" / "Email"; a person column on members-only forms). The "new responses" notification is about the form (itemId, with data.tableId).

A question is { id, type, label, help, required, options, showIf: { fieldId, oneOf } | null } with type one of short_text long_text email phone number date choice multi_choice yes_no link file (≤ 50 questions); their columns are text, long text, email, phone, number, date, single choice, multiple choice, single choice "Yes"/"No", web link and file. Members-only forms add a "Submitted by" person column; email adds "Email from / subject / message / attachments". A rule may only point at an earlier choice or yes/no question. responses is null when the viewer can't open the table (responsesHidden: true). settings.task: { listId, titleFieldId | null } | null is "Also create a task": a task list in the form's project the person can edit (400 INVALID_TASK_LIST, 404 when they can't open it); the title comes from a short-answer question (400 INVALID_TASK_TITLE; removing that question clears it). Each response then also makes a task there (title, the answers as plain text, a link to the row; history CREATED_FROM_FORM); a list that is gone or archived skips the task, never the response. Turning a form on with problems left answers 400 FORM_NOT_READY.

The page people fill in (no session; members-only forms use /member with a signed-in session, never an API token):

GET  /api/f/:slug             → { access, name, description, fields, maxFiles, maxFileMb, check, token } — or { access: "members" }
POST /api/f/:slug             multipart: answers (JSON), token, check?, files as file:<questionId> (≤ 5) → { ok, confirmation }
GET  /api/f/:slug/member      same as GET, plus you: { name, email }
POST /api/f/:slug/member

Closed, unknown or rotated links answer 404 FORM_UNAVAILABLE. A page left open for a day answers 400 FORM_RELOAD; a wrong check answer 400 FORM_CHECK; answers that do not fit 400 FORM_INVALID with details.errors per question. Limits and spam checks: the security notes.

Emailed responses: POST /api/inbound/email/mime with HTTP Basic auth (password INBOUND_EMAIL_SECRET) and the raw message — as the body (Content-Type: message/rfc822), Mailgun's body-mime + recipient, or SendGrid's raw email + envelope; ?to= or X-Original-To can name the recipient. 200 { ok, stored, reason? } (nothing stored for mail that is not for an open form, auto-replies or senders not allowed), 401 wrong secret, 404 not set up.

curl -u inbound:$INBOUND_EMAIL_SECRET -H 'Content-Type: message/rfc822' --data-binary @brief.eml https://api.example.com/api/inbound/email/mime

Imports from other tools#

Tasks from another tool's export file, always into a new task list of a project you can edit (403 for viewers). An import is seen only by its importer and workspace admins (404 for anyone else). Nothing is written until it is started; it then runs in the background (100 tasks per transaction) and the importer's user: room gets import.progress { id, projectId, status, done, total, listId }.

POST /api/projects/:pid/imports      multipart: file (≤ 20 MB, else 413), source: trello|asana|clickup|monday|jira|csv
                                     → 201 ImportDto in REVIEW: preview { counts, statuses, people (suggestedUserId), fields,
                                       columns (csv), members, sample, dates: { ask, sample }, warnings }, mapping, duplicateOf
GET  /api/projects/:pid/imports      latest 20 (yours; admins: everyone's)
GET  /api/imports/:id                status REVIEW|QUEUED|RUNNING|DONE|FAILED|CANCELED, done/total, list { id, name, archived,
                                     addedLater }, summary, error
PUT  /api/imports/:id/mapping        { listName, statuses: { key: { name, category } | { mergeInto } }, people: { key: userId|null },
                                       fields: { key: TEXT|NUMBER|CURRENCY|SELECT|MULTI_SELECT|DATE|CHECKBOX|URL|LOCATION|skip },
                                       columns?: { index: title|description|status|assignee|due|start|priority|estimate|labels|id|parent|field|skip }
                                       (estimate: hours like "1.5" or "1h 30m"; other values are left out),
                                       dateOrder?: dmy|mdy, includeArchived? } → ImportDto with dryRun { tasks, subtasks, comments,
                                       checklistItems, attachments, states, labels, fields, assigned, unassignedPeople, skipped, warnings }
POST /api/imports/:id/start          REVIEW → QUEUED (409 ALREADY_STARTED otherwise)
POST /api/imports/:id/cancel         stops it (a running import after the batch it is writing); 409 when it has finished
POST /api/imports/:id/undo           archives the list it made (restorable by managers); 409 NOTHING_TO_UNDO / ALREADY_UNDONE

Files that are not what the source makes answer 400 WRONG_FILE / INVALID_FILE; programs, scripts and web pages 400 FILE_NOT_ALLOWED; over 5,000 tasks, 20,000 comments, 100 statuses or 500 people 400 TOO_MANY_*; choices that do not fit the file (a person who cannot open the project, two statuses with one name, more than 50 fields) 400 INVALID_MAPPING; a file whose dates like 03/04/2025 could be either needs dateOrder (400 DATE_ORDER_REQUIRED: the preview's dates.ask). duplicateOf names who imported the same file before in the workspace (by, null for you).

Playbook schedules and the work calendar#

One schedule per playbook; runs start as whoever last saved it (owner). rule is a recurring rule (daily, weekly, monthly, yearly, custom) or { freq: "business_day", nth: 1…20 | -1…-5, time } (the Nth / Nth-to-last business day of every month), read in timezone, at most once a day. periodOffset 0 | -1: each run is for the month it starts in or the month before; answers (inputs) may hold {{period}} ("October 2026"), {{period_year}} and {{quarter_end}} (Yes / No), filled per run (run names and step titles can use them too, also for runs started by hand).

GET    /api/items/:iid/playbook/schedule           { schedule: { id, enabled, rule, summary, timezone, periodOffset, inputs,
                                                   roleAssignments, owner, nextRunAt, lastRunAt, lastRunId, lastStatus:
                                                   started|skipped|failed, lastError, runCount } | null, upcoming: [{ at, date, name }] }
                                                   (anyone who can see the playbook)
PUT    /api/items/:iid/playbook/schedule           { rule, timezone, periodOffset?, inputs?, roles? } → as GET. Editors who can add
                                                   tasks to its list; every role used by a step filled with someone who can open the
                                                   list (400 MISSING_ROLE / MISSING_INPUT / INVALID_INPUT / INVALID_ASSIGNEE /
                                                   INVALID_SCHEDULE / INVALID_TIMEZONE); the saver becomes the owner
PATCH  /api/items/:iid/playbook/schedule           { enabled } — pause, or resume from now (no catch-up; 400 OWNER_CANNOT_START)
POST   /api/items/:iid/playbook/schedule/preview   { rule, timezone, periodOffset?, inputs? } → { summary, occurrences: [{ at, date,
                                                   name, leftOut, error? }] } (the next 3)
POST   /api/items/:iid/playbook/schedule/run-now   starts the upcoming run now (its month; due dates count from its date; it will
                                                   not start again) → { runId, schedule, upcoming } (409 ALREADY_RUNNING)
DELETE /api/items/:iid/playbook/schedule           → 204 (runs already started stay)
GET    /api/workspaces/:wid/work-calendar          { workWeek: [0–6, 0 = Sunday], holidays: [{ id, date, name }] (from a year ago,
                                                   ≤ 500), canEdit } — members
PUT    /api/workspaces/:wid/work-calendar          { workWeek } (at least one day) — owners and admins
POST   /api/workspaces/:wid/holidays               { date: YYYY-MM-DD, name } → 201 the calendar (409 HOLIDAY_EXISTS; ≤ 1000)
DELETE /api/workspaces/:wid/holidays/:id           → 204
GET    /api/workspaces/:wid/capacity-settings      { weeklyHours (40), hoursPerTask (4), canEdit } — members (Workload in hours)
PUT    /api/workspaces/:wid/capacity-settings      { weeklyHours 1–168, hoursPerTask 0.25–80 } (quarter hours) — owners and admins
PUT    /api/workspaces/:wid/members/:uid/capacity  { weeklyHours: 0–168 | null } someone's week (null = the typical one) — owners
                                                   and admins; never API tokens (the members rule)
GET    /api/workspaces/:wid/time-off               ?from=&to= (default a month back to a year ahead, ≤ 2 years) &userId= → [{ id,
                                                   user, startDate, endDate, note?, canEdit }] (≤ 1000) — members; `note` only for
                                                   the person and admins
POST   /api/workspaces/:wid/time-off               { userId?, startDate, endDate (≤ 366 days), note? (≤ 200) } → 201 — your own, or
                                                   anyone's for owners and admins (≤ 200 per person)
DELETE /api/workspaces/:wid/time-off/:id           → 204 — the person or an admin

A slot starts once (a second start of the same slot is ALREADY_STARTED and counts as done); after downtime only the latest missed slot starts and the owner is told how many were missed. People who can no longer take their steps are replaced by the owner (who gets a run_schedule notification); when the owner cannot start it any more the schedule pauses; any other problem skips that one run (lastStatus: skipped), and so does a run whose name is already running. Steps take dueOffsetBusinessDays (0–250, exclusive with dueOffsetHours; 0 = the start day or the next business day) and condition: { input, op: is | is_not, value } | null; questions take options (≤ 12) and a start with an answer that is not one of them is 400 INVALID_INPUT. Steps whose condition does not apply are not created (what waited for them waits for what they waited for, unless another of its links implies it already); if none apply, 400 EMPTY_PLAYBOOK.

Time tracking#

People log their own time on tasks of team projects they can edit (never My space: 400 NOT_FOR_MY_SPACE; archived: 400 PROJECT_ARCHIVED; AI teammates never: 403 AI_CANNOT_LOG_TIME). An entry is one task on one day of the person's calendar (users.timezone): 1–1440 minutes, dated from two years back to tomorrow (400 INVALID_DATE), billable or not. People change and delete their own entries at any time.

GET    /api/me/timer                     { timer: { id, workspaceId, startedAt, date, note, billable, task, project } | null, now }
POST   /api/tasks/:tid/timer             { note?, billable? } → 201 as GET (a timer running elsewhere stops first; one per person)
PATCH  /api/me/timer                     { startedAt?, note?, billable? } (not in the future, at most 24 h back: 400 INVALID_START)
POST   /api/me/timer/stop                { minutes? } → the entry (what ran, rounded to a minute, 1–1440)
GET    /api/tasks/:tid/time              { available, canLog, billableByDefault, today, minutes, billableMinutes, people: [{ user,
                                         minutes }], entries (newest 200, no running timers), more } — anyone who can open the task
POST   /api/tasks/:tid/time              { date, minutes, note?, billable? } → 201 { id, user, taskId, projectId, date, minutes,
                                         startedAt, endedAt, note, billable, source, canEdit } (editors of the list)
PATCH  /api/time-entries/:id             { date?, minutes?, note?, billable? } — your own (403 for others' you can see)
DELETE /api/time-entries/:id             → 204
GET    /api/workspaces/:wid/timesheet?week=|from=&to=&projects=&user=
                                         { from, to, weekStart, today, person, rows: [{ task, missing?, project, days[],
                                         minutes, billableMinutes }], dayMinutes, minutes, billableMinutes, canEdit,
                                         canSeeTeam } — your own time (`user` = someone else's, read-only)
GET    /api/workspaces/:wid/timesheet/team?week=|from=&to=&projects=
                                         { from, to, weekStart, today, scope: 'workspace' | 'projects', people: [{ person,
                                         days[], minutes, billableMinutes }], dayMinutes, minutes, billableMinutes,
                                         projects: [{ id, name, color, icon, archived }] } — admins and project managers
GET    /api/projects/:pid/time           { today, month, minutes, billableMinutes, monthMinutes, billableByDefault, canManage }
                                         — people with a role on the project
PUT    /api/projects/:pid/time-settings  { billableByDefault } (managers; whether new time on the project is billable)

A task or list moved to another project takes its time along (never into My space).

Timesheets. Both timesheet routes take a week (week = any day of it, default this week in the viewer's time zone) or from + to (YYYY-MM-DD, at most 92 days: 400 INVALID_RANGE for more, or for an end before the start; week with from/to, or half a range: 400 VALIDATION_ERROR), and projects (comma-separated ids). Days are the entries' own dates (the logger's calendar); running timers are left out. Admins and owners see every team project (archived ones too); a project manager (an explicit Manager of a team project) sees the projects they manage. List managers and editors of projects open to everyone are not project managers here. Everyone else gets 403 FORBIDDEN on /team and on ?user= someone else (asked before the person is looked up), and guests 403 GUEST_NOT_ALLOWED. With ?user=, the person must be in the workspace or have time in it (404 NOT_FOUND), and only their time on the viewer's projects shows, with task names (and notes) only for tasks the viewer can open; canEdit is false. On those routes a projects id outside the viewer's projects is 404 NOT_FOUND (never read as "all"); on your own time, projects simply filters it. Team rows: every team member (not guests) for an admin without a project filter, otherwise the explicit members of the projects shown, plus anyone with time there (former members too), by name. The scope is worked out on every request. Personal API tokens act as their owner in their workspace.

Goals and portfolios#

Goals live in Goals items (type: GOALS): the item's viewers read them and follow them; its editors add, change, archive and delete them, log entries and write updates. A goal is a title and one target (a key result with an empty title) or 2–10 key results, each measured one way:

  • counter (targetValue a whole number ≥ 1, unit): what is logged adds up, +1 by default;
  • number (targetValue, startValue, unit or a currency code): a number or money amount; logged amounts add up from the start (targets below the start work);
  • done: done or not — one entry per period (409 ALREADY_DONE for another);
  • tasks (≤ 50) / projects (≤ 20 team projects): % done now, as before (they do not repeat: 400 NOT_REPEATABLE);
  • list (listId, stateId, onlyOwner, targetValue): top-level tasks of the list that reached the state in the period (moved into it, or created in it), optionally only those assigned to the goal's owner;
  • table (tableId, filter — the table view's conditions —, sumColumnId | null, dateColumnId | null, targetValue): rows matching the condition whose date (the day added, or a date column) is in the period, counted, or a number / money column added up (the currency follows the column).

An optional repeat (daily, weekly from Monday, monthly, quarterly) makes the bar count the current period only; past periods stay as history. A one-off goal counts all its entries, and linked work from startDate to endDate. Linked numbers are counted as the goal's owner, with their current access (in the owner's time zone); without an owner they keep their last count. Names are per viewer: tasks and projects carry the links the reader can open and hiddenLinks; a list or table measure is hidden: true — no numbers, names or condition — for a reader who cannot open that list or table, and the goal's progress is then null for them. Linking needs the editor to open the list, table, tasks or projects now (400 CANT_LINK, the same for something gone or hidden; 400 INVALID_SOURCE for a state or column that does not fit; 400 INVALID_FILTER); what a list or table measure counts is changed only by someone who can open it now (403; its title and target stay the editors'). Lists recount linked numbers counted more than 5 minutes ago or before the current period (in one batch); a goal's own page recounts it. An archived goal is frozen (409 GOAL_ARCHIVED for changes). Entries are for days from the goal's startDate to its endDate.

GET    /api/items/:iid/goals              ?show=current (default: not archived, not ended) | ended | archived | all
                                          → { canEdit, goals: GoalDto[] } (≤ 200, without links)
POST   /api/items/:iid/goals              { title, description?, ownerId | null, followerIds?, startDate? (default today),
                                          endDate?, repeat?, keyResults: [measure] (1, or 2–10 with titles) } → 201 GoalDto
                                          (400 OWNER_CANT_OPEN, FOLLOWER_CANT_OPEN, INVALID_DATES, CANT_LINK, NOT_REPEATABLE)
GET    /api/workspaces/:wid/goals         ?show=…&owner=<userId> → { enabled, goals: GoalDto & { item } (≤ 200), total, items:
                                          [{ id, name, projectId, projectName, canEdit }] } — every Goals item you can open;
                                          { enabled: false } when Goals are turned off
GET    /api/goals/:gid                    GoalDto: { id, itemId, title, description, owner, followers, following, startDate,
                                          endDate, repeat, period: { start, end }, today, remindAt, remindOn, archivedAt, progress (0–1 | null),
                                          computedAt, canEdit, keyResults: [{ id, title, kind, unit, currency, startValue,
                                          targetValue, currentValue | null, progress | null, missing, hidden?, links?, hiddenLinks?,
                                          source? (list / table you can open: itemName, stateName, filter, column names…),
                                          lastEntry? }] }
PATCH  /api/goals/:gid                    { title?, description?, ownerId?, startDate?, endDate?, repeat?, remindAt? ("HH:MM" | null),
                                          remindOn? (1–7, Monday = 1 | null = every day) } (400 NOTHING_TO_REMIND)
DELETE /api/goals/:gid                    → 204
POST   /api/goals/:gid/archive            → GoalDto (counted once more, then frozen)   ·   POST /api/goals/:gid/restore
POST   /api/goals/:gid/key-results        a measure with a title (the goal's own target is named like the goal) → 201 GoalDto
PATCH  /api/key-results/:kid              { title?, targetValue?, startValue?, unit?, currency?, add?, remove?, listId?, stateId?,
                                          onlyOwner?, tableId?, filter?, sumColumnId?, dateColumnId? } — what fits its kind
                                          (400 NOT_FOR_THIS_KIND; 403 for the list, table or condition of a list or table you
                                          can't open); tasks and projects change by add / remove → GoalDto
DELETE /api/key-results/:kid              → GoalDto (400 LAST_MEASURE: a goal keeps a target)
POST   /api/goals/:gid/entries            { keyResultId? (needed with several), day? (default the goal's today; later than any
                                          time zone's today: 400 FUTURE_DAY; before startDate: 400 BEFORE_START; after endDate:
                                          400 AFTER_END), value? (counter: +1, done: 1) } → 201 { entry, goal }
                                          (400 NOT_LOGGED for linked measures, VALUE_NEEDED, INVALID_VALUE)
GET    /api/goals/:gid/entries            ?keyResultId=&cursor= → { entries: [{ id, keyResultId, day, value, author,
                                          createdAt, updatedAt }], nextCursor } (newest day first, 50 a page)
PATCH  /api/goal-entries/:eid             { day? (the same days), value? } → { entry, goal }   ·   DELETE /api/goal-entries/:eid → GoalDto
GET    /api/goals/:gid/notes              { notes: [{ id, body, author, createdAt, updatedAt, canEdit, canDelete }] } (≤ 200)
POST   /api/goals/:gid/notes              { body (≤ 2,000) } → 201   ·   PATCH /api/goal-notes/:nid { body } (its author)
DELETE /api/goal-notes/:nid               → 204 (its author or the item's managers)
PUT    /api/goals/:gid/followers/:uid     follow (yourself: viewers; someone else: editors; 400 FOLLOWER_CANT_OPEN) → GoalDto
DELETE /api/goals/:gid/followers/:uid     unfollow → GoalDto
GET    /api/goals/:gid/periods            ?limit=12 (≤ 52) → { periods: [{ start, end, progress, keyResults: [{ id, value, target,
                                          progress }] }] } — a repeating goal's past periods, newest first (null: hidden or not counted)
GET    /api/goals/:gid/days               ?from=&to= (YYYY-MM-DD, ≤ 400 days) → { days: [{ day, keyResultId, value, entries }] } — each
                                          logged measure's total per day (the year heatmap and the month calendar)

The item's export is a CSV of goals and their measures in the current period (no names of linked things; a list or table measure's numbers only for an exporter who can open it). Realtime: goals.changed { itemId } to the item's room. Goals are not searched, referenced or given to AI teammates or MCP.

Reminders are off by default; the goal's editors set remindAt (each recipient's own clock) and, unless the goal repeats daily, remindOn (a weekday, or null for every day). They go to the owner and the followers who can still open the goal, as goal_reminder notifications (the Due date and goal reminders row of the notification settings): "Nothing logged today for …" when the goal has no entry in the period (a one-off goal: that day, or that week with a weekday), and once, 3 days before a one-off goal's end date, "3 days left for …" when its target isn't reached (only to people who see its %; the others get that day's "Nothing logged" one instead). A goal with nothing logged by hand has nothing to remind about unless it is a one-off goal with an end date (400 NOTHING_TO_REMIND); archived and ended goals stop, and a later change that leaves nothing to remind about (dates, repeat, key results) keeps remindAt but schedules nothing. Unfollowing stops a follower's reminders. Links lead to /w/:wid/goals?goal=<id>.

Portfolios are saved selections of team projects shown with the All projects columns: personal (their owner only) or shared (every member sees them; their owner and workspace admins change them). Each reader gets only the ids of the projects they can see; projects change by add / remove, so projects hidden from the editor stay. ≤ 50 per workspace, ≤ 200 projects each.

GET    /api/workspaces/:wid/portfolios    [{ id, name, shared, owner: { id, name }, canEdit, projectIds, createdAt }]
POST   /api/workspaces/:wid/portfolios    { name, shared?, projectIds? } → 201 (400 CANT_LINK, TOO_MANY_PORTFOLIOS)
PATCH  /api/portfolios/:id                { name?, shared? } (others' personal ones: 404; members on a shared one: 403)
DELETE /api/portfolios/:id                → 204
PUT    /api/portfolios/:id/projects       { add?, remove? } → the portfolio (400 CANT_LINK, TOO_MANY_PROJECTS)

Files & media: versions, folders, tags and thumbnails#

POST   /api/projects/:pid/media              multipart file, itemId?, folderId? ('' = none) → 201 MediaFile (straight into
                                               a folder of the project: 404 for another project's; guests 403 with folderId)
GET    /api/projects/:pid/media?folderId=<id>|none&within=<id>&tag=&type=&source=&q=&cursor=&limit=
                                             → { files: [MediaFile], nextCursor } (the newest version of each file;
                                               within = the folder and every folder inside it; not with folderId: 400)
GET    /api/media/:id                        → MediaFile with versions: [{ id, version, fileName, mimeType, size, createdAt,
                                               uploadedBy, url, downloadUrl }] (oldest first)
GET    /api/media/:id/file?download=1        → the bytes (the sandboxed file headers; Range → 206 / 416; private immutable cache)
GET    /api/media/:id/thumbnail              → image/webp, ≤ 400 px (images only; 404 = no thumbnail, 503 THUMBNAIL_BUSY)
POST   /api/media/:id/versions               multipart file → 201 MediaFile (library uploads, editors; task attachments:
                                               POST /api/attachments/:aid/versions, 400 TASK_ATTACHMENT)
PATCH  /api/media/:id                        { folderId?: id | null, tags?: string[] } → MediaFile (editors; the whole series;
                                               guests 403 GUEST_NOT_ALLOWED with folderId)
GET    /api/projects/:pid/media/folders      → { folders: [{ id, name, parentId, count }], tags: string[] } (A→Z; count =
                                               files directly in it)
POST   /api/projects/:pid/media/folders      { name, parentId? } → 201 folder (project editors; 409 FOLDER_EXISTS,
                                               400 FOLDER_LIMIT, 400 FOLDER_TOO_DEEP, 404 for a parent of another project)
PATCH  /api/media/folders/:id                { name?, parentId? } → { id, name, parentId } (project editors; renames and/or
                                               moves it with everything inside: 400 FOLDER_CYCLE, 400 FOLDER_TOO_DEEP,
                                               404, 409 FOLDER_EXISTS)
DELETE /api/media/folders/:id                → 204 (its files and subfolders move up one level; 409 FOLDER_EXISTS when a
                                               subfolder's name is taken there, and nothing changes)

MediaFile also carries version, folderId, tags and thumbnailUrl (null: no thumbnail). Folders nest up to 3 levels per project (≤ 200, names ≤ 60 characters, unique within a folder) and are listed A→Z; changes to a project's folders run under one lock, so concurrent moves never make a cycle or a fourth level. Tags are lower-cased, 1–30 characters, ≤ 10 per file. A file's folder and tags belong to its whole series (a new version keeps them). People who can open only some items of a project see only the folders on the way to a file they can see. The folder routes refuse guests (403). Deleting through the library follows the file's own rules (a task attachment: DELETE /api/attachments/:aid; a first version takes its series). Storage that cannot be reached answers 502 STORAGE_UNAVAILABLE.

Team chat#

Channels (public: readable by every workspace member; private: only by their members, workspace admins included) and direct messages (2–8 people, private, fixed). Every route re-checks access (AccessService.chat): 404 for non-members of a private channel or DM, guests and outsiders; API tokens get 403 on every chat route (no AI teammates or MCP in chat). Messages are Markdown with mention tokens, "@channel" / "@here", ref tokens (@[SAL-12 Title](task:<id>)) and up to 10 files (text ≤ 4000 characters, may be empty with files; 30 per 10 seconds per person, else 429 RATE_LIMITED); mentions keeps only people who can read the channel. Message ids are uuid v7 (time-ordered).

GET    /api/workspaces/:wid/chat               { channels: [summary], browse: [summary] } — mine (with unread ≤ 100 and mentions),
                                               then public channels I could join; summary = { id, kind: CHANNEL|DM, name, topic,
                                               private, archivedAt, lastMessageAt, createdById, memberCount, member, muted,
                                               lastReadId, unread, mentions, people (DMs: the others) }
POST   /api/workspaces/:wid/chat/channels      { name, topic?, private?, memberIds? } → 201 detail (409 NAME_TAKEN; names are lower case)
POST   /api/workspaces/:wid/chat/dms           { userIds } → 200 detail (the existing DM of these people, or a new one)
GET    /api/chat-channels/:id                  detail = summary + { members, canManage, canDelete, maxFileBytes }
PATCH  /api/chat-channels/:id                  { name?, topic?, archived? } → detail (its creator or an admin; DMs 400 DM_FIXED)
DELETE /api/chat-channels/:id                  → 204 (workspace admins; its messages go with it)
POST   /api/chat-channels/:id/join             → detail (public channels; 409 ARCHIVED)
POST   /api/chat-channels/:id/leave            → 204 (not DMs)
PUT    /api/chat-channels/:id/members          { add?, remove? } → detail (members add; its creator or an admin removes; anyone removes themselves)
POST   /api/chat-channels/:id/read             { messageId } → { lastReadId } (only moves forward; reads my chat notifications about it)
POST   /api/chat-channels/:id/mute             { muted } → { muted } (no notifications from it)
GET    /api/chat-channels/:id/messages         ?before=<id> | ?around=<id> → { messages, hasOlder, hasNewer } (50 per page, oldest first)
POST   /api/chat-channels/:id/messages         { body, attachmentIds? } → 201 message (writing in a public channel joins it; 409 ARCHIVED;
                                               400 FILES_GONE when a file is not my unsent upload to this conversation)
PATCH  /api/chat-messages/:id                  { body } → message (its author; the files stay)
DELETE /api/chat-messages/:id                  → 204 (its author, a workspace admin or the channel's creator): the text, files and
                                               reactions go at once
PUT    /api/chat-messages/:id/reactions/:emoji → message (one of the comment reactions: thumbs_up, heart, tada, eyes, check, pray)
DELETE /api/chat-messages/:id/reactions/:emoji → message
POST   /api/chat-channels/:id/files            multipart `file` → 201 attachment (≤ MAX_UPLOAD_MB, 400 FILE_TOO_LARGE; only its
                                               uploader can fetch it until sent; unsent ones go after a day)
GET    /api/chat-channels/:id/files            ?before=<id>&images=1 → { files: [attachment + uploader], hasMore } (sent, newest first)
GET    /api/chat-files/:id/download            ?inline=1 → the file (sandboxed; inline only for safe types; byte ranges)
GET    /api/chat-files/:id/thumbnail           → a picture's WebP thumbnail (made at upload)
DELETE /api/chat-files/:id                     → 204 (my unsent upload)
POST   /api/chat-messages/:id/task             { itemId, title, assigneeIds?, dueDate? } → 201 { message, task: { id } }
                                               (EDITOR on the list; 409 ALREADY_A_TASK, MESSAGE_DELETED)

A message is { id, channelId, authorId, author, body, mentions, broadcast: channel|here|null, attachments, reactions: [{ emoji, userIds }], taskId, task, editedAt, deletedAt, createdAt }; an attachment is { id, channelId, messageId, uploaderId, fileName, mimeType, sizeBytes, image, width, height, createdAt } (image: it has a thumbnail). task (the chip: { id, key, title, done }) is set only when the reader can open the task. "@channel" notifies the conversation's members, "@here" those of them online (everyone while the workspace hides who's online), at most 200, as chat_mention ("… notified everyone in #x"); both count as mentions in the unread counts. Realtime: chat.message and chat.message.updated (the message without task) go to the chat:<id> room, which members join for their unread counts (and anyone reading a public channel); chat.channels ({ workspaceId }) to the workspace room when channels or their people change. Notifications: chat_mention (the "mentioned" group) and chat_dm (the "messages" group, one unread per conversation) carry data: { chatChannelId, chatMessageId }, a title naming the channel and no body.

Presence#

Who's online. Tabs announce online / away (no input for 5 minutes, or hidden) over the socket in the presence:<workspaceId> room (members only, never guests); typing is the typing state in a conversation's chat: room. The server refuses what the workspace does not show (and drops what it held when a switch goes off): online states while "Show who's online" is off or from someone who appears offline, typing in chat while "Show typing indicators" is off (read as viewing on a task). "Last seen" is written when someone leaves and at most once a minute while online.

GET    /api/workspaces/:wid/presence           { showOnline, showTyping, appearOffline (mine), lastSeen: { userId: iso } }
                                               (lastSeen: members who don't appear offline, only while dots are shown; guests: none)
PATCH  /api/workspaces/:wid/presence           { showOnline?, showTyping? } → the same (admins)
PUT    /api/me/presence                        { appearOffline } → { appearOffline } (every workspace)

Realtime: presence-settings.changed ({ workspaceId } to the workspace room, { userId } to the person's room): tabs refetch the switches.

Notifications#

The Inbox (GET /api/workspaces/:wid/notifications?filter=all|unread|mentions|assigned|ai&cursor=, …/unread-count, POST …/read-all, POST /api/notifications/:id/read|unread) always keeps everything. Nobody is notified about what they cannot open; a notification about something the reader can no longer open (access removed, task moved to a restricted list) is listed as "You no longer have access to this", without its task, text or link. POST /api/tasks/:id/notifications/read reads my notifications about a task (the web app calls it when a task opens).

How notifications reach a person outside the app is set per person, for all their workspaces. These routes need a signed-in person (403 for API tokens and MCP):

GET    /api/me/notification-settings           { groups: [{ key, label, description, popup, email, slack, push }] (the ai_limit
                                               group only for someone who is an owner or admin in a workspace), emailEnabled,
                                               emailVerified, mailEnabled, digestHour, snoozedUntil, slack: { connected, hint, error },
                                               chatApp: slack | teams | google_chat | null,
                                               push: { enabled, publicKey, details, devices: [{ id, label, createdAt, lastSentAt }] } }
PATCH  /api/me/notification-settings           { groups?: { [key]: { popup?, email?: instant|digest|off, slack?, push? } },
                                               emailEnabled?, digestHour?: 6–11 | null, pushDetails? } → the settings
PUT    /api/me/notification-settings/snooze    { until: ISO time | null } (≤ 30 days; null or past = resume)
PUT    /api/me/notification-settings/chat      { url } — the chat destination, told by the link: Slack
                                               (https://hooks.slack.com/services/…), Microsoft Teams Workflows
                                               (https://<env>.environment.api.powerplatform.com/…/paths/invoke?…&sig=…, or
                                               prod-NN.<region>.logic.azure.com) or Google Chat
                                               (https://chat.googleapis.com/v1/spaces/<space>/messages?key=…&token=…); a test
                                               message is sent first; replaces the previous one
                                               (400 SLACK_URL_INVALID / SLACK_TEST_FAILED { reason })
POST   /api/me/notification-settings/chat/test   (5 test messages per 10 minutes, then 429)
DELETE /api/me/notification-settings/chat
                                               (`/slack` is the older name of these three routes and still works)
PUT    /api/me/notification-settings/push/devices      a browser's PushSubscription.toJSON() { endpoint, keys: { p256dh, auth } }
                                               → { deviceId, settings }; the same endpoint again refreshes it (and moves it to
                                               the caller if another account had it) (400 PUSH_OFF / PUSH_ENDPOINT_INVALID /
                                               TOO_MANY_DEVICES: at most 10)
DELETE /api/me/notification-settings/push/devices/:id  → the settings (404 for someone else's device)
POST   /api/me/notification-settings/push/forget       { endpoint } → the settings (sign-out on that device; unknown = fine)
POST   /api/me/notification-settings/push/test         → { sent } (5 per 10 minutes, then 429; 400 PUSH_NO_DEVICE)
POST   /api/notifications/unsubscribe          { token } or ?t= (RFC 8058 one-click) — no sign-in; turns notification emails off
POST   /api/notifications/resubscribe          { token } — no sign-in; turns them back on (the /unsubscribe page asks before acting)
                                               (400 UNSUBSCRIBE_LINK_INVALID)

Group keys: assigned, mentioned, comments, progress, reminders, ai_done, ai_failed, playbooks (also run_schedule: a scheduled run needs its owner's attention), requests (form_submitted), automations, ai_limit (owners and admins: this month's AI limit is 80% or 100% used). A comment thread assigned to you (comment_assigned) belongs to assigned (and the Inbox's "Assigned to me"); a thread you assigned being resolved (comment_resolved) to comments; an Approval task you created being answered (approval_decided) to progress. email: instant sends 5 minutes later (a burst is one email, at most one every 10 minutes per person); digest lists it in the daily summary at digestHour local time. Chat messages carry the title, the task and a link, never comment text (Slack: mrkdwn; Teams: an Adaptive Card whose text runs are plain text; Google Chat: text without < and >, so no mentions). The slack names in the settings mean the person's chat app. The webhook link is never returned (hint = its last 4 characters). Nothing is emailed to an unconfirmed address, nothing read in the app is sent, and every message is checked against the person's access when it is sent.

Push (Web Push to the installed app or a browser) goes to every device the person turned it on for, 30 seconds after the notification (read in the app by then = not pushed; more than 3 at once = one "N new notifications" push that opens the Inbox). A push carries { id, title, body, url, tag } — the notification's title and the task's key and title, or only "New notification" with pushDetails: false — encrypted for the device (RFC 8291) and signed with the server's VAPID key. Defaults: on for assigned, mentioned, reminders, ai_failed, ai_approval and playbooks. push.enabled is false when the server has no VAPID keys. A password change or reset removes every device.

The offline copy the installed app keeps (signed-in sessions only: 403 for API tokens and AI teammates):

GET /api/me/offline-snapshot?workspaceId=&recent=<task ids, comma-separated, ≤ 20>
    → { version: 1, savedAt, user, workspace, myTasks: [≤ 200 open tasks assigned to me],
        home: { overdue, today, upcoming, inProgress }, recent: [the given tasks I can open, with description,
        checklist, assignees, the last 20 comments as plain text and file names] }

Tasks are { id, key, title, state: { name, category }, dueDate, priority, list, project }; ids of tasks the caller cannot open (or that are in the Trash or another workspace) are left out.

Ranked full-text search (Postgres, language-neutral: words are matched whole, partial words only in task and doc titles and file names; a file name matches as written, without its extension or as words). Only what the caller can open is ever searched, counted or ranked (restricted lists, private projects, other people's My space, the Trash, archived pages and vaults never are); titles weigh more than bodies, and each kind keeps its best 200 matches.

GET /api/workspaces/:wid/search?q=                Ctrl+K: { query, terms, tasks, pages, comments, files, projects, items, people, agents }
GET /api/workspaces/:wid/search/results?q=&type=task,comment,page,record,file&project=&person=&after=YYYY-MM-DD&before=YYYY-MM-DD&page=1..10
    → { query, terms, hits: [SearchHit], counts: { task, comment, page, record, file }, page, hasMore }   (20 per page)

q takes "a phrase", OR and -word. counts are matches per kind (at most 200 each) with the project, person and date filters applied but not the type, so the type chips can show where the other matches are. person is a task's assignee or creator, a comment's author, a file's uploader, a doc's or record's creator; after / before are days in the caller's time zone (last update; files: when added). A SearchHit (@taskcrew/shared) has kind, id, title, a plain-text snippet around the first match, its project and item, and per kind the task (key, state), the comment's author, the file's fileName, mimeType, sizeBytes and uploader. A search that takes over 3 seconds answers 503 TOO_SLOW.

The words inside task attachments (PDF, DOCX, XLSX, PPTX, TXT/CSV/MD; no OCR) are read in the background after upload — files are found by name at once and by content within seconds. Images, old Office formats (.doc, .xls, .ppt) and emails are not read.

Your week#

GET /api/workspaces/:wid/me/week?which=this|last is the caller's private weekly review (there is no way to ask for someone else's). A week is Monday–Sunday in the caller's time zone (users.timezone).

GET /api/workspaces/:wid/me/week   { week: { which, start, end, today, timezone },
                                     header: { doneCount, previousCount, points, streak },
                                     done: { tasks: [{ day, task }], total, hidden }, ai: { runs: [{ id, taskId, status, failureCode,
                                     failureReason, finishedAt, agentName, agent, task }], total, toReviewCount },
                                     open: { count, tasks }, next: { count, tasks } | null, mentions: { items, total }, canEdit: [taskId] }

"Done" counts completions from the points ledger (net of same-day undos), so doneCount and points equal the Stats page's completedWeek and pointsWeek; finished tasks the caller can no longer open stay counted but are only hidden. ai lists runs the caller started that ended in the week (done or failed; the newest per task) and toReviewCount equals Home's counts.review. open / next are the caller's open tasks due in the week / next week (next is null for last week); canEdit are the listed ones the caller may change. mentions are this week's mention notifications the caller can still open. Lists are capped (done 50, AI 10, open and next 20, mentions 10). "Move to next week" in the app is a normal PATCH /api/tasks/:id (or the list's bulk dates shift).

Review & tidy and Plan my day#

Members only (guests get 403); everything is the caller's own view.

GET  /api/projects/:pid/tidy           { project, today, workWeek, holidays, findings: [{ rule, title, description, actions,
                                         defaultAction, entries: [{ id, kind, title, key?, itemId?, detail, dueDate?,
                                         assigneeIds?, selected, notFor? }] }], count, lastRunAt, lastSummary, snoozed }
GET  /api/workspaces/:wid/tidy         { projects: [{ id, name, icon, color, count, groups, lastRunAt }], total, nudge }
POST /api/projects/:pid/tidy/apply     { rule, action, ids, dates?: { [taskId]: 'YYYY-MM-DD' } }
                                       → { action, done: [id], skipped: [{ id, title?, message }], undo: [op] }
POST /api/projects/:pid/tidy/undo      { ops: [op] } → { undone, failed }
POST /api/projects/:pid/tidy/snooze    { rule } → { rule, until }        (hides the group for 7 days, for the caller)
POST /api/projects/:pid/tidy/finish    { counts: { [action]: n } } → { lastRunAt, previous: { lastRunAt, lastSummary } }
                                       (remembers the review: Home's nudge, "last reviewed")
POST /api/projects/:pid/tidy/unfinish  { lastRunAt, lastSummary }        (Undo all: puts `previous` back)
GET  /api/workspaces/:wid/day-plan     { today, availableMinutes, dayOff: weekend|holiday|timeOff|null, holidayName, typicalDayMinutes, typicalTaskMinutes, minutes: { [taskId]: n },
                                         planned: [task], candidates: { overdue, today, week, later }, savedAt }
PUT  /api/workspaces/:wid/day-plan     { taskIds, availableMinutes?, moveOverdue? } → the same, plus `moved`

Today's brief (DECISIONS.md §79): its daily push, for one workspace. Session only, like the other notification settings.

GET  /api/me/notification-settings/day-brief/:wid       { minute, days, here, otherWorkspace, nextAt, timezone, push: { enabled, devices } }
PUT  /api/me/notification-settings/day-brief/:wid       { minute: 0–1439 | null (off), days: [0–6] } → the same
POST /api/me/notification-settings/day-brief/:wid/send  → { devices }   (today's brief now, 5 per 10 minutes)
POST /api/internal/day-brief/run                         { at? } → { sent }   (test stacks, internal token)

Each fix goes through the usual service with the caller's permissions (task changes through the list's bulk path, so history and summary notifications are as for a selection); entries outside the project, or that the caller may not change, come back in skipped. undo ops are plain changes (dates, state, assignees, unarchive, dependencies, unread, recurring, form, restoreItem, restorePage) checked again when sent back.

Example#

TOKEN=tc_…
curl -H "Authorization: Bearer $TOKEN" http://localhost:4000/api/workspaces/$WID/tree
curl -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"title":"Follow up with ACME","priority":"HIGH","dueDate":"2026-12-01"}' \
  http://localhost:4000/api/items/$LIST_ID/tasks

Ask your workspace#

GET  /api/workspaces/:wid/ask                  members: { enabled, agent: { id, name, icon, color } | null, canConfigure,
                                                 listId, questions: [{ taskId, key, question, askedAt, run: { id, status,
                                                 output (Markdown, ≤ 20,000 characters), failureCode, failureReason, finishedAt } | null }] }
                                                 — your own newest 20 questions only (nobody else's, not even for admins)
POST /api/workspaces/:wid/ask                  { question (≤ 2000) } → 201 { taskId, runId }: a task in your My space
                                                 "Questions" list (created on first use), assigned to the chosen AI
                                                 teammate and started by you (read-only, your access). 409 ASK_OFF /
                                                 AGENT_UNAVAILABLE, 429 RATE_LIMITED (20 per person per hour, 300 per
                                                 workspace per day, per API instance)
POST /api/workspaces/:wid/ask/:tid/retry       "Try again" on your question whose last run failed or was stopped → 201
                                                 { taskId, runId } (counts toward the limits; 400 RUN_NOT_RETRYABLE)
PUT  /api/workspaces/:wid/ask/settings         admins: { agentId: uuid | null } → the GET body; 404 for another
                                                 workspace's teammate, 409 AGENT_ARCHIVED

A good answer closes its question (moved to Done by TaskCrew: no points, not "to review"); a failed one stays open with its reason. Runs on questions send no "finished" / "couldn't finish" notifications: the answer shows on the Ask page.

Assistant panel#

GET    /api/workspaces/:wid/assistant                      everyone: { available, reason: 'off' | 'not_configured' | 'guest' | null,
                                                             enabled, configured, canConfigure }
PUT    /api/workspaces/:wid/assistant/settings             admins: { enabled } → the GET body (on unless turned off)
GET    /api/workspaces/:wid/assistant/conversations        your newest 30: [{ id, title, updatedAt }]
GET    /api/workspaces/:wid/assistant/conversations/:cid   yours only (404 otherwise): { id, title, updatedAt, messages }
DELETE /api/workspaces/:wid/assistant/conversations/:cid   204
POST   /api/workspaces/:wid/assistant/chat                 { conversationId?, message (≤ 4000), context: { projectId?, itemId?,
                                                             taskId?, pageId?, view?, selection? (≤ 2000) } } → 200 JSON lines:
                                                             start { conversationId, title, message } · text { text } ·
                                                             status { text } · error { code, message } · done { message }.
                                                             Before the stream: 403 GUEST_NOT_ALLOWED, 409 ASSISTANT_OFF /
                                                             ASSISTANT_BUSY / AI_PAUSED, 503 AI_NOT_CONFIGURED, 429 RATE_LIMITED
                                                             (40 per person per 10 minutes, 1,500 per workspace per day)
POST   /api/workspaces/:wid/assistant/apply                { conversationId?, proposalId?, changes: [AssistantChange] (1–40) }
                                                             → 201 { results: [{ key, ok, error?, made? }] }; 403 PLAN_FORBIDDEN /
                                                             400 PLAN_INVALID with details.problems — then nothing was written
POST   /api/workspaces/:wid/assistant/conversations/:cid/proposals/:pid/discard   → { ok }

The context is built on the server with the caller's access (titles, structure, a page's start, a task's description — what they can't open is left out). The model reads further with read-only tools (search, list tasks, a task, a project's structure, a wiki's pages, a page, recent activity) and proposes changes with propose_changes, by names and keys; the API resolves each one with the caller's access (a change they can't make goes back to the model as an error) into an AssistantChange (create_folder, create_list, create_page, create_task with subtasks, update_task, rename_page, move_page; new:<key> points at a container made earlier in the same plan). Nothing is written until the person applies the changes they keep: every one is checked first, then written in order (containers first) through the app's own services as the person — tasks' history says "created the task with the Assistant". Each answer is billed to the month's AI limit and kept as an ai_usage_events row ("assistant") by person and project.

AI project builder#

POST /api/workspaces/:wid/project-builder/plan      members, not API tokens: { description (≤ 1000) } → 201 { plan, summary
                                                     { lists, tasks, subtasks, recurring, pages, phases, dashboard, teammates },
                                                     teammates: [{ key, name, icon, color, description, why, needs (connector
                                                     names not connected), inWorkspace }] }. One AI call; nothing is stored.
                                                     503 AI_NOT_CONFIGURED, 502 AI_UNAVAILABLE / PLAN_FAILED (no valid plan after
                                                     3 tries), 429 RATE_LIMITED with Retry-After (5 per person per 10 minutes,
                                                     30 per workspace per day, per API instance)
POST /api/workspaces/:wid/project-builder/projects  { plan, name, color?, visibility? } → 201 { project
                                                     (as GET /projects/:pid), agents: [{ id, name, icon, color }] }. The plan as
                                                     the preview left it (parts removed); checked and cleaned again, then created
                                                     like a template in one transaction, with the suggested teammates (those
                                                     already in the workspace are skipped)

A plan is { name, icon?, color?, description?, lists: [{ name, icon?, view?, states?: [{ name, category, isDefault? }], labels?: [{ name, color? }], fields?: [{ name, type, options?, currency? }], tasks?: [{ title, description?, priority?, state?, dueInDays?, subtasks? }], recurring?: [{ title, description?, priority?, rule, anchor, offsetDays? }] }], wiki?: { name, icon?, pages: [{ title, icon?, markdown }] } | null, playbook?: { key, name } | null, dashboard?: { key, name } | null, teammates: [{ key, why }] } — at most 5 lists, 8 states, 8 labels, 6 fields (no people fields), 25 tasks a list, 8 subtasks, 150 tasks in all, 5 recurring tasks (no custom cron), 6 wiki pages of 6,000 characters and 3 teammates. Playbooks, dashboards and teammates are built-in keys only (PLAN_PLAYBOOK_KEYS, PLAN_DASHBOARD_KEYS, PLAN_TEAMMATE_KEYS in @taskcrew/shared; not the Workspace assistant). 400 for anything else.

Guests and sharing#

A guest (workspace role GUEST) is someone from outside the team. They open only the projects and items shared with them, with View (VIEWER: reads only), Comment (COMMENTER: reads, comments, replies, reacts) or Edit (EDITOR), never Manager. Inside a shared project they see task lists, wikis, tables, canvases and notes; never vaults, forms, playbooks, goals or dashboards. Every workspace-wide route answers a guest 403 GUEST_NOT_ALLOWED: Home, search, All projects, Workload, Calendar, What changed, chat, AI teammates and their runs, connectors, automations, recurring rules, a list's setup (PATCH /api/items/:iid with any field: name, description, icon, colour, folder or settings such as the default view, sprints and hours per task; its states, labels and fields; its team saved views), templates, time tracking, new items and imports, My space, API tokens and settings. A guest keeps saved views of their own. GET /api/workspaces/:wid/members and the members of GET /api/projects/:pid list for a guest only the people who can open something shared with them. Their own tree, projects, items, tasks, comments (with Comment), notifications and profile work as usual. A member's VIEWER still reads and comments. API tokens are refused on these routes, and a guest's token stops working. "Require SSO" does not apply to guests (they sign in with a password).

POST   /api/projects/:pid/guests                    managers (owners and admins anywhere): { email, role: VIEWER | COMMENTER |
                                                      EDITOR } → 201 { status: 'added', email, userId } (already a guest here, or a
                                                      confirmed account: shared at once and emailed) | { status: 'invited', email,
                                                      mail, invite } (an emailed invite whose shares apply when they join; inviting
                                                      again adds to it); 400 MY_SPACE / GUEST_ROLE, 409 ALREADY_MEMBER (a member:
                                                      add them with PUT …/members) / ALREADY_INVITED
POST   /api/items/:iid/guests                       the same for a task list, wiki, table, canvas or notes (400 NOT_FOR_GUESTS
                                                      for other types)
GET    /api/projects/:pid/guest-invites             managers: the pending guest invites that share it → [{ id, email, role,
GET    /api/items/:iid/guest-invites                  createdAt, expiresAt, invitedBy }] (role: what the invite gives here)
DELETE /api/projects/:pid/guest-invites/:id         managers: cancels what the invite shares here → 204 (with nothing else left
DELETE /api/items/:iid/guest-invites/:id              in it, the invite is deleted and its link stops working); 404 when it does
                                                      not share it
GET    /api/workspaces/:wid/sharing?scope=all|mine  all (owners and admins): every guest { id, name, email, avatarColor, joinedAt,
                                                      lastSeenAt, invitedBy, access: [{ kind: project | item, id, name, projectId,
                                                      projectName, itemType?, role }] }, every pending guest invite { id, email,
                                                      createdAt, expiresAt, invitedBy, access }, every live public link { id, kind,
                                                      title, prefix, itemId, itemName, projectId, projectName, createdAt,
                                                      createdBy, expiresAt, expired, viewCount, lastViewedAt } and
                                                      publicLinksEnabled. mine (default, any member): the guests you invited, the
                                                      guest invites you sent and the links you made (a guest's access only as far
                                                      as you can open it). Guests: 403
DELETE /api/workspaces/:wid/guests/:uid             owners and admins, or whoever invited the guest → 204 (they lose everything
                                                      at once: off its tasks, rooms closed, audit log); 404 for a non-guest
DELETE /api/workspaces/:wid/invites/:iid            owners and admins; also whoever sent a guest invite
PUT    /api/projects/:pid/members/:uid              shares more with an existing guest ({ role: VIEWER | COMMENTER | EDITOR };
PUT    /api/items/:iid/members/:uid                 400 GUEST_ROLE for MANAGER, NOT_FOR_GUESTS for other item types)
PATCH  /api/workspaces/:wid/members/:uid            owners and admins change a guest to a member (or back): { role }

GET /api/invites/:token adds shared: string[] (what a guest invite shares). Project and item members, and GET /api/items/:iid/people, carry guest: true for guests. The workspace invite form (POST /api/workspaces/:wid/invites) never makes a guest (400).

Managers of a task list, dashboard or wiki share it read-only by link (/s/<token> on the web app; tcs_ + 256 random bits, stored as a SHA-256 hash for lookup and encrypted so managers can copy it again). Never for My space; at most 20 live links per item and 500 per workspace. API tokens are refused (403) on the managing routes. Placeholder people follow showPeople like people: a list's assignee column (names on only) adds "Alex (placeholder)", and a dashboard reads them as "Person N" unless names are on.

GET    /api/items/:iid/public-links                 managers: { enabled, personal, max, links: [{ id, kind, title, pageId, url,
                                                      prefix, showPeople, subpages, columns, createdAt, createdBy, expiresAt,
                                                      expired, viewCount, lastViewedAt }] } (wikis: the links of all pages)
POST   /api/items/:iid/public-links                 { pageId? (wikis), title?, expiresInDays: 7 | 30 | 90 | null (default 30),
                                                      showPeople (false), config? (lists: the view), columns? (lists:
                                                      due/start/priority/labels/assignee/field:<id>, never person fields),
                                                      view? (dashboards: dates and filters), subpages? (pages) } → 201 link;
                                                      400 MY_SPACE / NOT_SHAREABLE, 403 PUBLIC_LINKS_OFF, 409 LIMIT_REACHED
DELETE /api/public-links/:id                        managers of the item, owners and admins, and its creator → 204 (it stops
                                                      working at once)
PUT    /api/workspaces/:wid/settings/public-links   owners and admins: { enabled } → { enabled } (off: every link answers 403
                                                      PUBLIC_LINKS_OFF; links survive and work again when on)

Without signing in (Cache-Control: no-store, X-Robots-Tag: noindex, nofollow, Referrer-Policy: no-referrer):

GET    /api/public/s/:token                         { kind, title, sharedBy, expiresAt, generatedAt, list? | dashboard? | page? }
GET    /api/public/s/:token/pages/:n                a page inside a shared page (n ≥ 1, in the order `page.pages` lists them)
GET    /api/public/s/:token/media/:id               an image the shared pages show (images only)

404 LINK_GONE (unknown, revoked, creator left or lost the item, item archived), 410 LINK_EXPIRED, 403 PUBLIC_LINKS_OFF, 429 (300 unknown links an hour per network, 600 views an hour per link). Access review → Tokens & links lists every link (kind: "link") with publicLinksEnabled; creating, revoking and the switch are in the workspace audit log (public_link.created, public_link.revoked, public_links.switched).

AI teammates as colleagues#

  • @mention → run. A comment by a person (with edit rights) that mentions an AI teammate assigns it to the task if needed and starts a run (trigger: MENTION) with the comment as the run's instruction. If a run is already active on the task, the teammate replies that it is busy instead of queueing a second run (the next run sees the comment). Comments written by AI teammates never trigger runs. The teammate answers in the mention's thread: its replies, its add_comment on that task and its result (or failure) comment are replies there (threadId on the run).
  • Workspace tools. During a run an AI teammate can use search_workspace, get_task_status, add_comment, read_wiki_page, list_teammates, recent_activity and — unless workspaceTools: false in its definition — create_task, create_subtask, update_task / move_task (its own task, parent and subtasks), write_wiki_page. It acts as itself, with the access rights of the person who asked for the work, inside the run's workspace. create_subtask adds under its task, or beside it when its task is a subtask.
  • recent_activity { project?: name | key, days?: 1–31 = 7 } → { scope, period, projects: [{ name, key, progress, open, overdue, dueNext7Days }], doneInPeriod, overdue, dueNext7Days (≤ 20 each, done ≤ 30; task key, title, ref token, due, people), changes (sentences, newest first), comments (≤ 50, 300 characters), truncated, note? }: the "What changed" entries, All projects numbers and the tasks of the same lists — never My space, restricted lists or private projects they cannot open.
  • Questions are read-only. A run on a task in a list with settings.ask ("Questions", Ask your workspace) is snapshotted at ≤ 8 steps / 120 s without delegation or write tools, and may only use search_workspace, get_task_status, read_wiki_page, list_teammates and recent_activity (checked by the API and the runner; required connectors are still checked, but no connector tools are offered).
  • Delegation. With team: { canDelegate: true, delegateTo?: [agentIds] }, delegate_to_teammate creates a subtask (placed like create_subtask) assigned to another AI teammate with the brief as description and starts it (trigger: DELEGATION, parentRunId = the delegating run). Limits: chain depth ≤ 3, ≤ 5 hand-offs per run. When the delegated run finishes, the teammate reports back with a comment on the delegating task.

Single sign-on and provisioning (SCIM)#

SAML 2.0 single sign-on per workspace, SP-initiated only, and SCIM 2.0 Users. See the security notes.

GET    /api/workspaces/:wid/sso                 owners, in the app: { slug, enabled, enforced, jit, idpEntityId, idpSsoUrl,
                                                idpCertPem, idpCertPem2, certExpiresAt, sessionHours, testedAt,
                                                sp: { entityId, acsUrl, metadataUrl, loginUrl }, scim: { baseUrl, tokenPrefix,
                                                tokenCreatedAt, lastUsedAt }, domains: [{ id, domain, verifiedAt,
                                                record: { type: "TXT", name, value } }] }
PUT    /api/workspaces/:wid/sso                 { enabled, enforced, jit, idpEntityId, idpSsoUrl (https), idpCertPem (PEM),
                                                idpCertPem2?, sessionHours (1–168) } → the same; 400 INVALID_CERTIFICATE,
                                                400 SSO_NOT_TESTED (Require SSO needs a passing test with these settings)
POST   /api/workspaces/:wid/sso/scim-token      → 201 { token: "tcscim_…", settings } (shown once; replaces the previous one)
DELETE /api/workspaces/:wid/sso/scim-token      → 204
POST   /api/workspaces/:wid/sso/domains         { domain } → 201 settings (the domain to verify; 400 DOMAIN_EXISTS,
                                                TOO_MANY_DOMAINS at 20). SSO and SCIM only trust addresses at verified ones
POST   /api/workspaces/:wid/sso/domains/:id/verify → 200 settings, or 400 DOMAIN_NOT_VERIFIED (the TXT record isn't there yet)
DELETE /api/workspaces/:wid/sso/domains/:id     → 200 settings

GET    /api/sso/:slug                           public: { name, enabled: true } — 404 SSO_NOT_FOUND unless SSO is on
GET    /api/sso/:slug/metadata                  public: SP metadata (XML)
GET    /api/sso/:slug/login?next=&test=1        public: 302 to the identity provider (the request is stored, 10 minutes, once);
                                                `test=1`: an owner checks the settings (signs nobody in); it comes back to
                                                Settings with `?sso-test=passed`, or `failed&reason=signature|issuer|
                                                audience|recipient|expired|no_email|other`
POST   /api/sso/:slug/acs                       public, form-encoded SAMLResponse: 303 to `next` in the web app with a session
                                                cookie, or to `/sso?error=invalid|expired|no_access|deactivated|disabled|unverified_domain`.
                                                Only the request named by this browser's `tc_sso` cookie (set by /login)
                                                is accepted

An identity provider signs in only accounts that are members of the workspace or hold a pending invite to it, and creates accounts only for emails with no account (JIT, SCIM or an invite); anything else is no_access. A session from single sign-on lasts sessionHours and is confined to its workspace like an API token (other workspaces 404); it is refused (403) on API tokens, new workspaces, invites to others and /api/auth/* except /me, and on /api/me/notification-settings unless that workspace created the account. When a person signs in through SSO from their own password session, that session gains the workspace's proof instead. With Require SSO, a member's session without that proof gets 403 SSO_REQUIRED with details: { workspaceId, workspaceName, slug } in that workspace (REST, MCP and live rooms); owners pass (logged), API tokens and AI teammates are unaffected.

SCIM (RFC 7644), Authorization: Bearer tcscim_…, application/scim+json, 600 requests a minute per workspace:

GET    /api/scim/v2/ServiceProviderConfig | /ResourceTypes | /Schemas
GET    /api/scim/v2/Users?filter=userName eq "a@b.com"&startIndex=1&count=100   (filters: userName eq, externalId eq; count ≤ 100)
POST   /api/scim/v2/Users                       { userName (email), name, displayName, emails, externalId, active } → 201
GET    /api/scim/v2/Users/:id
PUT    /api/scim/v2/Users/:id                   full replace
PATCH  /api/scim/v2/Users/:id                   PatchOp (Okta and Entra ID forms: active, userName, displayName, name.*, externalId)
DELETE /api/scim/v2/Users/:id                   → 204
/api/scim/v2/Groups                             → 404 (roles stay in TaskCrew)

A confirmed account joins as a member at once; someone without an account at their first SSO sign-in; an unconfirmed existing account is never linked (it needs an invite). active: false or DELETE ends the membership at once and the person's sessions and push devices everywhere; owners answer 409 (mutability). Errors use SCIM's format: { schemas: [ "urn:ietf:params:scim:api:messages:2.0:Error" ], status, scimType?, detail }.

Configuration#

VariableWherePurpose
INTERNAL_API_TOKENAPI + runnerShared secret (≥ 16 chars) for runner → API calls. Without it, run-finished events and AI-teammate workspace tools are off.
API_INTERNAL_URLrunnerHow the runner reaches the API (default http://localhost:4000; http://api:4000 in docker-compose).
PUBLIC_API_URLAPIPublic base URL; the MCP server is at <PUBLIC_API_URL>/mcp, single sign-on at <PUBLIC_API_URL>/api/sso/<workspace ID>/… and SCIM at <PUBLIC_API_URL>/api/scim/v2 (changing it means updating your identity provider).
SMTP_URL, MAIL_FROMAPISMTP server and sender for invites, password resets and confirmations. Without them emails are only written to the API log (links included).
MAIL_TRANSPORTAPIsmtp (default with SMTP_URL), log (default without), memory (test outbox, automated tests only).
STORAGE_DRIVER, S3_ENDPOINT, S3_REGION, S3_BUCKET, S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY, S3_FORCE_PATH_STYLEAPI + runnerlocal (default: UPLOAD_DIR) or s3: an S3-compatible bucket (path-style URLs for MinIO, R2). The bucket stays private; files stream through the API.
THUMBNAILSAPIon (default) or off: image thumbnails in the media library.
AUTH_RATE_LIMITSAPISign-in, sign-up, reset, invite email and form response limits (default true; false only for automated tests).
INBOUND_EMAIL_DOMAIN, INBOUND_EMAIL_SECRET, INBOUND_EMAIL_MAX_MBAPIEmailed responses for forms: the domain of the forms' addresses, the webhook's Basic-auth password (≥ 16 characters; both or neither) and the largest message (default 30 MB).
SLACK_TRANSPORTAPIhttp (default: posts to people's Slack, Teams or Google Chat webhooks) or memory (test outbox; with either memory transport, deliveries run only on POST /api/internal/notifications/deliver).
VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY, VAPID_SUBJECTAPIPush notifications (npx web-push generate-vapid-keys; both or neither). VAPID_SUBJECT: mailto: or https: contact (default WEB_ORIGIN when https).
PUSH_TRANSPORTAPIhttp (default: sends to the browsers' push services) or memory (test outbox at /api/internal/push; deliveries then run only on POST /api/internal/notifications/deliver).
PUSH_ENDPOINT_HOSTS, PUSH_ENDPOINT_POLICYAPIMore push service hosts besides FCM, Mozilla, Apple and WNS (comma-separated, *.example.com allowed); strict (default: https on 443) or permissive (also http and other ports, for a local test push service).

These pages are built from the same docs that ship with the code, so they change when the product does. See what changed in the changelog