> Part of the Fastio API Reference. Overview: https://api.fast.io/current/llms/ # Comments API Base URL: `https://api.fast.io/current/` Auth: All endpoints require `Authorization: Bearer {jwt_token}`. Response format: JSON (standard envelope: `result` and data fields at the root). Content-Type: Comments use **JSON request bodies** (`Content-Type: application/json`), unlike most other Fastio endpoints which use `application/x-www-form-urlencoded`. --- ## Endpoint Summary | Method | Path | Description | |--------|------|-------------| | GET | `/current/comments/{entity_type}/{parent_id}/` | List all comments in a workspace or share | | GET | `/current/comments/{entity_type}/{parent_id}/{node_id}/` | List comments for a specific node | | POST | `/current/comments/{entity_type}/{parent_id}/` | Create or update a comment on an entity | | POST | `/current/comments/{entity_type}/{parent_id}/{node_id}/` | Create or update a comment on a node | | GET | `/current/comments/fileshare/{fileshare_id}/{node_id}/` | List a File Share recipient's comments on a node (see File Share Comments) | | POST | `/current/comments/fileshare/{fileshare_id}/{node_id}/` | Create or update a comment as a File Share recipient | | POST | `/current/comments/{comment_id}/update/` | Edit a comment by ID | | POST | `/current/comments/{comment_id}/resolve/` | Resolve a comment thread | | POST | `/current/comments/{comment_id}/reopen/` | Reopen a resolved comment thread | | POST | `/current/comments/{comment_id}/assign/` | Set, replace, or clear a thread's assignee | | POST | `/current/comments/{comment_id}/suggestion/accept/` | Accept a suggestion (editor or headless) | | POST | `/current/comments/{comment_id}/suggestion/confirm/` | Confirm an editor accept with the saved note version | | POST | `/current/comments/{comment_id}/suggestion/release/` | Give back an editor accept | | POST | `/current/comments/{comment_id}/suggestion/reject/` | Reject a suggestion | | POST | `/current/comments/{comment_id}/suggestion/withdraw/` | Withdraw your suggestion | | POST | `/current/comments/{comment_id}/suggestion/undo/` | Return a decided suggestion to pending | | GET | `/current/comments/{comment_id}/details/` | Get a single comment's details | | DELETE | `/current/comments/{comment_id}/delete/` | Soft-delete a comment and its replies | | POST | `/current/comments/bulk/delete/` | Bulk soft-delete multiple comments | | POST | `/current/comments/{comment_id}/reactions/` | Add or change an emoji reaction | | DELETE | `/current/comments/{comment_id}/reactions/` | Remove an emoji reaction | | GET | `/current/comments/{comment_id}/attachments/` | List a comment's attachments (hydrated, access-gated) | | POST | `/current/comments/{comment_id}/attachments/` | Attach one or many objects to a comment | | POST | `/current/comments/{comment_id}/attachments/detach/` | Detach an object from a comment | | POST | `/current/comments/{comment_id}/unlink/` | Unlink a comment from the entity it was linked to | | GET | `/current/comments/preferences/` | Your global comment email preference | | POST | `/current/comments/preferences/` | Set your global comment email preference | | GET | `/current/comments/preferences/{node_id}/` | Your comment email preference for one file | | POST | `/current/comments/preferences/{node_id}/` | Set or clear the per-file override | > **Searching comments:** To search comments by keyword, use the unified search > endpoint โ€” `GET /current/workspace/{workspace_id}/search/` (or > `/current/share/{share_id}/search/`) returns a `comments` bucket alongside > files and other types, each with its own pagination. See *Unified Search* in > the Storage Operations reference. Comment search results are permission-filtered > to comments the caller can see. --- ## Path Parameters Comments are scoped to a workspace or share, and optionally to a specific node (file or folder) within it. When no node ID is specified, all comments across the entire workspace or share are returned. | Parameter | Type | Format | Description | |-----------|------|--------|-------------| | `{entity_type}` | string | `"workspace"`, `"share"`, or `"fileshare"` | Entity type discriminator. For `"fileshare"` the `{node_id}` segment is **required** (see *File Share Comments*) | | `{parent_id}` | string | 19-digit numeric (a File Share also accepts its opaque `id_alt`) | Workspace, share, or File Share ID. **Note:** Do not confuse with `parent_id` in the POST request body, which is the parent *comment* ID for threading | | `{node_id}` | string | Alphanumeric opaque ID | File or folder ID within the entity (optional for workspace/share, required for `fileshare`) | | `{comment_id}` | string | Alphanumeric opaque ID | Comment identifier | --- ## Comment Object Every comment returned by the API has this structure: | Field | Type | Description | |-------|------|-------------| | `id` | string | Alphanumeric opaque ID of the comment | | `entity` | string | Opaque ID of the entity the comment belongs to (workspace, share, or node) | | `user_id` | string | 19-digit numeric ID of the comment author's account. Always present โ€” File Share recipients comment as signed-in accounts too | | `actor` | object | Who wrote the comment and whether an agent acted for them: `user_id`, `kind` (`human`, `agent`, `api_key`, `app`, `system`, `unknown`), `agent_name`, `name_source`, `credential_type`, `verified`. `verified` is `true` only for Fastio's own built-in agent; any other `agent_name` is **self-declared** -- display it (e.g. "Dobby (via API key) for Derek"), never treat it as verified. Comments created before actor attribution existed report `kind: "unknown"` credited to the comment's author, unless an earlier record names that same author, and are never `verified`. Full reference: *Actor Attribution* in the Storage reference | | `parent_id` | string\|null | Opaque ID of the parent comment (for replies), or null for top-level | | `body` | string | Comment text content (may include mention markup) | | `profile_type` | string\|null | The container type (`"workspace"` or `"share"`) that owns the entity | | `profile_id` | string\|null | 19-digit numeric ID of the workspace or share that owns the entity | | `scope_id` | string\|null | When a comment originated under a File Share and is surfaced in a workspace context, the 19-digit numeric ID of that File Share โ€” a present (non-null) value marks the comment as "via File Share". Null/omitted for comments that originated directly in the workspace or share | | `linked_entity_type` | string\|null | Type of the entity the comment is linked to (see Unlink Comment), or null when unlinked. New comments are never linked | | `linked_entity_id` | string\|null | Opaque ID of the linked entity, or null | | `external_author_email` | string\|null | Email of an external author on a legacy externally-authored comment; otherwise omitted/null | | `external_author_name` | string\|null | Display name of the external author, when present; otherwise omitted/null | | `external_author_token_id` | string\|null | Opaque ID of the access token the external author used, when present; otherwise omitted/null | | `reference` | object\|null | Anchoring reference to a position in a file (see Reference Anchoring) | | `mentions` | array | The server-validated set of mentioned profile IDs that took effect (19-digit numeric strings). Always present; empty when no mention took effect. This is the authoritative took-effect set โ€” mentions in the body markup that fail validation (e.g. a non-member) are excluded here | | `reactions` | object | Map of emoji character to total reaction count (e.g., `{"๐Ÿ‘": 3}`). With no reactions it serializes as an empty array `[]`, not `{}` | | `user_reaction` | string\|null | The current authenticated user's emoji reaction, or null | | `version_hash` | string\|null | Hash representing the current comment content version | | `version` | integer | Monotonically increasing version counter (starts at 1) | | `attachment_count` | integer | Number of objects attached to the comment (see Comment Attachments). Present on comment **list** rows and the comment **detail** response | | `attachments` | array | Hydrated, access-gated attachment rows (see Comment Attachments). **Detail endpoint only** | | `edited_at` | string\|null | Timestamp of last edit if the comment has been edited (`YYYY-MM-DD HH:MM:SS UTC`), or null | | `can_edit` | boolean | Whether the requesting user can edit this comment | | `can_delete` | boolean | Whether the requesting user can delete this comment | | `can_reply` | boolean | Whether the comment can be replied to (top-level only) | | `created` | string | Creation timestamp (`YYYY-MM-DD HH:MM:SS UTC`) | | `updated` | string | Last-updated timestamp (`YYYY-MM-DD HH:MM:SS UTC`) | | `deleted` | string\|null | Deletion timestamp (`YYYY-MM-DD HH:MM:SS UTC`), or null if active. Included only on the details and delete responses โ€” list rows never carry it, even with `include_deleted=true` | **Null fields are omitted on most responses.** On the list, create/update, update-by-ID and delete responses, a field whose value is null is **left out** rather than sent as `null` โ€” `parent_id`, `profile_type`, `profile_id`, `scope_id`, `linked_entity_*`, `external_author_*`, `reference`, `user_reaction`, `version_hash` and `edited_at`. The details and unlink responses send them as `null`. Treat a missing key and `null` the same. Note: the server-validated `mentions` set is surfaced as the top-level `mentions` array described above. The raw internal `properties` object (threading, pinning, content-filter flags, etc.) is never returned โ€” only the curated `mentions` list is exposed. **Service accounts.** A workspace service-account key may read comments in its own workspace; an `editor` may also create, edit, delete, react to and attach to them. Comments on a share or a File Share are refused with `403` and `error.params.reason` = `service_account_not_permitted` (even when the share belongs to the workspace), and comments that originated under a File Share (`scope_id` set) are left out of what it reads. A comment it writes has an `actor` carrying `service_account: true`, `credential_id`, `credential_hint`, `account_type: "service_account"` and the account's `name` (see *Service-account keys* in the [Auth reference](https://api.fast.io/current/llms/auth/)). A service account is never notified of a mention or reply. ### Thread State Fields (root comments only) A **thread** is a root comment (one with no `parent_id`) plus its replies. These fields carry the thread's state and live on the root; a reply returns them as `null`/omitted, the same as any other null field (see *Null fields are omitted* above). | Field | Type | Description | |-------|------|-------------| | `kind` | string | `"comment"` or `"suggestion"`. Root only; defaults to `"comment"` | | `resolved` | boolean | Whether the thread is resolved | | `resolved_by` | object\|null | `{user_id, name}` of whoever resolved the thread, or null | | `resolved_at` | string\|null | Resolution timestamp (`YYYY-MM-DD HH:MM:SS UTC`), or null | | `assignee` | object\|null | The thread's single assignee: `{user_id, name, agent?: {agent_name, credential_type, display, credential_id?}}`, or null when unassigned. `agent.credential_id` is included only when the caller is the assignee themselves or an admin | | `assigned_by` | object\|null | `{user_id, name}` of whoever made the current assignment, or null | | `assigned_at` | string\|null | Assignment timestamp (`YYYY-MM-DD HH:MM:SS UTC`), or null | | `state_version` | integer | A monotonic counter, incremented on every thread-state change (resolve, reopen, assign). Send it back as `if_state_version` to guard a write against a change you haven't seen yet (see *Concurrency* under Thread State below) | | `for_me` | boolean | Whether this thread is "for" the calling identity. For a person: assigned to them with no agent qualifier, or they are mentioned in the thread (root or any reply). For an agent credential: assigned to them via that same credential | | `for_my_agents` | boolean | True when the thread is assigned to the calling person via any of their agent connections | | `can_resolve` | boolean | Whether the caller may resolve or reopen this thread. Always `false` on File Share comments | | `can_assign` | boolean | Whether the caller may assign this thread. Always `false` on File Share comments | | `can_accept` | boolean | Whether the caller may accept this thread's suggestion (only meaningful when `kind` is `"suggestion"`) | | `can_reject` | boolean | Whether the caller may reject this thread's suggestion (only meaningful when `kind` is `"suggestion"`) | --- ## List Comments ### `GET /current/comments/{entity_type}/{parent_id}/` ### `GET /current/comments/{entity_type}/{parent_id}/{node_id}/` Without `{node_id}`: returns all comments across the entire workspace or share. With `{node_id}`: returns only comments on that specific file or folder. **Auth:** Required (JWT). Rate limited. **Query Parameters:** | Parameter | Type | Required | Default | Constraints | Description | |-----------|------|----------|---------|-------------|-------------| | `sort` | string | No | `asc` | `"asc"` or `"desc"` | Sort order by creation time | | `limit` | integer | No | -- | Min: 2, Max: 200 | Number of comments to return | | `offset` | integer | No | `0` | Min: 0 | Number of comments to skip | | `page` | integer | No | -- | Min: 1 | Page number (alternative to offset-based pagination) | | `include_deleted` | boolean | No | `false` | -- | Include soft-deleted comments in results | | `reference_type` | string | No | -- | -- | Filter by reference anchor type: `"video"`, `"image"`, `"document"`, `"general"`, or `"audio"` (see Reference Anchoring) | | `include_total` | boolean | No | `false` | -- | Include total count and pagination metadata in response | | `group` | string | No | -- | `"threads"` | Page by root thread instead of by individual comment -- see *Thread Grouping* below | | `status` | string | No | `"all"` | `"open"`, `"resolved"`, or `"all"` | Filter threads by state: `open` is an unresolved comment thread, `resolved` is a resolved one | | `for_me` | string | No | -- | `"true"` or `"agents"` | Filter to threads that are "for" the caller -- see `for_me` / `for_my_agents` under *Thread State Fields* above | | `kind` | string | No | `"comment"` | `"comment"`, `"suggestion"`, or `"all"` | Filter root threads by kind | | `include_suggestions` | boolean | No | `false` | -- | Include suggestion threads in the results. Suggestion threads are hidden unless requested; `kind=suggestion` implies this | **Request Example:** ```bash curl -X GET "https://api.fast.io/current/comments/workspace/1234567890123456789/?limit=50&include_total=true" \ -H "Authorization: Bearer {jwt_token}" ``` **Response:** ```json { "result": true, "comments": [ { "id": "abc123opaqueid", "entity": "xyz789opaqueid", "user_id": "1234567890123456789", "actor": { "user_id": "1234567890123456789", "kind": "human", "agent_name": null, "name_source": null, "credential_type": "session", "verified": false }, "body": "This looks great!", "profile_type": "workspace", "profile_id": "1234567890123456789", "mentions": [], "reactions": {"๐Ÿ‘": 2, "โค๏ธ": 1}, "user_reaction": "๐Ÿ‘", "version": 1, "created": "2025-01-15 10:30:00 UTC", "updated": "2025-01-15 10:30:00 UTC", "can_edit": true, "can_delete": true, "can_reply": true, "attachment_count": 0 } ], "count": 1, "allowed": true, "remaining": 95, "total": 5, "limit": 50, "offset": 0 } ``` **Response Fields:** | Field | Type | Description | |-------|------|-------------| | `comments` | array | Array of comment objects | | `count` | int | Number of comments in this response | | `allowed` | bool | Whether the current user can post new comments (based on the organization's comment limit) | | `remaining` | int | Remaining comments allowed under the organization's limit (only present if a limit applies) | | `total` | int | Total number of comments (only if `include_total=true`). On a share, the number of top-level comments you can see | | `limit` | int | Limit used in query (only if `include_total=true`) | | `offset` | int | Offset used in query (only if `include_total=true`) | ### Thread Grouping (`group=threads`) Without `group=threads`, the response is the flat shape above, unchanged. With `group=threads`, `limit` and `offset` page by **root thread** instead of by individual comment, and the response is: ```json { "result": true, "threads": [ { "id": "abc123opaqueid", "kind": "comment", "resolved": false, "state_version": 2, "replies": [ { "id": "def456opaqueid", "parent_id": "abc123opaqueid" } ], "replies_total": 1 } ], "counts": {"all": 12, "open": 9, "done": 3, "for_me": 2, "for_me_agents": 0, "open_comments": 8, "pending_suggestions": 1}, "limits": {"root_threads_used": 12, "root_threads_max": 1000}, "limit": 50, "offset": 0, "total": 12 } ``` | Field | Type | Description | |-------|------|-------------| | `threads` | array | Root comments, each carrying the *Thread State Fields* above plus `replies` | | `threads[].replies` | array | The thread's replies, embedded and capped per root (see `replies_offset` / `replies_limit` under *Get Comment Details* for loading the rest) | | `threads[].replies_total` | int | Total reply count for the thread, which may exceed the number embedded in `replies` | | `counts` | object | `{all, open, done, for_me, for_me_agents, open_comments, pending_suggestions}` -- root-thread counts honoring scope and visibility, but **ignoring** the `status` and `for_me` filters, so filter chips stay stable. `open` combines open comment threads and pending suggestion threads into one header count; `done` combines resolved comments and decided suggestions. The suggestion-related counts are populated only when suggestion threads are in scope (`include_suggestions=true` or `kind` includes `suggestion`) | | `limits` | object | `{root_threads_used, root_threads_max}` for the file. `root_threads_max` is `null` when the plan has no cap (unlimited). See *Thread Limits* under Thread State below | | `total` | int | Total number of root threads matching the filters (present the same way `include_total` governs it on the flat response) | | `counts_partial` | bool | Present (`true`) only when the surface has more than 5,000 root threads: the thread listing examines the first 5,000 roots in listing order, and `counts`, `total` and the filtered pages cover only those | | `truncated` / `truncated_limit` | bool / integer | Present with `counts_partial` (`truncated_limit` = 5,000): the `group=threads` or filtered listing is **truncated** โ€” root threads past the first 5,000 in listing order cannot be reached by its paging, `status` / `kind` / `for_me` filters or counts. Narrow the read (a single file's node) to reach them. The default flat list (no filter, no `group`) is not bounded this way | | `replies_truncated` | bool | Present (`true`) only when the page's threads together have more than 10,000 replies: some `replies` arrays are then cut short and their `replies_total` is a lower bound; load the rest per thread with `GET /current/comments/{root_id}/details/` | **Access Levels:** | Role | Access | |------|--------| | Workspace Owner/Member | Full access -- sees all comments | | Share Owner/Admin/Member | Full access -- sees all comments | | Share Guest (Send, Receive) | Filtered -- sees their own threads with every reply in them, plus threads started by share owners, admins or members with those people's replies and the guest's own replies. Other guests' threads and replies are hidden | | Share Guest (Exchange) | Full access -- sees all comments | | Signed-in non-member of a share open to any signed-in user | Send and Exchange: sees all comments. Receive: same as a Send/Receive guest | | Anyone-with-link share | Comments are disabled | **Error Responses:** **Reading the error tables:** the four-digit `16xx`/`17xx` values below are **HTTP-status classes, not `error.code`**. The `error.code` a client actually receives is assigned per endpoint, so **use the HTTP status as the gate and a documented `error.code` โ€” five or six digits, plus the `9661`-`9669` family โ€” only as a refinement**. A `16xx` value identifies the status class โ€” useful for telling which kind of failure occurred โ€” but comparing one against `error.code` will never match. Codes shown as five or six digits (and the `9661`-`9669` family) ARE `error.code` values. **If you widen a check from a specific code to a status, widen what you assert with it** โ€” a status covers failures the narrower code did not, so a message written for that one code becomes a confident falsehood on the rest. | Error Code | HTTP Status | Message | Cause | |------------|-------------|---------|-------| | `1605 (Invalid Input)` | 406 | "Invalid API format..." | Missing entity type or parent ID | | `1605 (Invalid Input)` | 406 | "Entity type must be \"workspace\", \"share\", or \"fileshare\"" | Invalid entity type | | `1680 (Access Denied)` | 401 | "Invalid entity or insufficient permissions" | User lacks access | | `1609 (Not Found)` | 404 | "Invalid entity or insufficient permissions" | Entity not found | | `1610 (Internal Error)` | 500 | "Failed to retrieve comments" | Internal error | | *(generated per call site)* | 503 | "Comment thread state is temporarily unavailable. Please try again." | The threads could not be classified (comment vs suggestion) โ€” `error.params.reason` = `comment_thread_state_unavailable`, `retryable: true`. Never answered with suggestions shown as comments | **Notes:** - **Scope-level queries:** When called without a `{node_id}`, this endpoint returns comments from all files and folders within the workspace or share. Only comments created after 2026-03-15 are included in scope-level results; older comments are accessible via the node-level endpoint. - **Share reads are filtered and paged by thread.** On a share (`GET /current/comments/share/{share_id}/` and `.../{node_id}/`), the access table above decides what you see, and comments on files you cannot view (for example, another guest's upload on a Receive share) are never shown. `limit` and `offset` count the top-level comments you can see, and each one is followed by its replies that you can see, so `count` can be larger than `limit`. With `include_total=true`, `total` is the number of top-level comments you can see. `allowed` and `remaining` reflect the share's full comment count, not only the comments visible to you. A comment you cannot see answers `404` "Comment not found" on its details, reactions and attachments routes, and replying to it answers `404` "Parent comment not found". Share search results follow the same rules. - **Workspace node reads include File Share comments.** A file shared through a File Share is the same node that lives in the owning workspace, so a workspace node read (`GET /current/comments/workspace/{workspace_id}/{node_id}/`) also returns comments left by File Share recipients on that file. Those File Share comments appear **read-only** in the workspace view: `can_reply`, `can_edit`, and `can_delete` are `false` (replies and edits to a File Share comment go through the File Share's own surface). The `total` count reflects everything visible in the listing (the workspace's own comments plus the File Share comments), while the `allowed` / `remaining` fields reflect only the workspace's own writable comments โ€” so `remaining` is not reduced by File Share comments you cannot edit. This inclusion is **one-directional**: workspace members see File Share comments, but File Share recipients never see the workspace's internal comments. --- ## Create or Update Comment ### `POST /current/comments/{entity_type}/{parent_id}/` ### `POST /current/comments/{entity_type}/{parent_id}/{node_id}/` Create a new comment or update an existing one. **Auth:** Required (JWT). Rate limited. **Content-Type:** `application/json` **Request Body:** | Field | Type | Required | Constraints | Description | |-------|------|----------|-------------|-------------| | `body` | string | Yes | 1-8192 UTF-8 characters (raw, not bytes); display text excluding mentions max 500 UTF-8 characters โ€” a mention inside markdown code counts in full (see Mentions). Over-limit is rejected, never truncated | Comment text content | | `comment_id` | string | No | Valid opaque ID | Include to update an existing comment; omit to create new | | `parent_id` | string | No | Valid opaque ID | Parent comment ID for threaded reply (single-level only). **Note:** This is the parent *comment's* opaque ID, not the workspace/share ID from the URL path `{parent_id}` | | `properties` | object | No | -- | Arbitrary key-value metadata to attach | | `reference` | object | No | See Reference Anchoring | Anchoring reference to a position in a file | | `target_id` | string | No | Valid object ID | Inline attachment on a **new** comment โ€” a single object to attach (see Comment Attachments). Ignored on update | | `target_ids` | array\ | No | Non-empty array | Inline attachments on a **new** comment โ€” multiple objects to attach, up to 25 per comment (see Comment Attachments). Ignored on update | | `reopen` | boolean | No | Reply only (`parent_id` set) | When `true`, reopens the thread as part of posting the reply, atomically. A plain reply (no `reopen`) never reopens a resolved thread. On a top-level create: `400 comment_thread_action_reply_only` | | `assign` | object | No | Reply only (`parent_id` set) | One of the body forms accepted by `POST /comments/{comment_id}/assign/` (see *Thread State: Resolve, Reopen, Assign*). Applied atomically with the reply. On a top-level create: `400 comment_thread_action_reply_only` | **Request Example -- Create a new comment:** ```bash curl -X POST "https://api.fast.io/current/comments/workspace/1234567890123456789/" \ -H "Authorization: Bearer {jwt_token}" \ -H "Content-Type: application/json" \ -d '{ "body": "Great work on this document!" }' ``` **Request Example -- Reply to a comment:** ```bash curl -X POST "https://api.fast.io/current/comments/workspace/1234567890123456789/" \ -H "Authorization: Bearer {jwt_token}" \ -H "Content-Type: application/json" \ -d '{ "body": "Thanks for the feedback!", "parent_id": "abc123opaqueid" }' ``` **Request Example -- Comment with mention and page reference:** ```bash curl -X POST "https://api.fast.io/current/comments/workspace/1234567890123456789/" \ -H "Authorization: Bearer {jwt_token}" \ -H "Content-Type: application/json" \ -d '{ "body": "Hey @[user:9876543210987654321:Jane Smith], can you review page 3?", "reference": {"type": "document", "page": 3} }' ``` **Request Example -- Update an existing comment:** ```bash curl -X POST "https://api.fast.io/current/comments/workspace/1234567890123456789/" \ -H "Authorization: Bearer {jwt_token}" \ -H "Content-Type: application/json" \ -d '{ "comment_id": "def456opaqueid", "body": "Updated: This looks great after the revision." }' ``` **Response:** ```json { "result": true, "comment": { "id": "abc123opaqueid", "entity": "xyz789opaqueid", "user_id": "1234567890123456789", "actor": { "user_id": "1234567890123456789", "kind": "agent", "agent_name": "Dobby", "name_source": "api_key_label", "credential_type": "api_key", "verified": false }, "body": "Great work on this document!", "profile_type": "workspace", "profile_id": "1234567890123456789", "mentions": [], "reactions": [], "version": 1, "created": "2025-01-15 10:30:00 UTC", "updated": "2025-01-15 10:30:00 UTC", "can_edit": true, "can_delete": true, "can_reply": true }, "attachments": [], "attachment_count": 0 } ``` **Response Fields:** | Field | Type | Description | |-------|------|-------------| | `comment` | object | The created or updated comment object (full schema above) | | `attachments` | array | **Create only** (omitted on an update). The hydrated attachments written with the new comment, returned at the top level beside `comment`, not inside it | | `attachment_count` | integer | **Create only** (omitted on an update). Number of objects attached to the new comment, at the top level beside `comment` | **Access Levels:** | Role | Access | |------|--------| | Workspace Owner/Member | Can create and edit own comments | | Workspace Guest | Can comment on (and edit their own comments on) a note they are allowed to edit; on any other file or folder, commenting needs workspace membership. View-only access is read-only | | Share Owner | Can create and edit own comments | | Share Guest | Can only post if comments are enabled on the share | **Error Responses:** | Error Code | HTTP Status | Message | Cause | |------------|-------------|---------|-------| | `1680 (Access Denied)` | 401 | "Authentication required to post comments" | User not authenticated | | `1605 (Invalid Input)` | 406 | "Invalid JSON in request body" | Malformed JSON | | `1605 (Invalid Input)` | 406 | "Comment body must be between 1 and 8192 characters" | Body length out of range | | `1605 (Invalid Input)` | 406 | "Comment text (excluding mentions) must not exceed 500 characters" | Display text too long โ€” note that a mention inside markdown code counts in full (see Mentions) | | `1605 (Invalid Input)` | 406 | "Your comment appears to contain spam content..." | Spam detected | | `1605 (Invalid Input)` | 406 | "Invalid parent comment ID format" | Malformed parent_id | | `1609 (Not Found)` | 404 | "Parent comment not found" | Referenced parent does not exist | | `1609 (Not Found)` | 404 | "Parent comment not found" | Parent is on a different entity or scope (reported the same as a missing parent) | | `1605 (Invalid Input)` | 406 | "Invalid reference data: ..." | Reference failed validation; `error.params.reason` = `anchor_too_large` when the encoded object exceeds 2048 bytes, else `anchor_invalid` (bad shape or an unknown key) | | `1605 (Invalid Input)` | 406 | "The anchor of an existing comment cannot be changed" | An edit (`comment_id` set) sent a `reference` whose `exact`, `prefix` or `suffix` differs from the stored one; `error.params.reason` = `anchor_invalid` | | `10368 (Rate Limited)` | 429 | "Too many requests, rate limit exceeded." | The standard rate-limit response (see Rate Limiting in the overview): too many comments created in a short time. Wait the number of seconds in the `Retry-After` header, then retry. Edits are not counted | | *(generated per call site)* | 409 | "Thread limit reached for this file" | Creating a new thread (a top-level, non-reply comment) would exceed the file's thread limit; `error.params.reason` = `comment_thread_limit_reached`. See *Thread Limits* under Thread State below | | *(generated per call site)* | 503 | "Thread limit could not be checked" | The thread-limit check could not complete; `error.params.reason` = `comment_thread_limit_unavailable`. Retryable | | *(generated per call site)* | 400 | "reopen must be a boolean" / "assign must be an object" | `error.params.reason` = `reopen_invalid` or `assignee_invalid` | | *(generated per call site)* | 400 | Reply-only thread action | `reopen` / `assign` sent on a top-level create; `error.params.reason` = `comment_thread_action_reply_only` | | *(generated per call site)* | 400 | Assignee refused | `assign` on a reply: `error.params.reason` = `assignee_invalid` or `assignee_no_access` (as on `assign/`) | | *(generated per call site)* | 403 | Thread action refused | `reopen` / `assign` on a reply the caller may not change, or on a File Share comment; `error.params.reason` = `comment_thread_forbidden` or `comment_thread_read_only` | | *(generated per call site)* | 409 | Thread changed | `reopen` on a suggestion thread (`comment_is_suggestion`) or a concurrent thread change that kept conflicting (`state_version_conflict`); the reply is not posted | | *(generated per call site)* | 503 | Thread state unavailable | `error.params.reason` = `comment_thread_state_unavailable`. Retryable; the reply is not posted | | `1609 (Not Found)` | 404 | "Comment not found" | Comment ID for update not found | | `1680 (Access Denied)` | 401 | "You do not have permission to edit this comment" | User is not the comment author | | `1610 (Internal Error)` | 500 | "Failed to save comment" | Internal error | **Notes:** - **Body fidelity:** The body is trimmed of leading and trailing whitespace and then stored unmodified โ€” HTML is not stripped or escaped โ€” so code, generic type parameters (`Array`), path templates (``), and structured data such as `data: {count: 5}` survive a round trip intact. Treat a returned `body` as untrusted input and escape it in your own client before rendering it as HTML. - **Content filtering:** No profanity, spam, or PII filtering is applied to bodies today. The spam-rejection response documented under Content Filtering remains part of this endpoint's contract and is not currently triggered. - **Mentions:** Mentioned users are parsed and validated against entity membership before the comment is saved. A mention that sits inside markdown code is treated as quoted text โ€” it notifies nobody and counts in full against the 500-character display cap (see *Mentions inside code*). - **Thread limits:** Each file has a limit on its threads (top-level comments, including suggestion threads); replies are never counted against it. See *Thread Limits* under *Thread State: Resolve, Reopen, Assign* below. - **Reopening and assigning on reply:** a reply body may carry `reopen` and/or `assign` (see *Request Body* above); both apply atomically with the reply. - **Updating:** Only the original author can edit. Editing populates the top-level `edited_at` timestamp on the returned comment. A text anchor (`exact` / `prefix` / `suffix`) is fixed when the comment is created: an edit may resend it unchanged or omit `reference`, but cannot change it. - **Server-managed properties:** `reactions`, `version`, `version_hash`, `edited_at`, `content_filtered`, `mentions`, `actor`, `pinned` and `reply_to` in a request's `properties` are ignored on create and on edit โ€” only the server writes them. - **Mention notifications on edit:** an edit notifies only the people it newly mentions; anyone already mentioned before the edit is not notified again. - **Creation rate:** comment creation (top-level comments and replies) is rate limited per user. An over-limit create answers `429` with a `Retry-After` header. --- ## File Share Comments ### `GET /current/comments/fileshare/{fileshare_id}/{node_id}/` ### `POST /current/comments/fileshare/{fileshare_id}/{node_id}/` `fileshare` is a third `{entity_type}` on the comment surface, used by **File Share recipients** to read and post comments on a file inside a File Share. It shares the request/response shapes of the workspace/share List and Create endpoints above โ€” the only differences are the `fileshare` discriminator and that the `{node_id}` segment is **required** (a File Share comment always targets a specific node, never the whole container). **Auth:** Required. The caller must have access to the File Share (a signed-in recipient); anonymous callers are denied. Comments left here are the File Share recipient's own comments on the node. **Path Parameters:** | Parameter | Type | Format | Description | |-----------|------|--------|-------------| | `{fileshare_id}` | string | Opaque `id_alt` (preferred) or 19-digit numeric | File Share ID: the File Share's `id_alt` (the id in its share link), or its legacy numeric profile ID | | `{node_id}` | string | Alphanumeric opaque ID | The file or folder node within the File Share. **Required** | **Request Example -- list:** ```bash curl -X GET "https://api.fast.io/current/comments/fileshare/adheih5r326qjiqvk4wfamvt4rqeh/23d4zvoprfmonyhy2wwjpoccqjaiz/" \ -H "Authorization: Bearer {jwt_token}" ``` **Request Example -- create:** ```bash curl -X POST "https://api.fast.io/current/comments/fileshare/adheih5r326qjiqvk4wfamvt4rqeh/23d4zvoprfmonyhy2wwjpoccqjaiz/" \ -H "Authorization: Bearer {jwt_token}" \ -H "Content-Type: application/json" \ -d '{"body": "Received โ€” thanks for sending this over."}' ``` The list and create responses use the same envelope and Comment Object schema as the entity-scoped endpoints above. A File Share comment carries the File Share's ID in `scope_id`; `user_id` is the recipient's account ID. **Notes:** - File Share comments are visible **read-only** in the owning workspace's node thread (`can_reply` / `can_edit` / `can_delete` are `false` there); replies and edits happen through this File Share surface. See the *List Comments* notes for the one-directional visibility rules. - The invalid-`entity_type` error message for the whole comment surface is `Entity type must be "workspace", "share", or "fileshare"`. **Error Responses:** | Error Code | HTTP Status | Message | Cause | |------------|-------------|---------|-------| | `1605 (Invalid Input)` | 406 | "Entity type must be \"workspace\", \"share\", or \"fileshare\"" | Invalid entity type | | `1680 (Access Denied)` | 401 | "Invalid entity or insufficient permissions" | Caller cannot access the File Share | | `1609 (Not Found)` | 404 | "Invalid entity or insufficient permissions" | File Share or node not found | --- ## Update Comment by ID ### `POST /current/comments/{comment_id}/update/` Edit an existing comment by its ID. Author-only. Works for **every** comment surface โ€” workspace, share, node, and File Share. **Auth:** Required (JWT). Rate limited. **Content-Type:** `application/json` **Request Body:** | Field | Type | Required | Constraints | Description | |-------|------|----------|-------------|-------------| | `body` | string | Yes | 1-8192 UTF-8 characters (raw, not bytes); display text excluding mentions max 500 UTF-8 characters โ€” a mention inside markdown code counts in full (see Mentions). Over-limit is rejected, never truncated | Replacement comment text | | `properties` | object | No | -- | Arbitrary key-value metadata to merge | | `reference` | object | No | See Reference Anchoring | Replacement anchoring reference | **Request Example:** ```bash curl -X POST "https://api.fast.io/current/comments/abc123opaqueid/update/" \ -H "Authorization: Bearer {jwt_token}" \ -H "Content-Type: application/json" \ -d '{ "body": "Updated: this looks great after the revision." }' ``` **Response:** the full updated comment object (same shape as Create or Update Comment above), with `edited_at` populated. **Error Responses:** | Error Code | HTTP Status | Message | Cause | |------------|-------------|---------|-------| | `1605 (Invalid Input)` | 406 | "Comment body must be between 1 and 8192 characters" | Body length out of range | | `1605 (Invalid Input)` | 406 | "Comment is deleted" | The comment has been deleted | | `1605 (Invalid Input)` | 406 | "Invalid reference data" | Reference failed validation; `error.params.reason` = `anchor_too_large` or `anchor_invalid` (see Reference Anchoring) | | `1605 (Invalid Input)` | 406 | "The anchor of an existing comment cannot be changed" | The `reference` changes the stored `exact`, `prefix` or `suffix`; `error.params.reason` = `anchor_invalid` | | `1609 (Not Found)` | 404 | "Comment not found" | Unknown comment ID, or the comment's surface is not visible to you | | `1680 (Access Denied)` | 401 | "You do not have permission to edit this comment" | Caller is not the comment author | **Notes:** - Only the original author can edit; mentions are re-validated from the edited body and the comment's `edited_at` timestamp is populated. - An edit can never move the comment: its entity, scope, and threading are immutable here. - Server-managed `properties` keys (`reactions`, `version`, `version_hash`, `edited_at`, `content_filtered`, `mentions`, `actor`, `pinned`, `reply_to`) supplied in the request are ignored โ€” they are written only by the server (`actor` is set from the credential that made the request). - The text anchor (`exact` / `prefix` / `suffix`) cannot change after the comment is created; resending it unchanged is accepted. - Only people the edit newly mentions are notified. --- ## Get Comment Details ### `GET /current/comments/{comment_id}/details/` Get a single comment's full details. When the comment is a root (no `parent_id`), the response carries the *Thread State Fields* described under *Comment Object* above. **Auth:** Required (JWT). Rate limited. **Query Parameters (root comments only):** | Parameter | Type | Required | Default | Constraints | Description | |-----------|------|----------|---------|-------------|-------------| | `replies_offset` | integer | No | `0` | Min: 0 | Number of replies to skip, for paging a root thread's replies beyond what a `group=threads` list page embeds | | `replies_limit` | integer | No | 50 | Max: 200 | Number of replies to return | **Request Example:** ```bash curl -X GET "https://api.fast.io/current/comments/abc123opaqueid/details/" \ -H "Authorization: Bearer {jwt_token}" ``` **Response:** ```json { "result": true, "comment": { "id": "abc123opaqueid", "entity": "xyz789opaqueid", "user_id": "1234567890123456789", "actor": { "user_id": "1234567890123456789", "kind": "human", "agent_name": null, "name_source": null, "credential_type": "session", "verified": false }, "parent_id": null, "body": "This looks great!", "profile_type": "workspace", "profile_id": "1234567890123456789", "scope_id": null, "linked_entity_type": null, "linked_entity_id": null, "external_author_email": null, "external_author_name": null, "external_author_token_id": null, "reference": null, "mentions": [], "reactions": [], "user_reaction": null, "version_hash": null, "version": 1, "edited_at": null, "created": "2025-01-15 10:30:00 UTC", "updated": "2025-01-15 10:30:00 UTC", "deleted": null, "can_edit": true, "can_delete": true, "can_reply": true, "attachment_count": 0, "attachments": [] } } ``` On a root comment, `comment` additionally carries the *Thread State Fields* above, plus `replies` (array, paged by `replies_offset` / `replies_limit`) and `replies_total` (int, the thread's total reply count). These three are omitted on a reply. **Error Responses:** | Error Code | HTTP Status | Message | Cause | |------------|-------------|---------|-------| | `1605 (Invalid Input)` | 406 | "Comment ID is required" | Missing comment ID | | `1605 (Invalid Input)` | 406 | "Invalid comment ID format" | Malformed opaque ID | | `1609 (Not Found)` | 404 | "Comment not found" | Comment does not exist | | `1680 (Access Denied)` | 401 | "You do not have permission to view this comment" | User lacks access to the entity | | *(generated per call site)* | 503 | "Comment thread state is temporarily unavailable. Please try again." | The comment's thread (or, for a reply, its root) could not be read โ€” `error.params.reason` = `comment_thread_state_unavailable`, `retryable: true` | --- ## Delete Comment ### `DELETE /current/comments/{comment_id}/delete/` Soft-delete a comment. **Recursive** -- also deletes the replies to this comment that you can access. A reply you cannot access (for example, one on a surface you have no access to) is left in place and is never named in the response. **Who can delete:** the comment's author, and workspace/share **admins or owners** (moderation โ€” admins can remove any comment on their workspace or share). File Share comments can only be deleted by their author; they are never moderator-deletable, including from the workspace view. The `can_delete` flag on every returned comment reflects this, so clients should drive the delete affordance from `can_delete`. **Auth:** Required (JWT). Rate limited. **Request Example:** ```bash curl -X DELETE "https://api.fast.io/current/comments/abc123opaqueid/delete/" \ -H "Authorization: Bearer {jwt_token}" ``` **Response:** ```json { "result": true, "message": "Comment deleted successfully", "comment": { "id": "abc123opaqueid", "entity": "xyz789opaqueid", "user_id": "1234567890123456789", "actor": { "user_id": "1234567890123456789", "kind": "human", "agent_name": null, "name_source": null, "credential_type": "session", "verified": false }, "body": "This looks great!", "profile_type": "workspace", "profile_id": "1234567890123456789", "mentions": [], "reactions": [], "version": 1, "created": "2025-01-15 10:30:00 UTC", "updated": "2025-01-16 08:00:00 UTC", "deleted": "2025-01-16 08:00:00 UTC", "can_edit": false, "can_delete": false, "can_reply": true }, "skipped": [] } ``` `skipped` lists the ids of replies you can access that could not be deleted (normally empty). Each can be deleted by its own author or by a moderator; deleting the parent again does not retry them. **Access Levels:** | Role | Access | |------|--------| | Comment Author | Can delete own comments | | Workspace/Share Owner or Admin | Can delete any comment on their workspace or share (moderation), except File Share comments | | Other Users | Denied | **Error Responses:** | Error Code | HTTP Status | Message | Cause | |------------|-------------|---------|-------| | `1605 (Invalid Input)` | 406 | "Comment ID is required" | Missing comment ID | | `1605 (Invalid Input)` | 406 | "Invalid comment ID format" | Malformed opaque ID | | `1609 (Not Found)` | 404 | "Comment not found" | Comment does not exist, or you cannot access it | | `1605 (Invalid Input)` | 406 | "Comment is already deleted" | Comment was previously deleted (only reported to a caller who can access it) | | `1680 (Access Denied)` | 401 | "You do not have permission to delete this comment" | Not author, entity owner, or admin | | `1610 (Internal Error)` | 500 | "Failed to delete comment" | Internal error | --- ## Bulk Delete Comments ### `POST /current/comments/bulk/delete/` Bulk soft-delete multiple comments. Each comment is processed independently -- partial success is possible. Each comment is deleted exactly as the single delete does it, **including its replies** that you can access. โš ๏ธ **Per-item isolation holds only when every element of `comment_ids` is a STRING.** A non-string element (a JSON number, boolean, or `null`) is not isolated to its own result entry -- it aborts the entire batch. **The abort is not a rollback:** ids are processed in array order and each delete is committed as it is made, so every id BEFORE the offending element is already deleted and stays deleted. Processing stops at the offending element, and you get a generic `500` instead of the per-item `results` report -- so you cannot tell from the response which ids went through. Nothing is deleted only when the offending element is the first one. Serialise every id as a JSON string. **Service accounts.** Every comment in the batch is checked before anything is deleted. If any of them is one a service account is refused on (a comment on a share or a File Share), the whole request fails with `403` and `error.params.reason` = `service_account_not_permitted`, and **nothing** in the batch is deleted. Otherwise the batch is processed per item as above. **Auth:** Required (JWT). Rate limited. **Content-Type:** `application/json` **Request Body:** | Field | Type | Required | Constraints | Description | |-------|------|----------|-------------|-------------| | `comment_ids` | array\ | Yes | Max 100 items | Array of comment opaque IDs to delete | **Request Example:** ```bash curl -X POST "https://api.fast.io/current/comments/bulk/delete/" \ -H "Authorization: Bearer {jwt_token}" \ -H "Content-Type: application/json" \ -d '{"comment_ids": ["abc123opaqueid", "def456opaqueid", "ghi789opaqueid"]}' ``` **Response:** ```json { "result": true, "success": true, "deleted_count": 3, "failed_count": 0, "total_count": 3, "results": { "abc123opaqueid": {"id": "abc123opaqueid", "success": true, "error": null}, "def456opaqueid": {"id": "def456opaqueid", "success": true, "error": null}, "ghi789opaqueid": {"id": "ghi789opaqueid", "success": true, "error": null} }, "skipped": [] } ``` **Response Fields:** | Field | Type | Description | |-------|------|-------------| | `success` | bool | `true` only if all comments were deleted (`failed_count === 0`) | | `deleted_count` | int | Number of successfully deleted comments | | `failed_count` | int | Number that failed to delete | | `total_count` | int | Total number of IDs provided | | `results` | object | Per-comment results keyed by comment ID | | `results.{id}.success` | bool | Whether this comment was deleted | | `results.{id}.error` | string\|null | Error message if failed, null on success | | `skipped` | array\ | Ids of replies you can access that a cascade could not delete (normally empty); each can be deleted by its own author or by a moderator. Replies you cannot access are left in place and never listed | **Per-Comment Error Messages:** | Error | Cause | |-------|-------| | `"Invalid comment ID format"` | Not a valid opaque ID | | `"Comment not found"` | No comment with this ID | | `"Comment already deleted"` | Previously soft-deleted (only reported when you can access the comment) | | `"Permission denied"` | User is not author, entity owner, or admin | | `"Failed to delete comment"` | Internal error | | `"Processing error"` | Unhandled error while processing this comment | **Request-Level Error Responses:** | Error Code | HTTP Status | Message | Cause | |------------|-------------|---------|-------| | `1605 (Invalid Input)` | 406 | "Invalid JSON request body" | Malformed JSON | | `1605 (Invalid Input)` | 406 | "comment_ids array is required" | Missing or non-array field | | `1605 (Invalid Input)` | 406 | "comment_ids array cannot be empty" | Empty array | | `1605 (Invalid Input)` | 406 | "Maximum 100 comments can be deleted at once" | Exceeds bulk limit | **Replies:** like single delete, bulk delete also deletes the replies you can access. An id already deleted earlier in the same batch (as a reply, or listed twice) is reported as a success. --- ## Add or Change Reaction ### `POST /current/comments/{comment_id}/reactions/` Add an emoji reaction to a comment. One reaction per user per comment -- sending a new reaction replaces any previous one. **Auth:** Required (JWT). Rate limited. **Content-Type:** `application/json` **Request Body:** | Field | Type | Required | Constraints | Description | |-------|------|----------|-------------|-------------| | `emoji` | string | Yes | Single emoji character; max 2 UTF-8 characters; must match Unicode emoji ranges | Emoji to react with | **Request Example:** ```bash curl -X POST "https://api.fast.io/current/comments/abc123opaqueid/reactions/" \ -H "Authorization: Bearer {jwt_token}" \ -H "Content-Type: application/json" \ -d '{"emoji": "๐Ÿ‘"}' ``` **Response:** ```json { "result": true, "reactions": {"๐Ÿ‘": 3, "โค๏ธ": 1}, "user_reaction": "๐Ÿ‘" } ``` **Response Fields:** | Field | Type | Description | |-------|------|-------------| | `reactions` | object | Map of emoji to total reaction count across all users (`[]` when there are none) | | `user_reaction` | string\|null | The current user's active reaction emoji | **Error Responses:** | Error Code | HTTP Status | Message | Cause | |------------|-------------|---------|-------| | `1605 (Invalid Input)` | 406 | "Comment ID is required" | Missing comment ID | | `1605 (Invalid Input)` | 406 | "Invalid comment ID format" | Malformed opaque ID | | `1609 (Not Found)` | 404 | "Comment not found" | Comment does not exist or is deleted | | `1680 (Access Denied)` | 401 | "You do not have permission to react to this comment" | User lacks entity access | | `1605 (Invalid Input)` | 406 | "emoji parameter is required" | Missing or non-string emoji | | `1605 (Invalid Input)` | 406 | "Invalid emoji character" | Does not match Unicode emoji pattern | | `1605 (Invalid Input)` | 406 | "Only single emoji allowed" | String exceeds 2 UTF-8 characters | | `1610 (Internal Error)` | 500 | "Failed to save reaction" | Internal error | --- ## Remove Reaction ### `DELETE /current/comments/{comment_id}/reactions/` Remove an emoji reaction from a comment. **Auth:** Required (JWT). Rate limited. **Content-Type:** `application/json` **Request Body:** | Field | Type | Required | Constraints | Description | |-------|------|----------|-------------|-------------| | `emoji` | string | No | Must match Unicode emoji ranges if provided | Specific emoji to remove. Omit to remove any reaction by the current user | **Request Example -- Remove specific emoji:** ```bash curl -X DELETE "https://api.fast.io/current/comments/abc123opaqueid/reactions/" \ -H "Authorization: Bearer {jwt_token}" \ -H "Content-Type: application/json" \ -d '{"emoji": "๐Ÿ‘"}' ``` **Request Example -- Remove any reaction:** ```bash curl -X DELETE "https://api.fast.io/current/comments/abc123opaqueid/reactions/" \ -H "Authorization: Bearer {jwt_token}" \ -H "Content-Type: application/json" \ -d '{}' ``` **Response:** ```json { "result": true, "reactions": {"โค๏ธ": 1}, "user_reaction": null } ``` **Response Fields:** | Field | Type | Description | |-------|------|-------------| | `reactions` | object | Updated map of emoji to total reaction count (`[]` when none remain) | | `user_reaction` | string\|null | Current user's reaction after removal (null if removed) | **Error Responses:** | Error Code | HTTP Status | Message | Cause | |------------|-------------|---------|-------| | `1605 (Invalid Input)` | 406 | "Comment ID is required" | Missing comment ID | | `1605 (Invalid Input)` | 406 | "Invalid comment ID format" | Malformed opaque ID | | `1609 (Not Found)` | 404 | "Comment not found" | Comment does not exist or is deleted | | `1680 (Access Denied)` | 401 | "You do not have permission to react to this comment" | User lacks entity access | | `1605 (Invalid Input)` | 406 | "emoji must be a string" | Non-string emoji parameter | | `1605 (Invalid Input)` | 406 | "Invalid emoji character" | Does not match Unicode emoji pattern | | `1610 (Internal Error)` | 500 | "Failed to remove reaction" | Internal error | **Notes:** - Removing a reaction that does not exist returns success (idempotent). - A "removed" event is triggered only when a reaction was actually removed. --- ## Unlink Comment ### `POST /current/comments/{comment_id}/unlink/` Clear a comment's link to the entity it was linked to. The comment itself is neither deleted nor moved โ€” only its link is cleared. **Auth:** Required (JWT). Rate limited. Authorization re-runs against the comment's **own scope** โ€” the workspace, share, or File Share that owns it โ€” not against whatever context you are calling from. On top of that, only the comment's author, or a workspace/share admin or owner (moderation; never on a File Share comment), may unlink it. **Request body (JSON, optional):** | Field | Type | Required | Description | |-------|------|----------|-------------| | `entity_id` | string | No | The entity the comment was linked to. Supply it to make a repeat call **idempotent**: if the comment is already unlinked, the call returns success instead of the "not linked" error below. Without it, unlinking an already-unlinked comment is an error. | **Response:** the updated comment, in the standard shape (see *Comment Object*), with its link cleared. **Error Responses:** | Error Code | HTTP Status | Message | Cause | |------------|-------------|---------|-------| | `152022` | 406 | "Comment ID is required" | `{comment_id}` missing | | `176691` | 406 | "Invalid comment ID format" | `{comment_id}` is not a valid opaque ID | | `123525`, `100562` or `148282` | 404 | "Comment not found" | Unknown comment ID โ€” distinct load checks, each with its own code | | `148282` | 406 | "Comment is not linked to any entity" | Nothing to clear, and no `entity_id` was sent | | varies per call site โ€” read `error.code` from the response | 401, or 404 when the scope hides the comment | "You do not have permission to unlink this comment", or "Comment not found" | The access check on the comment's own scope refused. The code is assigned by whichever check failed, and the status follows that check's category | | `123757` | 401 | "You do not have permission to unlink this comment" | You may write comments on the scope but are neither the comment's author nor a moderator | | `148282` or `151137` | 500 | "Failed to unlink comment" | Internal error โ€” two distinct failure paths | **Notes:** - A comment-unlinked event fires only when the comment actually had a link to clear. --- ## Thread State: Resolve, Reopen, Assign A **thread** is a root comment (one with no `parent_id`) plus its replies. Thread state lives on the root โ€” open or resolved, a single assignee, and `state_version`, a counter that increments on every change (see *Thread State Fields* under *Comment Object* above). Replies carry no thread state: sending a reply's ID to any of these three routes returns `400 comment_not_root`. All three routes: - take a JSON body, the same encoding as `POST /comments/...`; - return `{"result": true, "comment": {...}}`, the comment row with its thread fields; - require that you can comment on the item. Comments that belong to a File Share are read- and post-only. All three routes refuse them with `403 comment_thread_read_only`. ### Resolve #### `POST /current/comments/{comment_id}/resolve/` Marks the thread resolved. **Auth:** Required (JWT). Rate limited. **Content-Type:** `application/json` **Request Body (optional):** | Field | Type | Required | Description | |-------|------|----------|-------------| | `if_state_version` | integer | No | Guards the write against a change you haven't seen yet โ€” see *Concurrency* below | Who may resolve: the thread's author, the current assignee, anyone who can edit the item, or a workspace or share admin. **Request Example:** ```bash curl -X POST "https://api.fast.io/current/comments/abc123opaqueid/resolve/" \ -H "Authorization: Bearer {jwt_token}" \ -H "Content-Type: application/json" \ -d '{}' ``` **Response:** `{"result": true, "comment": {...}}`, the updated thread. **Idempotency:** resolving an already-resolved thread returns it unchanged, with `200`. `resolved_by` and `resolved_at` are not restamped, and no event fires. ### Reopen #### `POST /current/comments/{comment_id}/reopen/` Reopens a resolved thread. Same body, permissions and idempotency as Resolve. A "Resolve" undo is a reopen call with `if_state_version` set to the `state_version` that the resolve returned. **Request Example:** ```bash curl -X POST "https://api.fast.io/current/comments/abc123opaqueid/reopen/" \ -H "Authorization: Bearer {jwt_token}" \ -H "Content-Type: application/json" \ -d '{"if_state_version": 3}' ``` **Response:** `{"result": true, "comment": {...}}`. ### Assign #### `POST /current/comments/{comment_id}/assign/` Sets, replaces, or clears the thread's single assignee. Anyone who can comment on the item may assign. **Auth:** Required (JWT). Rate limited. **Content-Type:** `application/json` **Request Body** (exactly one form, plus an optional `if_state_version`): | Body | Meaning | |------|---------| | `{"assignee_user_id": ""}` | Assign a person (or a service account) | | `{"assignee_user_id": "", "assignee_agent": {"credential_type": "oauth"\|"api_key", "credential_id": ""}}` | Assign a person, to be handled by one of their own connected agents. The credential must be a live connection that belongs to that user, and its current scopes must reach the thread's surface: the share, for a thread started on a share (even when you reach it through the share's workspace), otherwise the workspace (an API key scoped elsewhere, read-only, or an OAuth connection restricted to other places is refused). Its display name comes from the connection, not from the request | | `{"assignee": "self"}` | Assign yourself. When you call with an agent connection, the assignment is to you, via that connection | | `{"assignee": "ripley"}` | Assign Ripley, on your behalf | | `{"clear": true}` | Unassign | **Request Example:** ```bash curl -X POST "https://api.fast.io/current/comments/abc123opaqueid/assign/" \ -H "Authorization: Bearer {jwt_token}" \ -H "Content-Type: application/json" \ -d '{"assignee_user_id": "1234567890123456789"}' ``` **Response:** `{"result": true, "comment": {...}}`. **Notes:** - Calls are idempotent. Assigning the current assignee, or clearing a thread that has no assignee, returns the thread unchanged. - An assignee who cannot see the item gets `400 assignee_no_access`. - A malformed body, an unknown connection, a connection that belongs to someone else, or one whose scopes do not reach this workspace or share gets `400 assignee_invalid`. - A service account cannot assign Ripley (a Ripley run is billed to a person): `400 assignee_invalid`. - `unassign` is the same call as `assign` with `{"clear": true}`. - To find who can be assigned (or mentioned) on a given file, use `GET /current/workspace/{workspace_id}/storage/{node_id}/principals/` (see [`llms/storage.txt`](https://api.fast.io/current/llms/storage/)). ### Concurrency (`if_state_version`) `if_state_version` is optional on resolve, reopen and assign. When it is present and the thread has changed since you read it, the route returns `409` with `error.params.reason` = `state_version_conflict` and `error.params.state_version` set to the current value. Reload and retry. If the thread is already in the state you asked for, the call succeeds unchanged even with a stale `if_state_version` โ€” that makes a retry after a lost response safe. ### Errors Gate on HTTP status, then `error.params.reason`. | Error Code | HTTP Status | `reason` | When | |------------|-------------|----------|------| | *(generated per call site)* | 400 | `comment_not_root` | The ID is a reply | | *(generated per call site)* | 400 | `assignee_invalid` | Bad assign body or agent connection | | *(generated per call site)* | 400 | `assignee_no_access` | The assignee cannot see the item | | *(generated per call site)* | 403 | `comment_thread_read_only` | File Share comment | | *(generated per call site)* | 403 | `comment_thread_forbidden` | You may not resolve or reopen this thread | | *(generated per call site)* | 409 | `state_version_conflict` | `if_state_version` mismatch | | *(generated per call site)* | 503 | `comment_thread_state_unavailable` | Temporary; retryable | ### Thread Limits Each file has a limit on its **threads** (top-level comments, and suggestion threads too). Replies are never counted. - Free: 100 threads per file. - Paid plans: 1,000. - Top plans: unlimited. When a new thread would exceed the limit, creating it (see *Create or Update Comment* above) fails with `409 comment_thread_limit_reached`. If the limit cannot be checked, it fails with `503 comment_thread_limit_unavailable` (retryable). See *Thread Grouping* under *List Comments* above for the `limits` block that reports usage against the cap. --- ## Suggestions A **suggestion** is a proposed edit to a workspace note: a comment thread with `kind: "suggestion"` whose root carries `suggestion{}`. The anchor (the root's `reference` `exact` / `prefix` / `suffix`) is the text it changes. Suggestions exist on **workspace notes only**. ### Create a suggestion ``` POST /current/comments/workspace/{workspace_id}/{note_id}/ ``` The comment create body, plus: | Field | Type | Required | Description | |-------|------|----------|-------------| | `kind` | string | Yes | `"suggestion"` (`"comment"` or omitted creates a plain comment) | | `suggestion` | object | Yes | `{op, replacement}`: `op` is `"replace"` or `"delete"`; `replacement` is the new text (`""` for a delete). The anchor's `exact` needs at least one non-whitespace character; a replace's `replacement` must be non-empty, stay within one block (no line break), differ from `exact`, and carry no control character (U+0000-U+001F except tab, DEL), zero-width space (U+200B) or line / paragraph separator (U+2028, U+2029) | | `reference` | object | Yes | The anchor; `exact` is the text the suggestion changes | | `body` | string | No | The rationale. May be empty | A suggestion is never a reply (`parent_id`), never an edit (`comment_id`) and takes no attachments (`target_ids` / `target_id`): those are `400` with `error.params.reason` = `suggestion_invalid`, as is an invalid `suggestion` object, or a `suggestion` object sent without `kind: "suggestion"`. A surface other than a workspace note is `400 suggestion_unsupported_surface`. A suggestion counts against the file's thread limit like any root comment. The response `comment` is the root row with its thread fields and `suggestion{}`. `POST /current/comments/workspace/{workspace_id}/{note_id}/suggestions/bulk/` creates up to 50 in one request: `{items: [{reference, suggestion, body?}]}` returns `{created: [...], failed: [{index, reason}]}`. ### Reading suggestions Suggestion threads are **hidden** from comment lists, details, counts and search unless you opt in with `include_suggestions=true` (or `kind=suggestion` on a list). Opted in, each suggestion root row carries: | Field | Type | Description | |-------|------|-------------| | `suggestion.op` | string | `"replace"` or `"delete"` | | `suggestion.replacement` | string | The proposed text | | `suggestion.state` | string | `pending`, `accepting` (an accept holds it), `accepted`, `rejected` or `withdrawn` | | `suggestion.state_version` | integer | The suggestion's own revision (separate from the thread's `state_version`); send it as `if_state_version` | | `suggestion.decided_by` / `decided_at` | object\|null / string\|null | Who decided it, and when | | `suggestion.applied_version_id` | string\|null | The note version an accept produced | | `can_accept` / `can_reject` | boolean | Whether the caller may accept (edit rights on the note) or reject it now: false once decided, while another accept holds it, and on a deleted root (`group=threads` and `details/` rows) | | `resolved` / `resolved_by` / `resolved_at` | | A suggestion root reads as resolved once decided (accepted, rejected or withdrawn), by its decider | | `can_edit` | boolean | False once the suggestion may no longer be edited (accepting, decided, or a headless accept was sent) | In `counts`, an open suggestion (`pending` or `accepting`) counts in `open` and `pending_suggestions`; a decided one counts in `done`. `status=open` / `status=resolved` judge a suggestion by its own state. Workspace search (`GET /current/workspace/{workspace_id}/search/`) returns suggestion comments only with `include_suggestions=true`. ### Edit, withdraw, reject, undo - **Edit** โ€” `POST /current/comments/{comment_id}/update/` with `suggestion: {op?, replacement?}` (merged over the stored payload) and/or `body` (the rationale; omit it to keep the stored one). Author only, and only while the suggestion is `pending`: while an accept holds it, once it is decided, or once a headless accept was sent, the edit is `409 suggestion_not_pending`. `if_state_version` pins the suggestion's `state_version` (`409 state_version_conflict`) on every edit, a rationale-only one too; every edit advances it. The comment and the suggestion change together or not at all. A `body` that is not a string is `400`. - **Withdraw** โ€” `POST /current/comments/{comment_id}/suggestion/withdraw/`: the author takes it back. - **Reject** โ€” `POST /current/comments/{comment_id}/suggestion/reject/`: anyone who may resolve the thread. - **Undo** โ€” `POST /current/comments/{comment_id}/suggestion/undo/`: the decider returns a rejected or withdrawn suggestion to `pending`. Undoing an accept needs the editor (`409 suggestion_needs_editor`). `withdraw`, `reject` and `undo` accept `if_state_version`; `reject` and `withdraw` also accept `confirm_unsettled` (boolean, default `false`; see below โ€” any other type is `400 suggestion_invalid`). Replies, reactions and mentions on a suggestion thread work like on any comment. **An unsettled headless accept blocks reject and withdraw.** Once a headless accept has sent the edit to the note and no `applied` answer has been recorded, the suggestion is *unsettled*: the edit **may already be in the note**. This lasts after the accept's hold expires and the suggestion reads `pending` again โ€” expiry alone never settles it. While it is unsettled, `reject` and `withdraw` answer `409` with `error.params.reason` = `suggestion_apply_unknown`, `retryable: true`, and `retry_after` while a hold remains. Two ways forward: **accept again** in headless mode (the same `idempotency_key` while the hold lasts, any key once it has expired) โ€” only an `applied` answer settles the suggestion, as accepted; or **check the note** yourself and, if you still want to decide, send the reject or withdraw again with `"confirm_unsettled": true`. That decides as usual even though the edit may be in the note (the response carries `"unsettled_acknowledged": true`); remove the edit from the note yourself if it landed. `confirm_unsettled` never overrides a live hold: while one remains the reject or withdraw still answers `409 suggestion_not_pending` with `retry_after`, so wait for it to end. An editor-mode accept that confirms also settles the suggestion (as accepted). No other accept answer (`suggestion_stale`, `suggestion_apply_unknown`, ...) proves the earlier send was not applied, so none settles it. ### Delete Deleting the root ends the suggestion: it can no longer be decided or edited, and it leaves lists and search like any deleted comment. A read that includes deleted comments (`include_deleted`, `details/`) can still show it, with `can_accept` / `can_reject` false. Deleting the workspace removes its suggestions. --- ## Accept a Suggestion ``` POST /current/comments/{comment_id}/suggestion/accept/ ``` Accept a suggested edit on a workspace note. `{comment_id}` is the suggestion's thread (root comment). **Auth required.** You need edit rights on the note; agent platform tokens cannot accept. **Request body (JSON):** | Field | Type | Required | Description | |-------|------|----------|-------------| | `mode` | string | No | `editor` (default): you hold the note open and apply the edit yourself, then call `confirm`. `headless`: Fastio applies the edit for you. | | `idempotency_key` | string | No | Headless only. 1-128 characters of `A-Z a-z 0-9 . _ : -`, chosen by you per accept attempt. Send the SAME key when you retry that attempt. | | `accept_token` | string | No | Editor only. To resume your own accept, send the `accept_token` it returned. | | `if_state_version` | integer | No | The suggestion's `state_version` you last saw; a mismatch is `409 state_version_conflict`. | **One accept at a time.** An accept holds the suggestion for a 90-second lease (renewed while a headless accept runs) plus a 25-second grace, so up to 116 seconds after its last renewal; `retry_after` always says when the hold ends. While it is held, any OTHER accept attempt โ€” including your own with a different `idempotency_key`, with none, or in the other mode โ€” gets `409` with `error.params.reason` = `suggestion_not_pending`, `retryable: true` and `retry_after`. Only a retry of the same attempt (same `idempotency_key`, or the editor's own `accept_token`) picks the held accept back up. **Headless outcomes:** | HTTP | `reason` | Meaning | What to do | |------|----------|---------|------------| | 200 | โ€” | Applied. Body `{comment, state: "accepted", applied_version_id}` | Done. Repeating the accept returns the same result. | | 409 | `suggestion_stale` | The anchored text no longer matches, so this request applied nothing. An earlier send of the same suggestion may still have changed the note, so the hold is kept until it expires (`retry_after`) and the suggestion stays unsettled | Check the note. Once the hold ends (`retry_after`), reject it with `confirm_unsettled: true`, or create a new suggestion | | 409 | `suggestion_needs_editor` | The note needs an editor to apply this suggestion (for example, it is open in an editor that cannot apply it for you), and nothing was applied: this was the first send of the attempt. `editor_reason` says why; `retryable` says whether trying again may work. The hold is released and the suggestion is `pending` again, not unsettled | If `retryable` is true, accept again after a short wait (about 10 seconds). Otherwise open the note and accept it in the editor | | 409 | `suggestion_not_pending` | Another accept holds it, or it was already decided | Re-read the suggestion; retry after `retry_after` | | 409 | `suggestion_lease_invalid` | The accept was taken over before anything was sent | Accept again | | 503 | `suggestion_apply_unknown` | The edit was sent (by this request or an earlier one of the same attempt) and MAY have been applied. Every outcome other than applied or stale after sending is reported this way (timeouts, outages, storage failures, refusals, and the note being busy or needing an editor when an earlier send of the same attempt may have applied; `editor_reason` is included when known), except a first send that applied nothing (`suggestion_needs_editor`, above) or that the service refused before applying anything (`suggestion_apply_unavailable` or `suggestion_accept_forbidden`, below) | Re-read the note. Retry with the **same** `idempotency_key` โ€” the retry is answered from the first attempt's record and never applies twice โ€” or wait `retry_after` seconds for the hold to expire | | 503 | `suggestion_apply_unavailable` | Nothing was applied. Either this request sent nothing and no earlier send of this attempt was known to it (the service is unavailable, or the suggestion could not be read or written; an overlapping retry of the same attempt may still be in flight), or the service refused the edit before applying any of it (`retryable: false`, a server-side problem): the hold is released, the suggestion is `pending` again and not unsettled, so `reject` and `withdraw` work as usual | Retry later with the same `idempotency_key` if `retryable` is true. When it is false, retrying will not help until the problem is fixed: accept in the editor, or try again later | | 403 | `suggestion_accept_forbidden` | You cannot edit this note, or your edit rights could not be confirmed when the edit was sent. Nothing was applied; the suggestion is `pending` again | โ€” | **Every successful accept response carries `comment`** โ€” the suggestion's root row with its `suggestion{}` (the leased replacement included) โ€” in editor and headless mode, on a first accept, on a resume of the same attempt and on a repeat of an accept that already applied. **Editor mode** returns `{comment, state: "accepting", accept_token, lease_expires, lease_seconds, apply_generation}`. Apply the edit in your editor, save, then call `POST /current/comments/{comment_id}/suggestion/confirm/` with `{accept_token, applied_version_id}` (the saved version of this note), or `POST /current/comments/{comment_id}/suggestion/release/` with `{accept_token}` to give it back. A headless accept whose edit was sent cannot be released by anyone: `release` answers `503 suggestion_apply_unknown` with `retry_after`, and the hold ends when it is confirmed or expires. --- ## Suggestion Decisions: Withdraw, Reject, Undo, Confirm, Release These routes act on a suggestion thread on a workspace note. `{comment_id}` is the suggestion's root comment. Every route takes a JSON object body (an empty body is `{}`) and answers `{"result": true, "comment": {...}, "state": "..."}`: `comment` is the root row with its thread fields and `suggestion{}`, and `state` is the suggestion's state after the call. Each route first checks that you can comment on the note, so the common errors at the end of each table apply to every one. ### Withdraw a Suggestion #### `POST /current/comments/{comment_id}/suggestion/withdraw/` The suggestion's author takes it back: `pending` โ†’ `withdrawn`. **Auth:** Required (JWT). Rate limited. **Content-Type:** `application/json` **Who may call it:** The suggestion's author only (the author of its root comment). You must be able to comment on the note. **Request Body:** | Field | Type | Required | Description | |-------|------|----------|-------------| | `if_state_version` | integer | No | The suggestion's `state_version` you last saw (not the thread's); a mismatch is `409 state_version_conflict`. Must be a non-negative integer (otherwise `400`) | | `confirm_unsettled` | boolean | No | Default `false`. `true` decides even while a headless accept that was sent is unsettled (its edit may already be in the note โ€” check the note first); the response then carries `unsettled_acknowledged: true`. Any other type is `400 suggestion_invalid` | **Request Example:** ```bash curl -X POST "https://api.fast.io/current/comments/abc123opaqueid/suggestion/withdraw/" \ -H "Authorization: Bearer {jwt_token}" \ -H "Content-Type: application/json" \ -d '{}' ``` **Response:** `{"result": true, "comment": {...}, "state": "withdrawn"}` โ€” `comment` is the root row with its thread fields and `suggestion{}`. Adds `"unsettled_acknowledged": true` when `confirm_unsettled` decided over an unsettled headless accept. - **Idempotent:** withdrawing again returns the suggestion unchanged (`200`), and no event fires. - **Event:** `comment_suggestion_decided` (decision `withdrawn`) on a real change. **Errors** (gate on HTTP status, then `error.params.reason`): | Error Code | HTTP Status | `reason` | When | |------------|-------------|----------|------| | *(generated per call site)* | 400 | โ€” ("if_state_version must be a non-negative integer") | `if_state_version` is not a non-negative integer | | *(generated per call site)* | 400 | `suggestion_invalid` | `confirm_unsettled` is not a boolean | | *(generated per call site)* | 403 | `suggestion_forbidden` | You are not the suggestion's author | | *(generated per call site)* | 409 | `suggestion_apply_unknown` | A headless accept was sent and never confirmed, so its edit may already be in the note (also after its hold expired). `retryable: true`, `state`, and `retry_after` while a hold remains. Check the note, then send `confirm_unsettled: true`, or accept again | | *(generated per call site)* | 409 | `suggestion_not_pending` | An accept holds the suggestion: `retryable: true`, `retry_after`. Or it was already decided (by someone else, or to another state): `state` | | *(generated per call site)* | 409 | `state_version_conflict` | `if_state_version` mismatch, or a concurrent change; `state_version` and `state` give the current values | | *(generated per call site)* | 400 | โ€” ("Invalid comment ID format") | The `{comment_id}` path segment is not a comment id | | *(generated per call site)* | 401 / 404 / 406 | โ€” ("Invalid entity or insufficient permissions") | You cannot comment where the suggestion lives; the status follows the access check (401 access denied, 404 not found, 406 otherwise) | | *(generated per call site)* | 404 | โ€” ("Comment not found") | Unknown or deleted comment | | *(generated per call site)* | 403 | `comment_thread_read_only` | A File Share comment | | *(generated per call site)* | 400 | `comment_not_root` | The id is a reply, not the suggestion root | | *(generated per call site)* | 400 | โ€” ("The request body must be a JSON object") | The body is not a JSON object | | *(generated per call site)* | 400 | `comment_not_suggestion` | The thread is not a suggestion | | *(generated per call site)* | 400 | `suggestion_unsupported_surface` | The suggestion is not on a workspace note | | *(generated per call site)* | 404 | โ€” ("The note this suggestion belongs to no longer exists") | The note was purged | | *(generated per call site)* | 503 | `comment_thread_state_unavailable` | The thread state could not be read; `retryable: true` | | *(generated per call site)* | 503 | `suggestion_state_unavailable` | The suggestion could not be read or written; `retryable: true` | ### Reject a Suggestion #### `POST /current/comments/{comment_id}/suggestion/reject/` Reject a pending suggestion: `pending` โ†’ `rejected`. **Auth:** Required (JWT). Rate limited. **Content-Type:** `application/json` **Who may call it:** Anyone who may resolve the thread: its author, the current assignee, anyone who can edit the note, or a workspace admin. You must be able to comment on the note. **Request Body:** | Field | Type | Required | Description | |-------|------|----------|-------------| | `if_state_version` | integer | No | The suggestion's `state_version` you last saw (not the thread's); a mismatch is `409 state_version_conflict`. Must be a non-negative integer (otherwise `400`) | | `confirm_unsettled` | boolean | No | Default `false`. `true` decides even while a headless accept that was sent is unsettled (its edit may already be in the note โ€” check the note first); the response then carries `unsettled_acknowledged: true`. Any other type is `400 suggestion_invalid` | **Request Example:** ```bash curl -X POST "https://api.fast.io/current/comments/abc123opaqueid/suggestion/reject/" \ -H "Authorization: Bearer {jwt_token}" \ -H "Content-Type: application/json" \ -d '{"if_state_version": 2}' ``` **Response:** `{"result": true, "comment": {...}, "state": "rejected"}` โ€” `comment` is the root row with its thread fields and `suggestion{}`. Adds `"unsettled_acknowledged": true` when `confirm_unsettled` decided over an unsettled headless accept. - **Idempotent:** a repeat by the same caller returns the suggestion unchanged (`200`), and no event fires. A suggestion someone else already decided is `409 suggestion_not_pending`. - **Event:** `comment_suggestion_decided` (decision `rejected`) on a real change. **Errors** (gate on HTTP status, then `error.params.reason`): | Error Code | HTTP Status | `reason` | When | |------------|-------------|----------|------| | *(generated per call site)* | 400 | โ€” ("if_state_version must be a non-negative integer") | `if_state_version` is not a non-negative integer | | *(generated per call site)* | 400 | `suggestion_invalid` | `confirm_unsettled` is not a boolean | | *(generated per call site)* | 403 | `suggestion_forbidden` | You may not resolve this thread | | *(generated per call site)* | 409 | `suggestion_apply_unknown` | A headless accept was sent and never confirmed, so its edit may already be in the note (also after its hold expired). `retryable: true`, `state`, and `retry_after` while a hold remains. Check the note, then send `confirm_unsettled: true`, or accept again | | *(generated per call site)* | 409 | `suggestion_not_pending` | An accept holds the suggestion: `retryable: true`, `retry_after`. Or it was already decided (by someone else, or to another state): `state` | | *(generated per call site)* | 409 | `state_version_conflict` | `if_state_version` mismatch, or a concurrent change; `state_version` and `state` give the current values | | *(generated per call site)* | 400 | โ€” ("Invalid comment ID format") | The `{comment_id}` path segment is not a comment id | | *(generated per call site)* | 401 / 404 / 406 | โ€” ("Invalid entity or insufficient permissions") | You cannot comment where the suggestion lives; the status follows the access check (401 access denied, 404 not found, 406 otherwise) | | *(generated per call site)* | 404 | โ€” ("Comment not found") | Unknown or deleted comment | | *(generated per call site)* | 403 | `comment_thread_read_only` | A File Share comment | | *(generated per call site)* | 400 | `comment_not_root` | The id is a reply, not the suggestion root | | *(generated per call site)* | 400 | โ€” ("The request body must be a JSON object") | The body is not a JSON object | | *(generated per call site)* | 400 | `comment_not_suggestion` | The thread is not a suggestion | | *(generated per call site)* | 400 | `suggestion_unsupported_surface` | The suggestion is not on a workspace note | | *(generated per call site)* | 404 | โ€” ("The note this suggestion belongs to no longer exists") | The note was purged | | *(generated per call site)* | 503 | `comment_thread_state_unavailable` | The thread state could not be read; `retryable: true` | | *(generated per call site)* | 503 | `suggestion_state_unavailable` | The suggestion could not be read or written; `retryable: true` | ### Undo a Suggestion Decision #### `POST /current/comments/{comment_id}/suggestion/undo/` Return a decided suggestion to `pending`. There is no server-side time window. **Auth:** Required (JWT). Rate limited. **Content-Type:** `application/json` **Who may call it:** The user who decided it only. Undoing an **accepted** suggestion also needs edit rights on the note. **Request Body:** | Field | Type | Required | Description | |-------|------|----------|-------------| | `if_state_version` | integer | No | The suggestion's `state_version` you last saw. Without it the undo is still pinned to the state and `state_version` the request read, so a suggestion that changed underneath is `409 state_version_conflict`, never undone. Must be a non-negative integer (otherwise `400`) | | `mode` | string | No | `editor` (default) or `headless`. Undoing an accepted suggestion needs `editor` | | `reverted_version_id` | string | To undo an accepted suggestion | The note version your editor saved after applying the inverse edit. It must be a version of this note (at most 128 characters); it is stored with the suggestion. Not needed for a rejected or withdrawn suggestion | **Request Example:** ```bash curl -X POST "https://api.fast.io/current/comments/abc123opaqueid/suggestion/undo/" \ -H "Authorization: Bearer {jwt_token}" \ -H "Content-Type: application/json" \ -d '{"mode": "editor", "reverted_version_id": "{version_id}"}' ``` **Response:** `{"result": true, "comment": {...}, "state": "pending"}`. - **Rejected or withdrawn:** metadata only, in either mode, with no body needed. - **Accepted:** Fastio never reverts the text. Apply the inverse edit in your editor, save, then send that save's version as `reverted_version_id`. Undoing an accept starts a new accept attempt: an accept key used before the undo is a new attempt afterwards. - **Idempotent:** a suggestion that is already `pending` is returned unchanged. - **Event:** `comment_suggestion_reverted` on a real change. **Errors** (gate on HTTP status, then `error.params.reason`): | Error Code | HTTP Status | `reason` | When | |------------|-------------|----------|------| | *(generated per call site)* | 400 | โ€” ("if_state_version must be a non-negative integer") | `if_state_version` is not a non-negative integer | | *(generated per call site)* | 400 | `suggestion_invalid` | `mode` is not `editor` or `headless`; or `reverted_version_id` is too long or is not a version of this note | | *(generated per call site)* | 409 | `suggestion_needs_editor` | Undoing an accepted suggestion with `mode: headless` or without `reverted_version_id`. `editor_reason` = `undo_requires_editor`, `retryable: false` | | *(generated per call site)* | 403 | `suggestion_undo_forbidden` | You did not decide this suggestion, or it is accepted and you cannot edit the note | | *(generated per call site)* | 409 | `suggestion_not_pending` | An accept holds the suggestion: `retryable: true`, `retry_after` | | *(generated per call site)* | 409 | `state_version_conflict` | The suggestion changed since the request read it, or `if_state_version` mismatch; `state_version` and `state` give the current values | | *(generated per call site)* | 503 | `suggestion_state_unavailable` | The `reverted_version_id` lookup failed; `retryable: true` | | *(generated per call site)* | 400 | โ€” ("Invalid comment ID format") | The `{comment_id}` path segment is not a comment id | | *(generated per call site)* | 401 / 404 / 406 | โ€” ("Invalid entity or insufficient permissions") | You cannot comment where the suggestion lives; the status follows the access check (401 access denied, 404 not found, 406 otherwise) | | *(generated per call site)* | 404 | โ€” ("Comment not found") | Unknown or deleted comment | | *(generated per call site)* | 403 | `comment_thread_read_only` | A File Share comment | | *(generated per call site)* | 400 | `comment_not_root` | The id is a reply, not the suggestion root | | *(generated per call site)* | 400 | โ€” ("The request body must be a JSON object") | The body is not a JSON object | | *(generated per call site)* | 400 | `comment_not_suggestion` | The thread is not a suggestion | | *(generated per call site)* | 400 | `suggestion_unsupported_surface` | The suggestion is not on a workspace note | | *(generated per call site)* | 404 | โ€” ("The note this suggestion belongs to no longer exists") | The note was purged | | *(generated per call site)* | 503 | `comment_thread_state_unavailable` | The thread state could not be read; `retryable: true` | | *(generated per call site)* | 503 | `suggestion_state_unavailable` | The suggestion could not be read or written; `retryable: true` | ### Confirm an Editor Accept #### `POST /current/comments/{comment_id}/suggestion/confirm/` Finish an editor-mode accept: record the note version your editor saved with the edit applied. `accepting` โ†’ `accepted`. **Auth:** Required (JWT). Rate limited. **Content-Type:** `application/json` **Who may call it:** The holder of the editor accept (its `accept_token`), with edit rights on the note. Fastio's built-in agent cannot confirm. **Request Body:** | Field | Type | Required | Description | |-------|------|----------|-------------| | `accept_token` | string | Yes | The `accept_token` the editor-mode accept returned (1-128 characters) | | `applied_version_id` | string | Yes | The note version your editor saved with the edit applied. It must be a version of this note | **Request Example:** ```bash curl -X POST "https://api.fast.io/current/comments/abc123opaqueid/suggestion/confirm/" \ -H "Authorization: Bearer {jwt_token}" \ -H "Content-Type: application/json" \ -d '{"accept_token": "{accept_token}", "applied_version_id": "{version_id}"}' ``` **Response:** `{"result": true, "comment": {...}, "state": "accepted", "applied_version_id": "{version_id}"}`. - **Replay-safe:** the same `accept_token` and `applied_version_id` on an accepted suggestion return it unchanged, and no second event fires. - **A lapsed hold still confirms** while nobody else has taken the suggestion (it still holds your token): the edit is in the note. - **Event:** `comment_suggestion_decided` (decision `accepted`) on a real change. **Errors** (gate on HTTP status, then `error.params.reason`): | Error Code | HTTP Status | `reason` | When | |------------|-------------|----------|------| | *(generated per call site)* | 403 | `suggestion_accept_forbidden` | Fastio's built-in agent, or you cannot edit the note | | *(generated per call site)* | 400 | `suggestion_invalid` | `accept_token` or `applied_version_id` is missing or too long, or `applied_version_id` is not a version of this note | | *(generated per call site)* | 409 | `suggestion_lease_invalid` | The token is not the suggestion's current accept (taken over, or held by someone else), or the suggestion was accepted with a different version. `state` gives the current state | | *(generated per call site)* | 409 | `suggestion_not_pending` | The suggestion was rejected or withdrawn | | *(generated per call site)* | 409 | `state_version_conflict` | A concurrent change kept the write from landing; `state_version` and `state` give the current values | | *(generated per call site)* | 503 | `suggestion_state_unavailable` | The `applied_version_id` lookup failed; `retryable: true` | | *(generated per call site)* | 400 | โ€” ("Invalid comment ID format") | The `{comment_id}` path segment is not a comment id | | *(generated per call site)* | 401 / 404 / 406 | โ€” ("Invalid entity or insufficient permissions") | You cannot comment where the suggestion lives; the status follows the access check (401 access denied, 404 not found, 406 otherwise) | | *(generated per call site)* | 404 | โ€” ("Comment not found") | Unknown or deleted comment | | *(generated per call site)* | 403 | `comment_thread_read_only` | A File Share comment | | *(generated per call site)* | 400 | `comment_not_root` | The id is a reply, not the suggestion root | | *(generated per call site)* | 400 | โ€” ("The request body must be a JSON object") | The body is not a JSON object | | *(generated per call site)* | 400 | `comment_not_suggestion` | The thread is not a suggestion | | *(generated per call site)* | 400 | `suggestion_unsupported_surface` | The suggestion is not on a workspace note | | *(generated per call site)* | 404 | โ€” ("The note this suggestion belongs to no longer exists") | The note was purged | | *(generated per call site)* | 503 | `comment_thread_state_unavailable` | The thread state could not be read; `retryable: true` | | *(generated per call site)* | 503 | `suggestion_state_unavailable` | The suggestion could not be read or written; `retryable: true` | ### Release an Editor Accept #### `POST /current/comments/{comment_id}/suggestion/release/` Give back an editor-mode accept without applying it: `accepting` โ†’ `pending`. **Auth:** Required (JWT). Rate limited. **Content-Type:** `application/json` **Who may call it:** The holder of the accept (its `accept_token`). You must be able to comment on the note. **Request Body:** | Field | Type | Required | Description | |-------|------|----------|-------------| | `accept_token` | string | Yes | The `accept_token` the accept returned (1-128 characters) | **Request Example:** ```bash curl -X POST "https://api.fast.io/current/comments/abc123opaqueid/suggestion/release/" \ -H "Authorization: Bearer {jwt_token}" \ -H "Content-Type: application/json" \ -d '{"accept_token": "{accept_token}"}' ``` **Response:** `{"result": true, "comment": {...}, "state": "pending"}`. - **Idempotent:** a suggestion that is already `pending` is returned unchanged. - **A headless accept whose edit was sent is never released**, by anyone: it ends when it is confirmed or its hold expires. - **Event:** none. **Errors** (gate on HTTP status, then `error.params.reason`): | Error Code | HTTP Status | `reason` | When | |------------|-------------|----------|------| | *(generated per call site)* | 400 | `suggestion_invalid` | `accept_token` is missing or too long | | *(generated per call site)* | 409 | `suggestion_not_pending` | The suggestion was already decided; `state` | | *(generated per call site)* | 409 | `suggestion_lease_invalid` | The token is not the suggestion's current accept, or someone else holds it | | *(generated per call site)* | 503 | `suggestion_apply_unknown` | A headless accept sent the edit, which may have been applied: `retryable: true`, `retry_after` (the hold left; also a `Retry-After` header) | | *(generated per call site)* | 409 | `state_version_conflict` | A concurrent change kept the write from landing; `state_version` and `state` give the current values | | *(generated per call site)* | 400 | โ€” ("Invalid comment ID format") | The `{comment_id}` path segment is not a comment id | | *(generated per call site)* | 401 / 404 / 406 | โ€” ("Invalid entity or insufficient permissions") | You cannot comment where the suggestion lives; the status follows the access check (401 access denied, 404 not found, 406 otherwise) | | *(generated per call site)* | 404 | โ€” ("Comment not found") | Unknown or deleted comment | | *(generated per call site)* | 403 | `comment_thread_read_only` | A File Share comment | | *(generated per call site)* | 400 | `comment_not_root` | The id is a reply, not the suggestion root | | *(generated per call site)* | 400 | โ€” ("The request body must be a JSON object") | The body is not a JSON object | | *(generated per call site)* | 400 | `comment_not_suggestion` | The thread is not a suggestion | | *(generated per call site)* | 400 | `suggestion_unsupported_surface` | The suggestion is not on a workspace note | | *(generated per call site)* | 404 | โ€” ("The note this suggestion belongs to no longer exists") | The note was purged | | *(generated per call site)* | 503 | `comment_thread_state_unavailable` | The thread state could not be read; `retryable: true` | | *(generated per call site)* | 503 | `suggestion_state_unavailable` | The suggestion could not be read or written; `retryable: true` | --- ## Comment Email Notifications Fastio emails people about comment activity that concerns them. Each kind of email is separate: - **Mention** โ€” someone mentions you in a comment. - **Assignment** โ€” a comment thread is assigned to you, or to one of your agents ("Assigned to you ยท via Claude" emails you). - **Reply** โ€” someone replies in a thread you started or that is assigned to you. - **Suggestion decided** โ€” a suggestion you made is accepted or rejected. Resolving or reopening a thread never sends email. Agents and service accounts are never emailed; work assigned to your agent is emailed to you. You are not emailed about your own actions, except a reply the Ripley agent posts on your behalf. Before an email is sent, Fastio checks again that the comment still exists, that you can still see it, and that your preference allows it. Email links open the file with the comment selected (`?comment={comment_id}`). ### Preference Modes | Mode | Emails | |---|---| | `mine_and_mentions` (default) | mentions, assignments, replies on threads you started or are assigned to, and your suggestions being decided | | `all` | everything above, plus replies on threads you replied in | | `none` | no comment email | A preference is global, with an optional override per file. The override wins; without one the file uses your global mode; without a global mode the default applies. ### `GET /current/comments/preferences/` Your global comment email mode. ```json { "result": true, "mode": "mine_and_mentions" } ``` ### `POST /current/comments/preferences/` Set your global mode. JSON body: ```json { "mode": "all" } ``` `mode` is one of `all`, `mine_and_mentions`, `none`, or `inherit` (clears your global setting so the default applies). The response is the same as GET. ### `GET /current/comments/preferences/{node_id}/` Your mode for one file. You must be able to view the file. ```json { "result": true, "mode": "none", "inherited": false } ``` `inherited` is `true` when the file has no override and `mode` comes from your global setting (or the default). ### `POST /current/comments/preferences/{node_id}/` Set or clear the override for one file. JSON body `{ "mode": "all" | "mine_and_mentions" | "none" | "inherit" }`; `inherit` removes the override. The response is the same as GET. Errors (`error.params.reason`): - `notification_preference_invalid` (400): `mode` is missing or not one of the values above. - `notification_preferences_unavailable` (503, `retryable: true`): try again. - `notification_preferences_forbidden` (403): service accounts have no comment email preferences. - Invalid file id (400), a file you cannot view (403) or that does not exist (404). OAuth connections restricted per place need the `comment` tool on the file's workspace or share to POST a per-file preference; reading it needs `read`. --- ## Comment Attachments Attach arbitrary platform objects to a comment to give it context โ€” a file or folder, a sign envelope, a share, a File Share, or a workspace. An attachment is a lightweight **reference**: it records what is attached, not a copy of it. - A comment can hold up to **25** attachments. - Attaching is **idempotent** โ€” re-attaching an object already attached is a no-op and does not count against the cap. - Attaching does **not** verify the target exists or is accessible; display names are resolved (and access-gated) on read. - **Attaching/detaching is author-only** โ€” only the comment's author can add or remove attachments (a moderator who can delete a comment cannot mutate its attachments). - A soft-deleted comment returns `404`. ### Attachment Object (hydrated) Every attachment returned by the API is hydrated and **access-gated to the caller**: an object the caller cannot see (or that no longer exists) is returned with `available: false` and `display_name: null` rather than leaking its name. | Field | Type | Description | |-------|------|-------------| | `target_id` | string | The attached object's ID (19-digit numeric for a workspace/share/envelope/File Share, or an opaque ID for a file/folder node) | | `target_type` | string | Coarse object type: `"node"`, `"envelope"`, `"share"`, `"workspace"`, or `"fileshare"` | | `kind` | string | Alias of `target_type` (same value) | | `display_name` | string\|null | The object's resolved display name, or `null` when the caller cannot see it or it has no name | | `available` | bool | `true` when the caller can access the object (and `display_name` is authoritative); `false` when it is inaccessible, missing, or could not be resolved | > **Render defensively:** when `available` is `false`, never show a name โ€” show a generic placeholder, and never assume `display_name` is non-null. ### List Comment Attachments #### `GET /current/comments/{comment_id}/attachments/` List all objects attached to a comment, hydrated and access-gated to the caller. **Auth:** Required (JWT). Rate limited. **Response:** ```json { "result": true, "comment_id": "abc123opaqueid", "attachments": [ { "target_id": "23d4zvoprfmonyhy2wwjpoccqjaiz", "target_type": "node", "kind": "node", "display_name": "spec.pdf", "available": true }, { "target_id": "9876543210987654321", "target_type": "share", "kind": "share", "display_name": null, "available": false } ], "count": 2 } ``` ### Attach Objects to a Comment #### `POST /current/comments/{comment_id}/attachments/` Attach one object (`target_id`) or many (`target_ids`) to a comment. Idempotent (already-attached objects are skipped); the new objects are committed atomically (a partial-batch failure attaches nothing). Returns the full, updated hydrated attachment list. **Auth:** Required (JWT). Rate limited. **Content-Type:** `application/json` **Request Body:** | Field | Type | Required | Constraints | Description | |-------|------|----------|-------------|-------------| | `target_id` | string | One of `target_id` / `target_ids` is required | Valid object ID | A single object to attach | | `target_ids` | array\ | One of `target_id` / `target_ids` is required | Non-empty array | Multiple objects to attach | A request whose new attachments would push the comment over the 25-attachment cap is rejected before anything is written. **Request Example -- single:** ```bash curl -X POST "https://api.fast.io/current/comments/abc123opaqueid/attachments/" \ -H "Authorization: Bearer {jwt_token}" \ -H "Content-Type: application/json" \ -d '{"target_id": "23d4zvoprfmonyhy2wwjpoccqjaiz"}' ``` **Request Example -- bulk:** ```bash curl -X POST "https://api.fast.io/current/comments/abc123opaqueid/attachments/" \ -H "Authorization: Bearer {jwt_token}" \ -H "Content-Type: application/json" \ -d '{"target_ids": ["23d4zvoprfmonyhy2wwjpoccqjaiz", "9876543210987654321"]}' ``` **Response:** ```json { "result": true, "comment_id": "abc123opaqueid", "attached": 1, "attachments": [ { "target_id": "23d4zvoprfmonyhy2wwjpoccqjaiz", "target_type": "node", "kind": "node", "display_name": "spec.pdf", "available": true } ], "count": 1 } ``` **Response Fields:** | Field | Type | Description | |-------|------|-------------| | `comment_id` | string | The comment the attachments belong to | | `attached` | int | Number of objects newly attached by this request (0 when every target was already attached) | | `attachments` | array | The full hydrated attachment list after the operation | | `count` | int | Number of attachments in the list | **Error Responses:** | Error Code | HTTP Status | Message | Cause | |------------|-------------|---------|-------| | `1605 (Invalid Input)` | 406 | "A target_id or non-empty target_ids array is required" | No target supplied | | `143686` | 406 | "target_ids exceeds the maximum allowed input size of 200" | More than 200 raw entries supplied. This is a raw-input bound, separate from -- and looser than -- the 25-attachments-per-comment cap, which is applied afterwards to the deduplicated set | | `1605 (Invalid Input)` | 406 | "Invalid attachment target" | A target ID could not be decoded | | `1605 (Invalid Input)` | 406 | "Attachment limit reached (maximum 25 per comment)" | The new attachments would exceed the cap | | `1680 (Access Denied)` | 401 | "You do not have permission to modify attachments on this comment" | Caller is not the comment author | | `1609 (Not Found)` | 404 | "Comment not found" | Comment does not exist, is not visible to you, or is trashed | ### Detach an Object from a Comment #### `POST /current/comments/{comment_id}/attachments/detach/` Remove an attachment by its `target_id`. Idempotent โ€” detaching an object that is not attached returns success with `removed: false`. Returns the updated hydrated attachment list. **Auth:** Required (JWT). Rate limited. **Content-Type:** `application/json` **Request Body:** | Field | Type | Required | Constraints | Description | |-------|------|----------|-------------|-------------| | `target_id` | string | Yes | Valid object ID | The object to detach | **Request Example:** ```bash curl -X POST "https://api.fast.io/current/comments/abc123opaqueid/attachments/detach/" \ -H "Authorization: Bearer {jwt_token}" \ -H "Content-Type: application/json" \ -d '{"target_id": "23d4zvoprfmonyhy2wwjpoccqjaiz"}' ``` **Response:** ```json { "result": true, "comment_id": "abc123opaqueid", "removed": true, "attachments": [], "count": 0 } ``` **Response Fields:** | Field | Type | Description | |-------|------|-------------| | `comment_id` | string | The comment the attachment belonged to | | `removed` | bool | `true` if an attachment was actually removed; `false` if the object was not attached | | `attachments` | array | The full hydrated attachment list after the operation | | `count` | int | Number of attachments remaining | ### Attaching at comment-create time (inline) You can attach objects when creating a comment by including `target_id` or `target_ids` in the comment-create body (see Create or Update Comment). Inline attachments apply **only to a new comment** โ€” they are ignored on an update, and the comment plus its attachments are written together so the comment never appears without them. An invalid target or one that would exceed the cap rejects the whole create. ### Counts on reads `attachment_count` is returned on every **comment list** row and on the **comment detail** response. The hydrated `attachments` array is returned **only** on the comment detail response (`GET /current/comments/{comment_id}/details/`). All counts are computed live on each read. --- ## Threading Comments support **single-level threading only**. - Set `parent_id` in the request body to the opaque ID of the comment you are replying to. This is distinct from `{parent_id}` in the URL path, which identifies the workspace or share container. - Replies to a top-level comment appear as children of that comment. - Replies to a reply are **auto-flattened** -- they become siblings of the original reply (children of the same top-level parent). The API does not support nested threading beyond one level. ``` Comment A (top-level) โ”œโ”€โ”€ Comment B (reply to A) โ”œโ”€โ”€ Comment C (reply to B โ†’ auto-flattened to reply to A) โ””โ”€โ”€ Comment D (reply to A) ``` --- ## Mentions Use mention markup in the `body` field to tag other users: ``` @[user:USER_ID:Display Name] ``` To summon Ripley into a workspace file's thread, use the token `@[agent:ripley]` (or assign the thread with `{"assignee": "ripley"}`); see the AI reference. | Component | Description | |-----------|-------------| | `user` | Literal prefix (always `user`) | | `USER_ID` | The user's 19-digit numeric profile ID | | `Display Name` | Display name to show in the UI | **Character limit details:** - The **full mention tag markup** (e.g., `@[user:1234567890123456789:Jane Smith]`) counts toward the 8192-character body limit. Both the 8192 raw cap and the 500 display cap count UTF-8 **characters**, not bytes โ€” a CJK or emoji character costs exactly one, the same as an ASCII one. - Display text (excluding all mention markup) is separately limited to 500 characters. A mention inside markdown code is **not** discounted โ€” it counts in full (see *Mentions inside code*, below). - Mentioned users are validated against entity membership at save time. Invalid mentions (users without access to the entity) are dropped silently. - A workspace service account is never a mention recipient: no mention or reply notification is ever sent to it. **Example body with mentions:** ``` Hey @[user:1234567890123456789:Jane Smith] and @[user:9876543210987654321:Bob Jones], please review this file. ``` ### Mentions inside code **This is a live behaviour change.** A mention that sits inside markdown code โ€” a fenced block, or a single-line inline backtick span โ€” is treated as text the author is **showing**, not as a hail: - It **does not notify** the mentioned user. - It **counts in full** against the 500-character display cap, instead of being discounted as mention markup. Two consequences to plan for: someone who is notified today by a fenced mention **stops being notified**, and a long body that hid mention markup inside a fence **may now be rejected** by the display cap that previously discounted it. **What counts as code โ€” deliberately narrow, and matched per line:** - A **fenced block** opened by a run of three or more backticks or three or more tildes, indented at most 3 spaces, closing on a run of the same character at least as long, alone on its line. An unclosed fence runs to the end of the body. - A **single-line inline backtick span** โ€” a run of N backticks closing on a run of exactly N backticks on the **same** line. **What is NOT recognised as code.** A mention in any of these still notifies, and is still discounted from the display cap, exactly as before: - A fence carrying a blockquote or other container prefix (a `>` marker ahead of the fence characters). - A fence indented four or more spaces. - Indented code blocks generally โ€” four spaces or a tab, with no fence markers at all. - A backtick span whose opening and closing runs sit on different lines. **The bias is deliberate.** Failing to recognise code is safe โ€” it is the behaviour that was already live. Inventing code where there is none would silently drop a real person's notification. When in doubt, it is not code. --- ## Reference Anchoring Comments can be anchored to a specific position within a file using the `reference` object. The **`type`** field is required and selects which additional fields are valid. **`type` values:** | `type` | Applies to | Valid anchor fields | |--------|------------|---------------------| | `"video"` | Video files | `timestamp` (or `timestamp_start` + `timestamp_end`); optional `region` | | `"audio"` | Audio files | `timestamp` (or `timestamp_start` + `timestamp_end`) | | `"document"` | Documents / text | `page`, `text_snippet`, `section`, and the text-anchor fields `exact` / `prefix` / `suffix` / `start_offset` / `end_offset` | | `"image"` | Images | `region` | | `"general"` | Any file | none (a bare `{"type": "general"}` anchor) | **Anchor fields:** | Field | Type | Used with | Description | |-------|------|-----------|-------------| | `timestamp` | number | `video`, `audio` | Position in seconds (non-negative). Mutually exclusive with the range fields | | `timestamp_start` / `timestamp_end` | number | `video`, `audio` | A time range in seconds (both non-negative, `end` > `start`) | | `page` | integer | `document` | Page number (positive integer) | | `text_snippet` | string | `document` | The referenced text passage (max 500 characters) | | `section` | object | `document` | A line/character range: `{"start": {"line", "char"}, "end": {"line", "char"}}`; `line` is a positive integer | | `exact` / `prefix` / `suffix` | string | `document` | Text-selection anchor: the selected text (`exact`, 1-500 characters) plus the text just before and after it (`prefix` / `suffix`, max 100 characters each). `exact` requires both `prefix` and `suffix`, and they require `exact` | | `start_offset` / `end_offset` | integer | `document` | Character offsets of the selection (non-negative; both or neither; `end_offset` > `start_offset`). A hint only: the quote (`exact` + `prefix` + `suffix`) is the anchor | | `offset_space` | string | `document` | The text space the offsets are measured in. The only accepted value is `"plaintext-utf16-nl"` (rendered plain text, UTF-16 code units, one newline between blocks) | | `v` | integer | `document` | Text-anchor schema version. The only accepted value is `2` | | `source_version_id` | string | `document` | The file version the anchor was taken on (non-empty, max 128 characters) | | `region` | object | `video`, `image` | Rectangular area with keys `x1`, `y1`, `x2`, `y2`, each a number **0โ€“100** (percent of width/height), with `x2` > `x1` and `y2` > `y1` | Any unrecognized `type` is rejected with `"Invalid reference type: {value}"`, and any key not listed above with `"Unknown reference key: {key}"` (`error.params.reason` = `anchor_invalid`). The whole `reference` object, JSON-encoded, may not exceed 2048 bytes (non-ASCII characters count as their 6-byte escaped form); a larger one is rejected with `error.params.reason` = `anchor_too_large`. **The text anchor is immutable.** Once a comment is created, its `exact`, `prefix` and `suffix` cannot be changed, added or removed by an edit โ€” either edit route rejects that with `"The anchor of an existing comment cannot be changed"` (`error.params.reason` = `anchor_invalid`). Resending the same values, or omitting `reference`, is fine; the offsets may change. **Example -- Video timestamp:** ```json { "body": "The transition at this point needs work.", "reference": {"type": "video", "timestamp": 42.5} } ``` **Example -- Document page:** ```json { "body": "Typo in the second paragraph.", "reference": {"type": "document", "page": 7} } ``` **Example -- Image region:** ```json { "body": "This area needs higher contrast.", "reference": {"type": "image", "region": {"x1": 20, "y1": 15, "x2": 55, "y2": 40}} } ``` **Example -- Document text snippet:** ```json { "body": "This sentence should be rephrased.", "reference": {"type": "document", "text_snippet": "The quick brown fox jumps over the lazy dog."} } ``` --- ## Content Filtering Comment bodies are stored as sent โ€” the filtering stages below are currently inactive: - **HTML handling:** HTML is **not** stripped or escaped โ€” the body is trimmed of leading and trailing whitespace and then stored unmodified, so code and markup-shaped text survive intact. Escape or sanitize the body in your own client before rendering it as HTML. - **Spam detection:** No spam detection is applied today. The spam-rejection response (`406`) remains part of this endpoint's documented contract and may become active again, so clients should continue to handle it. - **Profanity/PII filtering:** No profanity or PII filtering is applied today; the returned `body` matches what was stored.