Comments API Threading, mentions, reactions, and reference anchoring for workspace and share entities.
Comments use JSON request bodies (Content-Type: application/json), unlike most other Fastio endpoints which use application/x-www-form-urlencoded.
Endpoint Summary
| Method | Endpoint | 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 |
| 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 (works for every comment surface) |
| 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 |
| 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 |
| 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/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, share, or File 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 |
File Share comments. When {entity_type} is "fileshare", {parent_id} is the File Share's 19-digit numeric profile ID. Comments on a File Share are scoped to that File Share — a recipient sees only the comments made under it, never comments on the same underlying file from the owning workspace or from another File Share. Commenting requires the viewer to be signed in; public anyone-with-link visitors cannot comment. Comments are available only when the File Share owner has enabled comments, and they are always disabled on public anyone-with-link shares. A client should read the File Share's effective comments_enabled flag from its details response to decide whether to show the comment UI.
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). 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
/current/comments/{entity_type}/{parent_id}/
List all comments across the entire workspace or share.
/current/comments/{entity_type}/{parent_id}/{node_id}/
List comments for a specific node within an entity.
Without {node_id}: returns all comments across the entire workspace or share. With {node_id}: returns only comments on that specific file or folder.
Note: Only comments created after 2026-03-15 are included in scope-level results (without {node_id}); older comments are accessible via the node-level endpoint.
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
curl -X GET "https://api.fast.io/current/comments/workspace/1234567890123456789/?limit=50&include_total=true" \
-H "Authorization: Bearer {jwt_token}"
Response
{
"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:
{
"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) |
| 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/ |
| 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) |
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/17xxvalues below are HTTP-status classes, noterror.code. Theerror.codea client actually receives is assigned per endpoint, so use the HTTP status as the gate and a documentederror.code— five or six digits, plus the9661-9669family — only as a refinement. A16xxvalue identifies the status class — useful for telling which kind of failure occurred — but comparing one againsterror.codewill never match. Five- and six-digit codes (and the9661-9669family) are realerror.codevalues. 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 |
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
/current/comments/{entity_type}/{parent_id}/
Create a new comment or update an existing one on an entity.
/current/comments/{entity_type}/{parent_id}/{node_id}/
Create a new comment or update an existing one on a specific node.
Auth: Required (JWT). Rate limited.
Content-Type: application/json
Request Body
| Field | Type | Required | Constraints | Description |
|---|---|---|---|---|
| body | string | Required | 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<string> | No | Non-empty array | Inline attachments on a new comment — multiple objects to attach, up to 25 per comment. 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
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
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
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
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
{
"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: 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<T>), path templates (<workspace-id>), and structured data such asdata: {count: 5}survive a round trip intact. Treat a returnedbodyas 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
reopenand/orassign(see Request Body above); both apply atomically with the reply. - Updating: Only the original author can edit. Editing populates the top-level
edited_attimestamp on the returned comment. A text anchor (exact/prefix/suffix) is fixed when the comment is created: an edit may resend it unchanged or omitreference, but cannot change it. - Server-managed properties:
reactions,version,version_hash,edited_at,content_filtered,mentions,actor,pinnedandreply_toin a request'spropertiesare 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
429with aRetry-Afterheader.
File Share Comments
/current/comments/fileshare/{fileshare_id}/{node_id}/
List a File Share recipient's comments on a node.
/current/comments/fileshare/{fileshare_id}/{node_id}/
Create or update a comment as a File Share recipient.
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
curl -X GET "https://api.fast.io/current/comments/fileshare/adheih5r326qjiqvk4wfamvt4rqeh/23d4zvoprfmonyhy2wwjpoccqjaiz/" \
-H "Authorization: Bearer {jwt_token}"
Request Example — create
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_deletearefalsethere); replies and edits happen through this File Share surface. See the List Comments notes for the one-directional visibility rules. - The invalid-
entity_typeerror message for the whole comment surface isEntity 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
/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 | Required | 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
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_attimestamp is populated. - An edit can never move the comment: its entity, scope, and threading are immutable here.
- Server-managed
propertieskeys (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 (actoris 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
/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
curl -X GET "https://api.fast.io/current/comments/abc123opaqueid/details/" \
-H "Authorization: Bearer {jwt_token}"
Response
{
"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
/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
curl -X DELETE "https://api.fast.io/current/comments/abc123opaqueid/delete/" \
-H "Authorization: Bearer {jwt_token}"
Response
{
"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
/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<string> | Required | Max 100 items | Array of comment opaque IDs to delete |
Request Example
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
{
"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<string> | 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
/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 | Required | Single emoji character; max 2 UTF-8 characters; must match Unicode emoji ranges | Emoji to react with |
Request Example
curl -X POST "https://api.fast.io/current/comments/abc123opaqueid/reactions/" \
-H "Authorization: Bearer {jwt_token}" \
-H "Content-Type: application/json" \
-d '{"emoji": "👍"}'
Response
{
"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
/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
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
curl -X DELETE "https://api.fast.io/current/comments/abc123opaqueid/reactions/" \
-H "Authorization: Bearer {jwt_token}" \
-H "Content-Type: application/json" \
-d '{}'
Response
{
"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
/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 | Constraints | 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
/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
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
/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
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
/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": "<user id>"} | Assign a person (or a service account) |
{"assignee_user_id": "<user id>", "assignee_agent": {"credential_type": "oauth"|"api_key", "credential_id": "<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
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. unassignis the same call asassignwith{"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 Comment Principals in the Storage reference).
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
/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/withsuggestion: {op?, replacement?}(merged over the stored payload) and/orbody(the rationale; omit it to keep the stored one). Author only, and only while the suggestion ispending: while an accept holds it, once it is decided, or once a headless accept was sent, the edit is409 suggestion_not_pending.if_state_versionpins the suggestion'sstate_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. Abodythat is not a string is400. - 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 topending. 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
/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
/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
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(decisionwithdrawn) 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
/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
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 is409 suggestion_not_pending. - Event:
comment_suggestion_decided(decisionrejected) 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
/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
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
pendingis returned unchanged. - Event:
comment_suggestion_revertedon 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
/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
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_tokenandapplied_version_idon 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(decisionaccepted) 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
/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
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
pendingis 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.
{ "result": true, "mode": "mine_and_mentions" }
POST /current/comments/preferences/
Set your global mode. JSON body:
{ "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.
{ "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):modeis 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. Render defensively: when available is false, never show a name — show a generic placeholder, and never assume display_name is non-null.
| 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 |
List Comment Attachments
/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
{
"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
/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 | Valid object ID | A single object to attach |
| target_ids | array<string> | One of target_id / target_ids | 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
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
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
{
"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
/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 |
Response
{
"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.
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_idin 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
{
"body": "The transition at this point needs work.",
"reference": {"type": "video", "timestamp": 42.5}
}
Example — Document page
{
"body": "Typo in the second paragraph.",
"reference": {"type": "document", "page": 7}
}
Example — Image region
{
"body": "This area needs higher contrast.",
"reference": {"type": "image", "region": {"x1": 20, "y1": 15, "x2": 55, "y2": 40}}
}
Example — Document text snippet
{
"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 error) 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
bodymatches what was stored.