# TaskOH assistant access: reference for AI agents | Field | Value | |---|---| | Status | As built (personal access tokens only; OAuth not available) | | Version | 1.5 | | Date | 2026-09-07 | | Applies to | `https://api.taskoh.app/mcp` (MCP server) · `https://api.taskoh.app/openapi.json` (REST) · `https://app.taskoh.app` (web app) | | Related docs | [People guide](https://taskoh.app/docs/assistant-access.html) (creating tokens, client setup) · [this reference as a web page](https://taskoh.app/docs/assistant-access-for-agents.html) · [openapi.json](https://api.taskoh.app/openapi.json) (REST schema) | This document is for an LLM-based agent that holds a TaskOH token and must act for a user. ## 1. Purpose and mental model - TaskOH is a project management app. Hierarchy: workspace > projects > tasks (> subtasks, comments). - A token belongs to one user. Every call runs as that user, with that user's role in each project. There are no token scopes. - Project roles: `admin`, `project_manager`, `guest`. `admin` and `project_manager` are "the project team". `guest` is a client: sees only `client_visible` tasks, cannot edit or delete tasks, cannot set labels. - Writes that produce activity events (`task_created`, `status_changed`, `scope_changed`, `billing_changed`, `billing_visibility_changed`, `comment_added`, `task_deleted`) are recorded in the TaskOH activity log with `metadata.via = "api"`; the web app shows them as "via API". Other field edits (title, description, priority, due date, assignee, labels, parent, `client_visible`) are not logged — see §5.6. - Fixed vocabularies: | Field | Values | |---|---| | status | `inbox`, `planned`, `in_progress`, `in_review`, `needs_client`, `done` | | type | `request`, `bug`, `task` | | priority | `1` low, `2` medium, `3` high (or `null`) | | scope | `unclassified`, `in_scope`, `out_of_scope` | - "Open" means status is not `done`. `search_tasks` has no open/closed switch: pass `"status":["inbox","planned","in_progress","in_review","needs_client"]` for open tasks (as in §7.5) or `["done"]` for closed ones. ### 1.1 Minimum successful session 1. `get_context` (no arguments) → learn `me`, your projects and roles. 2. If you will name a member or label: `get_context` with `project`. 3. Read: `get_my_tasks` (own open work) or `search_tasks` (needs `project`) or `get_task`. 4. Write: `create_task` / `update_task` / `add_comment`; `delete_task` only after explicit user consent (§7.8). 5. Report the returned `url`. Full rules: §8. Recipes: §7. ## 2. Authentication ### 2.1 Header ```http Authorization: Bearer toh_pat_<64 lowercase hex characters> ``` - Scheme is case-insensitive; the token is 72 characters, prefix `toh_pat_`. - The same header works on `POST /mcp` and on the REST operations listed in §9; the session-only endpoints in §2.3 return 403. ### 2.2 Failure responses Authentication is checked before the controller on `POST /mcp` and on every route in the protected `/api` group (the seven REST operations in §9 are all in it); `GET`/`DELETE /mcp` answer 405 without checking the token (see §3.1). The body is always the REST envelope, even on `/mcp`. | HTTP | Header | Body | Cause | |---|---|---|---| | 401 | `WWW-Authenticate: Bearer` | `{"success":false,"error":{"type":"unauthorized","message":"Missing Authorization header","code":401}}` | no header | | 401 | `WWW-Authenticate: Bearer` | same shape, message `Invalid Authorization header format` | header does not match `Bearer ` | | 401 | `WWW-Authenticate: Bearer` | same shape, message `Invalid or revoked token` | bearer starts with `toh_` but the token is unknown, revoked, or expired | | 401 | `WWW-Authenticate: Bearer` | same shape, message `Invalid token` | bearer does not start with `toh_` (the token was pasted without its `toh_pat_` prefix) | | 403 | none | `{"success":false,"error":{"type":"forbidden","message":"This route requires an interactive session","code":403}}` | valid token on a session-only route | | 403 | `server: cloudflare`, `content-type: text/plain` | `error code: 1010` (plain text, no JSON envelope) | answered by the CDN edge, not by TaskOH: a zone security rule rejected the request. Since 2026-09-07 the API host no longer applies browser-signature checks (Python's default `Python-urllib/3.x` works), so this should not occur in normal operation. Not an authentication problem. | | 429 | `Retry-After: 60` | `{"success":false,"error":{"type":"rate_limited","message":"Too many requests. Please try again later.","code":429}}` | more than 120 requests in 60 s with this token — one bucket per token, shared by every `/api` route and `POST /mcp`; a JSON-RPC batch of n messages costs n requests and is refused as a whole when it does not fit. Not an authentication problem. | - A 401 with `Invalid or revoked token` is final for that token. Do not retry. Tell the user to create a new token at https://app.taskoh.app/integrations (sign in first; the page is reachable by every role, including clients). - Revocation and expiry take effect on the next request (no caching). - A plain-text 403 with the body `error code: 1010` never comes from TaskOH: it is Cloudflare answering before the request reaches the API. Until 2026-09-07 the edge rejected Python's default `Python-urllib/3.x` User-Agent this way; the API host (`api.taskoh.app`) no longer applies that check, while the browser-facing hosts `taskoh.app` and `app.taskoh.app` still do. If you ever see it on the API host, retry after a short wait and report the `cf-ray` response header to the TaskOH team — the token is fine. Sending a descriptive `User-Agent` such as `my-assistant/1.0 (+https://example.com)` remains good practice. - A 429 is not a token problem: the token is neither revoked nor otherwise affected. Wait the number of seconds given in `Retry-After` (60), then retry, and reduce call volume: paginate `search_tasks` with `page`/`per_page` (max 100), send one `update_task` with several fields instead of several calls, and call `get_context` once per session. The quota is 120 requests per rolling 60-second window per token (not per IP address), counted across every `/api` route and `POST /mcp` combined; a JSON-RPC batch of n messages (max 20) costs n requests, and a batch that does not fit in the remaining quota is refused as a whole with the same HTTP 429 envelope and `Retry-After: 60` — none of its messages is executed. On `/mcp` the 429 is a plain HTTP response with the JSON envelope above, not a JSON-RPC error object. Browser sessions (the web app's session JWT) calling `POST /mcp` share the same 120-per-60 s bucket, keyed per user; sessions on the `/api` REST routes are not limited, and the sign-in endpoints keep their separate limit of 10 per 15 minutes per IP address. ### 2.3 Session-only endpoints (403 for tokens) A token receives the 403 above on endpoints that mint credentials, grant durable access to other people, or take irreversible or financial actions: token management (listing, creating, revoking), profile and password changes, adding or removing project members, workspace team management (removing a person, resending an invitation), deleting a project, and Stripe checkout or billing-portal sessions. Read-only lists (`GET /api/team`, `GET /api/subscription`) stay available. None of these are part of the documented agent operations (§5, §9). Project payloads returned to a token (`GET /api/projects`, `GET /api/projects/{id}`, `POST /api/projects`) omit the project's public share-link fields. ### 2.4 Token hygiene - Never log, echo, quote, or store the token. Never include it in a tool result, comment body, or task text. - Rejected tokens are logged server-side with their last 4 characters, the client IP address and the request path — never the token. - Never try to create tokens: the account owner is e-mailed on every token creation and at most 20 active tokens exist per user (token management is session-only anyway, §2.3). ## 3. Choosing a transport | | MCP (preferred) | REST | |---|---|---| | URL | `POST https://api.taskoh.app/mcp` | `https://api.taskoh.app/api/...`, schema at `https://api.taskoh.app/openapi.json` | | Format | JSON-RPC 2.0, JSON-only Streamable HTTP | JSON, envelope `{"success":true,"data":...}` | | Name resolution | project, assignee and label names accepted | numeric ids only | | Operations | 8 tools | 7 operations | | Use when | the client supports MCP (Claude Code, Claude Desktop via `mcp-remote`, Claude API, OpenAI Responses/Agents SDK) | the client is a ChatGPT Custom GPT Action or a plain HTTP script | ### 3.1 MCP transport facts | Fact | Value | |---|---| | Method | `POST` only. `GET` and `DELETE` return HTTP `405` with `Allow: POST` and an empty body, before and without authentication; other verbs are answered by the app's JSON error handler with HTTP 405 (body shape not part of the contract) | | Headers to send | `Authorization`, `Content-Type: application/json`. `MCP-Protocol-Version` is optional | | Supported protocol versions | `2025-03-26`, `2025-06-18`, `2025-11-25` (default `2025-11-25`) | | `MCP-Protocol-Version` header | if present it must match `YYYY-MM-DD`, otherwise HTTP `400` with JSON-RPC error `-32600` `Malformed MCP-Protocol-Version header`. Any date-shaped value is accepted | | Sessions | none. No `Mcp-Session-Id` header is ever emitted or required. `initialize` is optional; `tools/list` and `tools/call` work without it | | Server-sent events | not supported. Use `--transport http` (Claude Code) or `--transport http-only` (`mcp-remote`) | | Batch | a non-empty JSON array of up to 20 requests returns a JSON array of replies (HTTP `200`). Empty array `[]` returns `-32600` `Invalid Request`; more than 20 elements returns `-32600` `Batch too large (max 20)`. Each message costs one request of the rate-limit quota (§2.2): a batch that does not fit in the remaining quota is refused as a whole with HTTP `429`, and none of its messages is executed | | Request `id` | a string, a number or `null`; any other type answers `-32600` `Invalid Request` | | Notifications | a message without `id`, or whose method starts with `notifications/`, gets no reply. If the whole request was notifications the response is HTTP `202` with an empty body | | Unknown method | `-32601` `Method not found` (HTTP 200). `server/discover` is not implemented; fall back to `initialize` | | Methods implemented | `initialize`, `ping`, `tools/list`, `tools/call` | | Response `Content-Type` | always `application/json` | | HTTP status of JSON-RPC errors | `200` except the `400` above and the `405` | A complete request: ```http POST /mcp HTTP/1.1 Host: api.taskoh.app Authorization: Bearer toh_pat_<64 lowercase hex characters> Content-Type: application/json {"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"get_context","arguments":{}}} ``` The reply is HTTP 200 with `Content-Type: application/json` and a JSON-RPC response whose `result` has the tool result shape shown in §3.2. `initialize` result: ```json {"protocolVersion":"2025-11-25","capabilities":{"tools":{}},"serverInfo":{"name":"taskoh","version":"1.0"},"instructions":"Call get_context once before resolving project, member or label names. Tasks live in projects; statuses are inbox, planned, in_progress, in_review, needs_client, done. Use get_my_tasks for the user's own open work and search_tasks for everything else. Writes act as the signed-in user and appear in TaskOH's activity log. Full reference: https://api.taskoh.app/llms.txt"} ``` `protocolVersion` echoes `params.protocolVersion` if it is in the supported list, otherwise `2025-11-25`. ### 3.2 Tool result shape - Success: `{"content":[{"type":"text","text":""}],"structuredContent":}`. Read `structuredContent`. - Business or permission failure: `{"content":[{"type":"text","text":""}],"isError":true}`. The message is meant for you; correct the arguments and retry. - Argument or schema failure: JSON-RPC error `-32602` with the message in `error.message`. ## 4. Data model reference ### 4.1 Compact task (list rows) Returned by `get_my_tasks`, `search_tasks`, and as `subtasks` in `get_task`. Key order is fixed. ```json {"id":42,"project_id":3,"project_name":"Acme Portal","title":"Login fails on Safari","status":"in_progress","type":"bug","priority":3,"due_date":"2026-09-12","assignee_name":"Maya Okafor","label_names":["frontend"],"subtask_count":2,"parent_id":null,"client_visible":true,"url":"https://app.taskoh.app/projects/3/tasks/42"} ``` | Key | Type | |---|---| | `id` | integer | | `project_id` | integer | | `project_name` | string or null (null if the project is not in your project list) | | `title` | string | | `status` | status enum | | `type` | type enum | | `priority` | integer 1–3 or null | | `due_date` | `YYYY-MM-DD` string or null | | `assignee_name` | string or null | | `label_names` | string[] | | `subtask_count` | integer; omitted for guests (§4.5) | | `parent_id` | integer or null | | `client_visible` | boolean | | `url` | string, `https://app.taskoh.app/projects/{project_id}/tasks/{id}` | ### 4.2 Full task Returned by `get_task`, `create_task`, `update_task`. | Key | Type | Notes | |---|---|---| | `id` | integer | | | `project_id` | integer | | | `parent_id` | integer or null | null = top-level task | | `title` | string | | | `description` | string or null | | | `details` | string or null | | | `status` | status enum | | | `type` | type enum | | | `scope` | scope enum | | | `is_billable` | boolean | only true when `scope` is `out_of_scope` | | `required_minutes` | integer or null | positive multiple of 15; hidden from guests unless billing is shared | | `fee_amount` | decimal string or null | e.g. `"1500.00"`; hidden from guests unless billing is shared | | `fee_currency` | string or null | 3 uppercase letters; hidden from guests unless billing is shared | | `billing_client_visible` | boolean | whether the client may see minutes and fee | | `client_visible` | boolean | whether guests see the task at all | | `created_by_user_id` | integer or null | | | `assignee_user_id` | integer or null | | | `priority` | integer 1–3 or null | | | `due_date` | `YYYY-MM-DD` or null | | | `metadata` | object, or `[]` when empty | an empty metadata is serialised as `[]`, not `{}` | | `position` | integer | board position | | `created_at` | ISO 8601 string | | | `updated_at` | ISO 8601 string | | | `label_ids` | integer[] | | | `subtask_count` | integer | omitted for guests (§4.5) | | `assignee_name` | string or null | | | `assignee_email` | string or null | omitted for guests (§4.5) | | `assignee_last_activity_at` | ISO 8601 string or null | omitted for guests (§4.5) | | `label_names` | string[] | | | `project_name` | string or null | | | `url` | string | | Scope and billing rules (enforced by `update_task`; each violation is an isError whose text is listed in §11): - `is_billable: true` requires `scope: out_of_scope`. - `required_minutes` must be a positive multiple of 15 (at most 4294967295). - `fee_amount` is a positive decimal string, integer part 1–10 digits without leading zeros, at most 2 decimals; the server stores and returns it normalised to two decimals (`"1500"` becomes `"1500.00"`). - `fee_amount` requires `fee_currency` (`^[A-Z]{3}$`). - `fee_currency` without `fee_amount` is rejected. - minutes and fee must be null when not billable. ### 4.3 Project, member, label (from `get_context`) ```json {"me":{"id":7,"name":"Sam Rivera","email":"me@example.com"}, "statuses":["inbox","planned","in_progress","in_review","needs_client","done"], "types":["request","bug","task"], "scopes":["unclassified","in_scope","out_of_scope"], "projects":[{"id":3,"name":"Acme Portal","my_role":"admin","client_portal_slug":"acme"}], "members":[{"id":7,"name":"Sam Rivera","email":"me@example.com","role":"admin"}], "labels":[{"id":11,"name":"frontend","color":"#f00"}]} ``` - `projects[]`: `id` integer, `name` string, `my_role` one of `admin`, `project_manager`, `guest`, `client_portal_slug` string or null. - `members[]` and `labels[]` are present only when `project` was passed. `members[].name` may be null. When the caller is a guest in that project, `members[]` entries carry no `email` key at all (the caller's own `me.email` is still present). - `me.name` and `me.email` are string or null; fall back to the id when null. ### 4.4 Comment and activity shapes (from `get_task`) ```json {"comments":[{"id":5,"author_name":"Maya Okafor","body":"Looks good","created_at":"2026-09-06T14:10:00+00:00"}], "activity":[{"type":"task_created","type_label":"Task created","actor_name":"Sam Rivera","via":"api","created_at":"2026-09-06T14:10:00+00:00","metadata":{"title":"Login fails on Safari","via":"api"}}]} ``` - `author_name`: the author's name, or their email when no name is set. - `via` is `"api"` for token-made events, otherwise null. - Activity `type` values: `task_created`, `status_changed`, `approval_requested`, `approved`, `changes_requested`, `comment_added`, `attachment_added`, `visibility_changed`, `quote_sent`, `quote_approved`, `quote_rejected`, `scope_changed`, `billing_changed`, `billing_visibility_changed`, `task_deleted`. ### 4.5 What guests see | Aspect | Guest behaviour | |---|---| | Task lists and `get_task` | only tasks with `client_visible: true`; hidden tasks return `Task not found` | | Subtasks in `get_task` | only client-visible subtasks | | `required_minutes`, `fee_amount`, `fee_currency` | removed unless the task is both `client_visible` and `billing_client_visible` | | Activity in `get_task` | `scope_changed`, `billing_changed`, `billing_visibility_changed` events removed unless `billing_client_visible` | | `create_task` | allowed, following the client portal's request rules: `client_visible` forced to `true`; `type` defaults to `request`; `status`, `priority` and `due_date` are ignored (the task starts in `inbox` without priority or due date); labels rejected with `Only the project team can set labels`; `parent_task_id` must be a client-visible task (a hidden, foreign or missing parent answers `Task not found`); refused with `Client requests are disabled for this project` when the project does not accept client requests; the project team is notified of a new client request | | `update_task`, `delete_task` | `Permission denied: edit_task` | | `add_comment` | allowed on client-visible tasks | | `get_context` members | `members[]` entries carry no `email` key; the caller's own `me.email` is still present | | Assignee errors | candidates listed as ` (id )`, without e-mail addresses | | Assignee resolution | an e-mail address is not matched for a guest (use `me`, a member id or a name), so a guess cannot be confirmed | | Task shapes | `subtask_count`, `assignee_email` and `assignee_last_activity_at` are absent for guests (team members keep them) | | `parent_task_id` in `create_task` | must be a client-visible task; a hidden, foreign or missing parent answers `Task not found`, so hidden tasks cannot be probed through the parent field | | Task shapes | `subtask_count` (compact and full task), `assignee_email` and `assignee_last_activity_at` (full task) are omitted | ## 5. Tool reference `tools/list` always returns all 8 tools in this order, regardless of role. Annotation sets: | Set | Value | |---|---| | READ_ONLY | `{"readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false}` | | WRITE | `{"readOnlyHint":false,"destructiveHint":false,"idempotentHint":false,"openWorldHint":false}` | | WRITE_IDEMPOTENT | `{"readOnlyHint":false,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false}` | | DESTRUCTIVE | `{"readOnlyHint":false,"destructiveHint":true,"idempotentHint":true,"openWorldHint":false}` | The schemas use one non-standard keyword, `maxBytes`: the maximum size of a string in UTF-8 bytes (`maxLength` counts characters). Both are enforced with a `-32602` (§11). Common `isError` texts (all tools that take a task or project): | Text | Meaning | |---|---| | `Task not found` | task missing, in a project you are not a member of, or hidden from a guest — also for `parent_task_id` in `create_task` | | `No project named ''. Your projects: (id ), ...` | unknown project (or `Your projects: none`) | | `Project name '' is ambiguous. Candidates: (id ), ...` | two projects share the name; use the id | ### 5.1 get_context - Title: `Get workspace context`. Annotations: READ_ONLY. - When: first call of every session. Also with `project` before any name-based create, update, or search. - inputSchema: ```json {"type":"object","additionalProperties":false,"properties":{"project":{"type":"string","description":"Project id or name. When set, also returns that project's members and labels."}}} ``` - Returns: the object in §4.3. `members` and `labels` only when `project` is given. - Errors: -32602 for schema failures (unknown keys), isError for an unknown or ambiguous `project` — texts in §11. - Side effects: none. Permission: all roles. ### 5.2 get_my_tasks - Title: `Get my open tasks`. Annotations: READ_ONLY. - When: "what is on my plate", "my open tasks", "what is overdue for me". - inputSchema: ```json {"type":"object","additionalProperties":false,"properties":{"limit":{"type":"integer","minimum":1,"maximum":100,"default":50}}} ``` - Returns `{"tasks":[...],"total":2}`. Order: overdue first, then priority descending, then due date ascending. Excludes `done`. Guests see only client-visible tasks. `total` is the number of rows returned. The `limit` is applied before role filtering, so a result shorter than `limit` does not prove there are no more tasks; to be exhaustive call again with `limit: 100`. Not paginated. Overdue = `due_date` earlier than the server's current date (UTC). - Errors: -32602 for schema failures (`limit` type and 1–100 bounds) — texts in §11. - Side effects: none. Permission: all roles. ### 5.3 search_tasks - Title: `Search tasks in a project`. Annotations: READ_ONLY. - When: any task listing that is not "my open tasks": by project, status, assignee, label, text, unassigned, done, subtasks. - inputSchema: ```json {"type":"object","additionalProperties":false,"required":["project"],"properties":{ "project":{"type":"string","description":"Project id or name"}, "query":{"type":"string","description":"Text in title or description"}, "status":{"type":"array","items":{"type":"string","enum":["inbox","planned","in_progress","in_review","needs_client","done"]}}, "type":{"type":"array","items":{"type":"string","enum":["request","bug","task"]}}, "assignee":{"type":"string","description":"\"me\", \"unassigned\", user id, name or email"}, "labels":{"type":"array","items":{"type":"string"},"description":"Label names; all must match"}, "priority":{"type":"array","items":{"type":"integer","minimum":1,"maximum":3}}, "scope":{"type":"array","items":{"type":"string","enum":["unclassified","in_scope","out_of_scope"]}}, "is_billable":{"type":"boolean"}, "parent_task_id":{"type":"integer","description":"Only subtasks of this task; 0 = top-level tasks only"}, "sort":{"type":"string","enum":["position","priority","due_date","created_at","title","status"],"default":"position"}, "order":{"type":"string","enum":["asc","desc"],"default":"asc"}, "page":{"type":"integer","minimum":1,"default":1}, "per_page":{"type":"integer","minimum":1,"maximum":100,"default":25}}} ``` - Semantics: `query` matches title or description (substring, trimmed, empty ignored). `status`, `type`, `priority`, `scope` are OR within the array. `labels` is AND (all must be on the task). `assignee: "unassigned"` = no assignee. `parent_task_id` omitted = all tasks; `0` = top-level only; `>0` = subtasks of that task. `sort: "position"` ignores `order`. - Returns `{"tasks":[...],"total":31,"page":2,"per_page":10,"total_pages":4}`. - Errors: -32602 for schema failures (a missing `project` has its own message; array items report as `status[0]`), isError for an unknown or ambiguous project, assignee, or label — texts in §11. - Side effects: none. Permission: all roles; guests get only client-visible tasks. ### 5.4 get_task - Title: `Get a task`. Annotations: READ_ONLY. - When: the user asks about one task, before updating or deleting, or to read comments/activity. - inputSchema: ```json {"type":"object","additionalProperties":false,"required":["task_id"],"properties":{"task_id":{"type":"integer"},"include":{"type":"array","items":{"type":"string","enum":["comments","subtasks","activity"]},"default":["comments","subtasks"]}}} ``` - Returns the full task (§4.2) plus, per `include`: `comments` (oldest first, max 50), `subtasks` (compact rows, position order, uncapped), `activity` (newest first, max 50). `include: []` returns only the task. - Errors: -32602 for schema failures (`task_id`, `include[n]`), isError for task lookup — texts in §11. - Side effects: none. Permission: any member of the task's project; guest rules in §4.5. ### 5.5 create_task - Title: `Create a task`. Annotations: WRITE. - When: the user asks to add, create, log, or file a task, bug, or request, or to create a subtask. - inputSchema: ```json {"type":"object","additionalProperties":false,"required":["project","title"],"properties":{ "project":{"type":"string","description":"Project id or name"}, "title":{"type":"string","maxLength":255}, "description":{"type":"string"}, "type":{"type":"string","enum":["request","bug","task"],"default":"task"}, "status":{"type":"string","enum":["inbox","planned","in_progress","in_review","needs_client","done"],"default":"inbox"}, "priority":{"type":"integer","minimum":1,"maximum":3,"description":"1 low, 2 medium, 3 high"}, "due_date":{"type":"string","format":"date","description":"YYYY-MM-DD"}, "assignee":{"type":"string","description":"\"me\", user id, name or email; must be a project member"}, "labels":{"type":"array","items":{"type":"string"},"description":"Existing label names; unknown names are rejected, never created. Project team only."}, "parent_task_id":{"type":"integer","description":"Create as a subtask of this task (same project)"}, "client_visible":{"type":"boolean","default":false}}} ``` - Returns the full task (§4.2). Example (abbreviated): ```json {"id":42,"project_id":3,"parent_id":null,"title":"Login fails on Safari","description":null,"details":null,"status":"inbox","type":"bug","scope":"unclassified","is_billable":false,"required_minutes":null,"fee_amount":null,"fee_currency":null,"billing_client_visible":false,"client_visible":false,"created_by_user_id":7,"assignee_user_id":9,"priority":3,"due_date":"2026-09-12","metadata":[],"position":0,"created_at":"2026-09-06T14:10:00+00:00","updated_at":"2026-09-06T14:10:00+00:00","label_ids":[11],"subtask_count":0,"assignee_name":"Maya Okafor","assignee_email":"maya@example.com","assignee_last_activity_at":null,"label_names":["frontend"],"project_name":"Acme Portal","url":"https://app.taskoh.app/projects/3/tasks/42"} ``` - Semantics: `title` is trimmed server-side; an empty or whitespace-only `title` (and the literal `"0"`) is rejected with `Title is required`. `due_date` must be a valid calendar date in `YYYY-MM-DD`. - Errors: -32602 for schema failures (`description` must be a string; null is not accepted on create), isError for project/member/label lookup, guest label permission, title, due date, and parent-task failures — texts in §11. - Order of checks: project, labels, label permission, assignee, then the write. A bad name creates nothing. - Side effects: activity `task_created` (`metadata.title`, `via: "api"`). No assignment notification on create. - Permission: all roles. Guest: `client_visible` forced to `true`, `type` defaults to `request`, labels rejected. ### 5.6 update_task - Title: `Update a task`. Annotations: WRITE_IDEMPOTENT. - When: change status, assignee, due date, priority, title, description, labels, parent, visibility, scope, or billing of an existing task. - inputSchema: ```json {"type":"object","additionalProperties":false,"required":["task_id"],"properties":{ "task_id":{"type":"integer"}, "title":{"type":"string","maxLength":255}, "description":{"type":["string","null"]}, "details":{"type":["string","null"]}, "status":{"type":"string","enum":["inbox","planned","in_progress","in_review","needs_client","done"]}, "type":{"type":"string","enum":["request","bug","task"]}, "priority":{"type":["integer","null"],"minimum":1,"maximum":3}, "due_date":{"type":["string","null"],"format":"date"}, "assignee":{"type":["string","null"],"description":"\"me\", id, name or email; null unassigns"}, "parent_task_id":{"type":["integer","null"],"description":"null promotes to top level"}, "add_labels":{"type":"array","items":{"type":"string"}}, "remove_labels":{"type":"array","items":{"type":"string"}}, "client_visible":{"type":"boolean"}, "scope":{"type":"string","enum":["unclassified","in_scope","out_of_scope"]}, "is_billable":{"type":"boolean","description":"Only allowed when scope is out_of_scope"}, "required_minutes":{"type":["integer","null"],"description":"Multiple of 15"}, "fee_amount":{"type":["string","null"],"description":"Decimal string, e.g. \"1500.00\"; requires fee_currency"}, "fee_currency":{"type":["string","null"],"pattern":"^[A-Z]{3}$"}, "billing_client_visible":{"type":"boolean"}}} ``` - Only the given keys change. `due_date: null` clears the date; `assignee: null` unassigns; `parent_task_id: null` promotes to top level; `priority: null` clears priority. `status` and `type` must be valid enum values; an empty string is rejected with -32602. `title` is trimmed; an empty or whitespace-only `title` is rejected with `Title is required` (isError) — omit the key to leave the title unchanged. `due_date` must be a valid calendar date in `YYYY-MM-DD` (§5.5). - Returns the full task (§4.2) after the update. - Errors: -32602 for schema failures (`labels` is not accepted here; use `add_labels`/`remove_labels`), isError for task access and permission, member/label lookup, due date, parent (self, missing, circular), and the six billing rules in §4.2 — texts in §11. - Order of checks: task access, `add_labels`, `remove_labels`, then the write (which is where the edit_task permission is enforced — a guest with an unknown label sees the label error first). Unknown labels change nothing. - Side effects: activity `status_changed` `{from,to}`, `scope_changed` `{from,to}`, `billing_changed`, `billing_visibility_changed` only when the value actually changed. Assigning another user sends them an assignment notification (none when you assign yourself). No activity event for title, description, priority, due date, labels, parent, or `client_visible` changes (the `visibility_changed` event is emitted only by the web app's toggle). - Permission: `admin` and `project_manager` only. ### 5.7 add_comment - Title: `Add a comment`. Annotations: WRITE. - When: the user wants to leave a note, reply, or notify a project member on a task. - inputSchema: ```json {"type":"object","additionalProperties":false,"required":["task_id","body"],"properties":{"task_id":{"type":"integer"},"body":{"type":"string","minLength":1,"description":"Plain text. Mention a project member as @their.email@example.com to notify them."}}} ``` - Returns: ```json {"id":77,"task_id":42,"author_name":"Sam Rivera","body":"Ship it @maya@example.com","created_at":"2026-09-06T14:10:00+00:00"} ``` - `author_name`: the author's name, or their email when no name is set. - Errors: -32602 for schema failures (`body` must be a non-blank string), isError for task lookup — texts in §11. - Side effects: activity `comment_added`; every other project member gets a new-comment notification; each `@email` that belongs to a project member gets a mention notification (a mention of your own email is ignored). Mentions must be the full email, prefixed with `@`; names do not work. - Permission: all roles; guests only on client-visible tasks. ### 5.8 delete_task - Title: `Delete a task`. Annotations: DESTRUCTIVE (the only tool with `destructiveHint: true`). - When: only after the user has explicitly confirmed deletion of a specific task. Follow the steps in §7.8; rule in §8. - inputSchema: ```json {"type":"object","additionalProperties":false,"required":["task_id","confirm"],"properties":{"task_id":{"type":"integer"},"confirm":{"type":"boolean","const":true,"description":"Must be true. Deletes the task and all of its subtasks and comments permanently."}}} ``` - Returns: ```json {"deleted_task_id":42,"deleted_subtask_ids":[43,44]} ``` - Errors: -32602 for a missing, non-boolean, or false `confirm` (checked before any lookup), isError for task lookup and permission — texts in §11. - Side effects: the task, its subtasks, and its comments are removed permanently (no undo). Activity `task_deleted` with `metadata` `{"task_id":42,"title":"...","subtask_count":2,"via":"api"}` remains in the project log. - Permission: `admin` and `project_manager` only. ## 6. Name resolution rules Always call `get_context` (with `project` when you will name members or labels) before relying on a name. Matching is exact and case-insensitive; there is no fuzzy matching. | Argument | Accepted forms | Resolution | Failure | |---|---|---|---| | `project` | all-digit id (as a JSON string, e.g. `"3"`) or project name | id matched first among your memberships; else case-insensitive exact name | `No project named ''. Your projects: ...` / `Project name '' is ambiguous. Candidates: ...` | | `assignee` | `me`, all-digit user id, email, name | `me` = token owner (no membership check); others among project members by id, email, or name. **Guests: id, name and `me` only** — an e-mail address never resolves for a guest, so it cannot be used to confirm who is a member | `No project member matching ''. Members: (id ), ...` (guests: ` (id )`) / `Assignee '' is ambiguous. Candidates: ...` | | `assignee` in `search_tasks` | also `unassigned` | tasks with no assignee | `unassigned` in `create_task`/`update_task` is looked up as a name and fails | | `labels`, `add_labels`, `remove_labels` | label names | case-insensitive exact name among the project's labels; duplicates collapsed | `Unknown label '' in . Available labels: , . Labels are not created automatically.` | | `task_id`, `parent_task_id` | integer id only | | `Task not found` / `Parent task not found in this project` | - Ambiguity: when a name matches more than one project or member, the error lists the candidates with ids. Use the id, or ask the user which one they mean. - Two members can share a display name; prefer email when the user gives one. - Labels are never created by the API. If a label does not exist, tell the user and proceed without it or ask. ## 7. Recipes All calls are `tools/call`; only `params` is shown. ### 7.1 What is on my plate ```json {"name":"get_my_tasks","arguments":{}} ``` Report each task as title, project, status, due date, and `url`. Overdue tasks (as defined in §5.2) come first. ### 7.2 Create a bug in a project assigned to a person, due on a date ```json {"name":"get_context","arguments":{"project":"Acme Portal"}} ``` Confirm the project exists and that the person is in `members` (match name or email). Then: ```json {"name":"create_task","arguments":{"project":"3","title":"Login fails on Safari","type":"bug","priority":3,"assignee":"maya@example.com","due_date":"2026-09-12"}} ``` Report the returned `id` and `url`. ### 7.3 Move task N to done ```json {"name":"update_task","arguments":{"task_id":42,"status":"done"}} ``` If the reply is isError `Permission denied: edit_task`, the user is a guest in that project; say so. ### 7.4 Add a comment ```json {"name":"add_comment","arguments":{"task_id":42,"body":"Reproduced on Safari 18. @maya@example.com can you take a look?"}} ``` Use the member's email from `get_context` for mentions. ### 7.5 List unassigned high-priority tasks in a project ```json {"name":"search_tasks","arguments":{"project":"Acme Portal","assignee":"unassigned","priority":[3],"status":["inbox","planned","in_progress","in_review","needs_client"],"per_page":50}} ``` If `total_pages` > 1, repeat with `page: 2` and so on. ### 7.6 Create a subtask ```json {"name":"create_task","arguments":{"project":"3","title":"Add Safari to the browser matrix","parent_task_id":42}} ``` The parent must be in the same project. ### 7.7 Mark a task out-of-scope, billable, with hours and fee Hours must convert to a multiple of 15 minutes (2.5 h = 150). Send all fields in one call: ```json {"name":"update_task","arguments":{"task_id":42,"scope":"out_of_scope","is_billable":true,"required_minutes":150,"fee_amount":"1500.00","fee_currency":"EUR"}} ``` Add `"billing_client_visible":true` only if the user wants the client to see minutes and fee. ### 7.8 Delete a task 1. `get_task` with `include: ["subtasks"]` to learn the title and `subtask_count`. 2. Tell the user exactly what will be deleted: the title, the number of subtasks, and that comments are removed permanently. 3. Wait for an explicit yes for that task. Do not treat the original request as consent. 4. Only then: ```json {"name":"delete_task","arguments":{"task_id":42,"confirm":true}} ``` 5. Report `deleted_task_id` and `deleted_subtask_ids`. ## 8. Behaviour rules for agents Do: - Call `get_context` once at the start of a session; call it again with `project` before resolving members or labels in that project. - After a name resolves, use ids (`task_id`, project id) for later calls. - Ask the user before `delete_task`; set `confirm: true` only after explicit consent for that specific task. - Report the task `url` after every create or update. - Paginate `search_tasks` with `page` when `total_pages` > 1; do not claim a list is complete otherwise. - On `isError`, read the message, correct the arguments (use the listed candidates, ids, or labels), and retry once. On `-32602`, fix the argument named in the message. - Prefer one `update_task` call with several fields over several calls. - On HTTP 429, wait the `Retry-After` period (60 s) before retrying and reduce call volume (fewer, larger calls: `per_page` up to 100, one `update_task` with several fields, `get_context` once per session); never retry in a tight loop. The token is unaffected (§2.2). - Tell the user when a result was filtered by their role (guest), when a permission error occurs, or when a project or member does not exist. - Send a descriptive `User-Agent` header (for example `my-assistant/1.0`) on every request. It is no longer required on the API host (§2.2), but it identifies your client in logs and keeps working if edge rules change. Do not: - Invent, guess, or auto-create labels, members, or projects. Names must come from `get_context`. - Retry a write blindly after a `-32603` `Internal error` or a transport failure; check with `get_task` or `search_tasks` first to see whether the write landed. - Expose, log, or repeat the token. - Assume a task exists because the user remembers it: `Task not found` also covers tasks hidden from guests and tasks in projects the user is not a member of. - Use `search_tasks` without `project`; use `get_my_tasks` for the user's own work across projects. - Send `"42"` for an integer field or `null` where the schema does not list `"null"`. - Send a JSON number for `project` — it is always a string, even when it is an id: `"project":"3"`, not `"project":3`. ## 9. REST reference for GPT Actions Schema: `https://api.taskoh.app/openapi.json` (OpenAPI 3.1.0, title `TaskOH`, version `1.0.0`, server `https://api.taskoh.app`, security scheme `bearerAuth`). Import it with Authentication: API Key, Auth Type: Bearer. | operationId | Method and path | isConsequential | Input | Success | |---|---|---|---|---| | `list_projects` | `GET /api/projects` | false | none | 200 `data: {count, projects[], workspace_role}` | | `list_my_tasks` | `GET /api/dashboard/my-tasks` | false | query `limit` (1–100, default 50; out-of-range values are clamped into that range, never a validation error) | 200 `data: Task[]` | | `list_project_tasks` | `GET /api/projects/{id}/tasks` | false | path `id`; query `search`, `status[]`, `type[]`, `assignee_user_id[]`, `label_ids[]`, `priority[]`, `scope[]`, `is_billable[]` (`"1"`/`"0"`), `parent_id`, `sort`, `order`, `page`, `per_page` (1–100, default 50; out-of-range values are clamped into that range, never a validation error) | 200 `data: {project_id, tasks[], total, page, per_page, total_pages}` | | `create_task` | `POST /api/projects/{id}/tasks` | true | path `id`; body `TaskWrite`, `title` required | 201 `data: Task` | | `get_task` | `GET /api/tasks/{id}` | false | path `id` | 200 `data: Task` | | `update_task` | `PATCH /api/tasks/{id}` | true | path `id`; body `TaskWrite` | 200 `data: Task` | | `add_comment` | `POST /api/tasks/{taskId}/comments` | true | path `taskId`; body `{"body": "..."}` | 201 `data: Comment` | - Array query parameters use the `[]` suffix and are repeated for several values: `?status[]=inbox&status[]=planned`. - `list_project_tasks` `sort` values: `position`, `priority`, `due_date`, `created_at`, `title`, `status`, `type`, `scope`, `is_billable`, `assignee`, `client_visible`. - `TaskWrite` fields: `title` (max 255), `description`, `details` (update only), `status`, `type`, `priority` (1–3 or null), `due_date` (`YYYY-MM-DD`), `assignee_user_id`, `parent_id`, `client_visible`, `position` (0–100000), `scope`, `is_billable`, `required_minutes`, `fee_amount`, `fee_currency`, `billing_client_visible`. - REST uses numeric ids only. Call `list_projects` first to map names to ids. Members and labels are not part of the REST operations; use MCP `get_context` (or ask the user) to obtain `assignee_user_id` and `label_ids[]`. - The OpenAPI schema exposes no delete operation, so a GPT Action cannot delete tasks. Of the documented operations, only MCP `delete_task` deletes, and it requires `confirm: true`. Note that the token is not scoped to the documented operations: it acts as the user on the rest of the REST API as well (see the people guide, §9 and §10). - Success envelope: `{"success":true,"data":...}`. Error envelope: `{"success":false,"error":{"type":"","message":"","code":}}`, with `details` added on 422 validation errors. Types include `unauthorized` (401), `forbidden` (403), `not_found` (404), `validation_error` (422), and `plan_limit_exceeded` (422, when the workspace plan blocks the action; `details.limit_type` is `max_projects` or `max_members_per_project` and the message names the plan and limit). - Writes that produce activity events (see §1) are recorded in the activity log as "via API"; plain field edits are not logged. ## 10. Limits | Limit | Value | |---|---| | `search_tasks.per_page` | max 100, default 25 | | `get_my_tasks.limit` | max 100, default 50 | | `get_task` comments and activity | first 50 comments, newest 50 events | | `get_task` subtasks | uncapped | | REST `list_my_tasks.limit` and `list_project_tasks.per_page` | 1–100, default 50; out-of-range values are clamped (see §9) | | `title` | 255 characters | | Request execution time | Long-running requests are terminated server-side. | | Result size | no server limit; the LLM context window is the constraint. Use `per_page` and `include` to trim | | Rate limiting | 120 requests per rolling 60-second window per token: one bucket per token (not per IP address), shared by every `/api` route and `POST /mcp` combined. A JSON-RPC batch costs one request per message (max 20) and is refused as a whole when it does not fit. Browser sessions calling `POST /mcp` share the same bucket, keyed per user; sessions on `/api` are not limited. Above the quota: HTTP 429 with `Retry-After: 60` and the body `{"success":false,"error":{"type":"rate_limited","message":"Too many requests. Please try again later.","code":429}}` (on `/mcp` too a plain HTTP 429, not a JSON-RPC error). The token is not affected; wait 60 s, then retry with fewer, larger calls. Browser sessions are not limited by this rule (§2.2). | | Batch | Max 20 requests per batch (`-32600` `Batch too large (max 20)` above that). Each message costs one request of the quota, and a batch that does not fit is refused as a whole with HTTP 429 without executing anything. Keep batches small (a handful of requests); batches are dispatched sequentially and are subject to the same execution limit as a single request. | | Argument sizes | `title` 255 characters; `description`, `details` and `body` 65535 bytes; `query` 1000 bytes; `page` at most 100000; array filters at most 20 items (duplicates ignored). Over the limit: `-32602` (§11). | ## 11. Error catalogue | Layer | Code / status | Message | Action | |---|---|---|---| | HTTP | 401 | `Missing Authorization header` | send the header | | HTTP | 401 | `Invalid Authorization header format` | use `Bearer ` | | HTTP | 401 | `Invalid or revoked token` | stop; user must create a new token at https://app.taskoh.app/integrations | | HTTP | 401 | `Invalid token` | bearer does not start with `toh_`; the token was pasted incompletely — resend the full `toh_pat_…` token | | HTTP | 403 | `This route requires an interactive session` | route is token-blocked; not available to agents | | HTTP | 403 | plain text `error code: 1010`, `server: cloudflare`, no JSON | answered by the CDN edge, not TaskOH (a zone security rule); should not occur on the API host since 2026-09-07 — retry after a short wait and report the `cf-ray` header if it persists; not an auth error | | HTTP | 405 | empty body, `Allow: POST` | use `POST` on `/mcp`; no SSE | | HTTP | 429 | `Too many requests. Please try again later.` (`Retry-After: 60`) | more than 120 requests in 60 s with this token; wait 60 s, retry, reduce volume | | HTTP | 400 | JSON-RPC `-32600` `Malformed MCP-Protocol-Version header` | send a `YYYY-MM-DD` value or omit the header | | JSON-RPC | `-32700` | `Parse error` | body is not valid JSON | | JSON-RPC | `-32600` | `Invalid Request` | message is not an object, `method` missing, batch is `[]`, or `id` is not a string, number or null | | JSON-RPC | `-32601` | `Method not found` | only `initialize`, `ping`, `tools/list`, `tools/call` exist | | JSON-RPC | `-32602` | `Missing tool name` | set `params.name` | | JSON-RPC | `-32602` | `arguments must be an object` | set `params.arguments` to an object | | JSON-RPC | `-32602` | `Unknown tool ''. Available: get_context, get_my_tasks, search_tasks, get_task, create_task, update_task, add_comment, delete_task` | fix the name | | JSON-RPC | `-32602` | `Missing required argument ''` | add the key | | JSON-RPC | `-32602` | `Unknown argument '' for ` | remove the key | | JSON-RPC | `-32602` | `Argument '' must be of type or ` | fix the JSON type (`"42"` is not an integer) | | JSON-RPC | `-32602` | `Argument '' must be one of: ...` | use an enum value; array items report as `status[0]` | | JSON-RPC | `-32602` | `Argument '' must be true` | `delete_task.confirm` | | JSON-RPC | `-32602` | `Argument '' must be at least ` / `must be at most ` | integer bounds | | JSON-RPC | `-32602` | `Argument '' must not be empty` | whitespace-only string | | JSON-RPC | `-32602` | `Argument '' must be at most 255 characters` | shorten (`title`) | | JSON-RPC | `-32602` | `Argument '' must be at most 65535 bytes` / `must be at most 1000 bytes` | `description`, `details` and `body` are capped at 65535 bytes, `query` at 1000 bytes; shorten | | JSON-RPC | `-32602` | `Argument '' must have at most 20 items` | array filters (`status`, `type`, `labels`, `priority`, `scope`, `add_labels`, `remove_labels`) take at most 20 items; duplicates are ignored | | JSON-RPC | `-32602` | `Argument 'page' must be at most 100000` | `page` is capped at 100000 | | JSON-RPC | `-32602` | `Argument 'fee_currency' must match ^[A-Z]{3}$` | e.g. `EUR` | | JSON-RPC | `-32602` | `project is required — call get_context to list projects, or get_my_tasks for your own open work.` | add `project` to `search_tasks` | | JSON-RPC | `-32603` | `Internal error` | do not retry writes blindly; verify state, then report | | isError | — | `Task not found` | wrong id, foreign project, or hidden from guest | | isError | — | `No project named ''. Your projects: ...` | pick from the list | | isError | — | `Project name '' is ambiguous. Candidates: ...` | use the id | | isError | — | `No project member matching ''. Members: ...` | pick from the list | | isError | — | `Assignee '' is ambiguous. Candidates: ...` | use id or email | | isError | — | `Unknown label '' in . Available labels: ... Labels are not created automatically.` | use an existing label or drop it | | isError | — | `Only the project team can set labels` | guest; create without labels | | isError | — | `Permission denied: edit_task` | guest cannot update or delete | | isError | — | `Title is required` | empty or whitespace-only `title` on create or update (§5.5, §5.6) | | isError | — | `Invalid due_date format. Use YYYY-MM-DD` | send a valid calendar date in `YYYY-MM-DD`; see §5.5 | | isError | — | `Parent task not found in this project` | parent must be in the same project | | isError | — | `A task cannot be its own parent` | change `parent_task_id` | | isError | — | `Cannot set a descendant as the parent (circular reference)` | change `parent_task_id` | | isError | — | `Only out-of-scope tasks can be billed separately` | set `scope: out_of_scope` first or in the same call | | isError | — | `Required minutes must be a positive multiple of 15 (quarter-hour increments)` | round to 15 | | isError | — | `Fee amount must be a positive decimal with at most 2 decimal places` | e.g. `"1500.00"`; integer part 1–10 digits, no leading zeros, at most 2 decimals (see §4.2) | | isError | — | `Fee amount must be greater than zero` | positive amount | | isError | — | `Fee currency must be a 3-letter uppercase code when a fee is provided` | add `fee_currency` | | isError | — | `Fee currency can only be provided with a fee amount` | add `fee_amount` or drop the currency | | isError | — | `Clear the required hours and fee when a task is not billable` | send `required_minutes: null`, `fee_amount: null`, `fee_currency: null` | ## 12. Example JSON-RPC exchanges ### 12.1 initialize Request: ```json {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"my-agent","version":"1.0"}}} ``` Response (HTTP 200): ```json {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-06-18","capabilities":{"tools":{}},"serverInfo":{"name":"taskoh","version":"1.0"},"instructions":"Call get_context once before resolving project, member or label names. Tasks live in projects; statuses are inbox, planned, in_progress, in_review, needs_client, done. Use get_my_tasks for the user's own open work and search_tasks for everything else. Writes act as the signed-in user and appear in TaskOH's activity log. Full reference: https://api.taskoh.app/llms.txt"}} ``` The client's `notifications/initialized` message gets HTTP 202 with an empty body. ### 12.2 tools/list (abbreviated) Request: ```json {"jsonrpc":"2.0","id":2,"method":"tools/list"} ``` Response: ```json {"jsonrpc":"2.0","id":2,"result":{"tools":[ {"name":"get_context","title":"Get workspace context","description":"Returns the signed-in user, projects with your role, and the fixed status list. Pass a project to also get its members and labels.","inputSchema":{"type":"object","additionalProperties":false,"properties":{"project":{"type":"string","description":"Project id or name. When set, also returns that project's members and labels."}}},"annotations":{"readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false}}, {"name":"delete_task","title":"Delete a task","description":"Deletes a task permanently, including its subtasks and comments. Requires confirm: true.","inputSchema":{"type":"object","additionalProperties":false,"required":["task_id","confirm"],"properties":{"task_id":{"type":"integer"},"confirm":{"type":"boolean","const":true,"description":"Must be true. Deletes the task and all of its subtasks and comments permanently."}}},"annotations":{"readOnlyHint":false,"destructiveHint":true,"idempotentHint":true,"openWorldHint":false}} ]}} ``` Only the first and last of the 8 entries are shown; the other six (`get_my_tasks`, `search_tasks`, `get_task`, `create_task`, `update_task`, `add_comment`, in that order between them) follow the same shape, and their exact `inputSchema` values are the ones printed in §5.2–§5.7. There is no `nextCursor`; the list is complete. ### 12.3 create_task Request: ```json {"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"create_task","arguments":{"project":"Acme Portal","title":"Login fails on Safari","type":"bug","priority":3,"assignee":"Maya Okafor","due_date":"2026-09-12","labels":["frontend"]}}} ``` Response: ```json {"jsonrpc":"2.0","id":3,"result":{"content":[{"type":"text","text":"{\"id\":42,\"project_id\":3,\"parent_id\":null,\"title\":\"Login fails on Safari\",\"description\":null,\"details\":null,\"status\":\"inbox\",\"type\":\"bug\",\"scope\":\"unclassified\",\"is_billable\":false,\"required_minutes\":null,\"fee_amount\":null,\"fee_currency\":null,\"billing_client_visible\":false,\"client_visible\":false,\"created_by_user_id\":7,\"assignee_user_id\":9,\"priority\":3,\"due_date\":\"2026-09-12\",\"metadata\":[],\"position\":0,\"created_at\":\"2026-09-06T14:10:00+00:00\",\"updated_at\":\"2026-09-06T14:10:00+00:00\",\"label_ids\":[11],\"subtask_count\":0,\"assignee_name\":\"Maya Okafor\",\"assignee_email\":\"maya@example.com\",\"assignee_last_activity_at\":null,\"label_names\":[\"frontend\"],\"project_name\":\"Acme Portal\",\"url\":\"https://app.taskoh.app/projects/3/tasks/42\"}"}],"structuredContent":{"id":42,"project_id":3,"parent_id":null,"title":"Login fails on Safari","description":null,"details":null,"status":"inbox","type":"bug","scope":"unclassified","is_billable":false,"required_minutes":null,"fee_amount":null,"fee_currency":null,"billing_client_visible":false,"client_visible":false,"created_by_user_id":7,"assignee_user_id":9,"priority":3,"due_date":"2026-09-12","metadata":[],"position":0,"created_at":"2026-09-06T14:10:00+00:00","updated_at":"2026-09-06T14:10:00+00:00","label_ids":[11],"subtask_count":0,"assignee_name":"Maya Okafor","assignee_email":"maya@example.com","assignee_last_activity_at":null,"label_names":["frontend"],"project_name":"Acme Portal","url":"https://app.taskoh.app/projects/3/tasks/42"}}} ``` ### 12.4 update_task Request: ```json {"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"update_task","arguments":{"task_id":42,"status":"in_progress","assignee":"me","add_labels":["backend"],"remove_labels":["frontend"]}}} ``` Response: same shape as §12.3 with `status: "in_progress"`, `assignee_user_id: 7`, `label_names: ["backend"]`, and a newer `updated_at`. ### 12.5 delete_task with confirm Request: ```json {"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"delete_task","arguments":{"task_id":42,"confirm":true}}} ``` Response: ```json {"jsonrpc":"2.0","id":5,"result":{"content":[{"type":"text","text":"{\"deleted_task_id\":42,\"deleted_subtask_ids\":[43,44]}"}],"structuredContent":{"deleted_task_id":42,"deleted_subtask_ids":[43,44]}}} ``` ### 12.6 A -32602 error Request (confirm omitted): ```json {"jsonrpc":"2.0","id":6,"method":"tools/call","params":{"name":"delete_task","arguments":{"task_id":42}}} ``` Response (HTTP 200): ```json {"jsonrpc":"2.0","id":6,"error":{"code":-32602,"message":"Missing required argument 'confirm'"}} ``` Request (wrong type): ```json {"jsonrpc":"2.0","id":7,"method":"tools/call","params":{"name":"get_task","arguments":{"task_id":"42"}}} ``` Response: ```json {"jsonrpc":"2.0","id":7,"error":{"code":-32602,"message":"Argument 'task_id' must be of type integer"}} ``` ### 12.7 An isError result Request: ```json {"jsonrpc":"2.0","id":8,"method":"tools/call","params":{"name":"create_task","arguments":{"project":"Acme Portal","title":"Fix header","labels":["front-end"]}}} ``` Response (HTTP 200, JSON-RPC success, tool error): ```json {"jsonrpc":"2.0","id":8,"result":{"content":[{"type":"text","text":"Unknown label 'front-end' in Acme Portal. Available labels: frontend, backend. Labels are not created automatically."}],"isError":true}} ``` Correct call: `"labels":["frontend"]`. ## 13. Changelog | Version | Date | Change | |---|---|---| | 1.5 | 2026-09-07 | Security hardening: a JSON-RPC batch of n messages costs n requests and is refused whole when it does not fit; browser sessions on `POST /mcp` share the per-user 120/60 s bucket; token-only endpoints now also cover member and team management, project deletion and Stripe routes; project payloads omit share-link fields for tokens; guest `create_task` follows the portal request rules (`status`/`priority`/`due_date` ignored, disabled requests refused, team notified); guests receive no member e-mails, `subtask_count` or assignee e-mails; validation caps for `page`, `title`, text fields and array filters; JSON-RPC `id` type check; the owner is e-mailed on token creation and holds at most 20 active tokens. | | 1.4 | 2026-09-07 | The CDN's browser-signature check was switched off for `api.taskoh.app`: Python's default `Python-urllib/3.x` User-Agent now works on the API host (`taskoh.app` and `app.taskoh.app` still apply the check). The `error code: 1010` entries in §2.2 and §11 are kept as a diagnostic. No API change. | | 1.3 | 2026-09-07 | Per-token rate limiting introduced: 120 requests per rolling 60 s per token across `/api` and `POST /mcp`; HTTP 429 with `Retry-After: 60` (batch charging and the session rule on `/mcp` arrived with 1.5). | | 1.2 | 2026-09-07 | Documented the CDN edge block: a request whose `User-Agent` is Python's default `Python-urllib/3.x` receives a plain-text 403 `error code: 1010` from Cloudflare before reaching TaskOH. Send a descriptive `User-Agent` (§2.2, §8, §11). No API change. | | 1.1 | 2026-09-06 | `title` is trimmed server-side and an empty or whitespace-only title is rejected with `Title is required` on create and update. `due_date` must be a valid calendar date (calendar-invalid dates such as `2026-02-30` are rejected). REST `list_my_tasks.limit` and `list_project_tasks.per_page` are clamped to 1–100 (default 50) and `openapi.json` declares the same bounds. | | 1.0 | 2026-09-06 | First release. Authentication is by personal access token only (`toh_pat_`); OAuth is on the roadmap. 8 MCP tools, 7 REST operations, no token scopes. |