Comments
import { Aside } from ‘@astrojs/starlight/components’;
Threaded comments are a first-class collaboration surface: humans and agents leave notes on pages and files, assign action items, pin spots on a page, and get notified on @mentions and replies.
Access from page code via sdk.comments, or call the data API directly.
Two surfaces, one store
Section titled “Two surfaces, one store”| Surface | Where it lives | Scoping |
|---|---|---|
| Page / artifact | The page’s own artifact_id |
Optional contextId for sections/pins |
| File (deliverable) | The workspace asset-bucket artifact | Required contextId: file:<deliverableId> |
All rows live in D1 table artifact_comments. Feature flag: collab.comments.
Response envelope
Section titled “Response envelope”Every data-API call returns a consistent JSON envelope:
// Success{ "success": true, "data": { /* payload */ } }
// Error{ "success": false, "error": "Human-readable message", "code": "ERROR_CODE" }When listing comments, read response.data.comments (not top-level comments).
REST API
Section titled “REST API”Base path: /v1/data/{artifactId}/comments
| Method | Path | Notes |
|---|---|---|
GET |
/ |
List. Query: contextId, parentId (null for roots), resolved, assignee, limit, skip |
POST |
/ |
Create. Body: content (required), contextId, parentId, authorName, position, state, mentions[], assignee, dueAt |
GET |
/{id} |
Single comment (+ reactions) |
PATCH |
/{id} |
Edit content (author or artifact owner) |
DELETE |
/{id} |
Delete (author or artifact owner) |
GET |
/{id}/replies |
Direct replies |
PATCH |
/{id}/resolve |
{ "resolved": true | false } — author, owner, assignee, or anyone with review access |
PATCH |
/{id}/assign |
{ "assignee": "email" | null, "dueAt"?: string | null } — signed in |
POST |
/{id}/reactions |
{ "emoji": "👍" } toggle — signed in |
GET / PUT |
/_config |
Per-artifact config (PUT owner-only) |
GET |
/_people |
Assignable people. Requires a session that is itself on the artifact (owner, workspace member, or collaborator) — a bare sign-in gets 403 FORBIDDEN |
GET |
/_unread |
Unread count for signed-in viewer |
POST |
/_read |
Mark all read |
WS |
/ws |
Realtime events (not available on file buckets) |
Config (/_config)
Section titled “Config (/_config)”interface CommentsConfig { enabled: boolean; // default true identityMode: 'anonymous' | 'named' | 'authenticated'; // default 'anonymous' allowReplies: boolean; // default true maxDepth: number; // 1–10, default 3 overlayEnabled?: boolean; // signed-in viewer toolbar; default true}identityMode |
Who can post |
|---|---|
anonymous |
Anyone; optional display name |
named |
Display name required if not signed in |
authenticated |
Session required |
File comment examples
Section titled “File comment examples”# List comments on a fileGET /v1/data/{bucketArtifactId}/comments?contextId=file%3Adlv_abc123Cookie: shareout_session=…
# Post on a filePOST /v1/data/{bucketArtifactId}/commentsContent-Type: application/json
{ "content": "Please export as PDF as well.", "contextId": "file:dlv_abc123"}Who may read/post on a file:
- File owner
- Workspace member when the file is not
private - External sharee with a grant capability of
view(read) orcomment(post)
SDK methods
Section titled “SDK methods”// CRUDadd(options: { content: string; contextId?: string; parentId?: string; authorName?: string; position?: CommentPosition; state?: unknown; mentions?: string[];}): Promise<Comment>
reply(parentId: string, content: string, authorName?: string): Promise<Comment>edit(id: string, content: string): Promise<Comment>delete(id: string): Promise<boolean>findById(id: string): Promise<Comment | null>getReplies(parentId: string): Promise<Comment[]>getThread(rootId: string): Promise<CommentThread>react(id: string, emoji: string): Promise<ReactionResult>
// Configuration (owner only)getConfig(): Promise<CommentsConfig>setConfig(config: Partial<CommentsConfig>): Promise<CommentsConfig>
// Action items (session required)resolve(id: string, resolved?: boolean): Promise<Comment>assign(id: string, options?: { assignee?: string | null; dueAt?: string | null }): Promise<Comment>
// Real-timesubscribe(handler: (event: CommentEvent) => void): () => voidsubscribeToContext(contextId: string, handler: (event: CommentEvent) => void): () => voiddisconnect(): void
// State bridge (pinned comments)onCaptureState(fn: () => unknown | Promise<unknown>): voidonRestoreState(fn: (state: unknown) => void | Promise<void>): voidQuery builder
Section titled “Query builder”find(filter?: { contextId?: string; parentId?: string | null }): CommentsQuery
query.context(contextId: string): CommentsQueryquery.topLevel(): CommentsQueryquery.sort(field: 'createdAt' | 'updatedAt', order: 'asc' | 'desc'): CommentsQueryquery.limit(n: number): CommentsQueryquery.skip(n: number): CommentsQueryquery.exec(): Promise<Comment[]>interface Comment { id: string; contextId: string | null; parentId: string | null; authorId: string | null; authorName: string; content: string; createdAt: string; updatedAt: string; resolved?: boolean; resolvedBy?: string | null; resolvedAt?: string | null; position?: CommentPosition | null; state?: unknown | null; mentions?: string[]; authorType?: 'human' | 'agent'; reactions?: Record<string, { count: number; mine: boolean }>; assigneeUserId?: string | null; assigneeEmail?: string | null; dueAt?: string | null;}
interface CommentEvent { type: 'comment:added' | 'comment:updated' | 'comment:deleted' | 'comment:resolved'; comment: Comment;}
interface CommentThread { comment: Comment; replies: CommentThread[];}
interface CommentPosition { selector?: string; relX?: number; relY?: number; pctX?: number; pctY?: number; scrollY?: number;}Examples
Section titled “Examples”// Configure (owner only)await sdk.comments.setConfig({ enabled: true, identityMode: 'named', allowReplies: true, maxDepth: 3, overlayEnabled: true,});
// Add a comment pinned to a slideconst comment = await sdk.comments.add({ content: 'Great chart!', contextId: 'slide-5', authorName: 'Alice',});
// Replyawait sdk.comments.reply(comment.id, 'Thanks!', 'Bob');
// Query top-level comments for a contextconst comments = await sdk.comments .find({ contextId: 'slide-5' }) .topLevel() .sort('createdAt', 'desc') .limit(50) .exec();
// Full nested threadconst thread = await sdk.comments.getThread(comment.id);
// Real-time updatesconst unsub = sdk.comments.subscribeToContext('slide-5', (event) => { if (event.type === 'comment:added') renderComment(event.comment);});// Later:unsub();State bridge
Section titled “State bridge”Pinned comments can capture and restore app state (e.g. active filters on a dashboard). Register callbacks before adding comments:
sdk.comments.onCaptureState(() => ({ filters: currentFilters, activeTab,}));
sdk.comments.onRestoreState((state) => { currentFilters = state.filters; activeTab = state.activeTab;});When a comment is added, the current state is stored with it. Opening a pinned comment
replays the state via onRestoreState.
Action items
Section titled “Action items”Any comment can be turned into an action item by assigning it to a workspace member or collaborator. The assignee gets an email (and a Telegram message if linked). When they resolve the comment, the requester is notified and can reopen with one click.
In the artifact viewer: open the comment panel → Assign → pick a person and an optional due date.
Via API:
# Assign when creatingPOST /v1/data/{artifactId}/comments{ "content": "Fix the Y-axis label", "assignee": "alice@example.com", "dueAt": "2025-08-01T00:00:00Z" }
# Assign or unassign an existing commentPATCH /v1/data/{artifactId}/comments/{id}/assign{ "assignee": "alice@example.com", "dueAt": "2025-08-01T00:00:00Z" }
# UnassignPATCH /v1/data/{artifactId}/comments/{id}/assign{ "assignee": null }The assignee must be in the artifact’s people set (workspace members + collaborators);
unknown addresses return 400 ASSIGNEE_NOT_FOUND.
Tracking: open action items across artifacts appear in the Home notifications bell — sorted by due date, with Done / Reopen.
Filter by assignee:
GET /v1/data/{artifactId}/comments?assignee=meGET /v1/data/{artifactId}/comments?assignee=alice@example.comAI crew tools: comment_create, action_item_create, action_item_list.
Viewer toolbar (built-in UI)
Section titled “Viewer toolbar (built-in UI)”When comments are enabled, the sandbox viewer injects a comments overlay according to
identityMode:
identityMode |
Who sees the panel |
|---|---|
anonymous (default) |
Anyone — optional display name |
named |
Anyone — display name required to post |
authenticated |
Signed-in only (guests see Log in to comment) |
| Feature | Notes |
|---|---|
| Unread badge | Signed-in only (counts comments since last read) |
| Open / Resolved filters | Switch thread views |
| Reactions / Assign | Signed-in only |
| Typing indicators | Via the comments WebSocket |
| Presence | Others currently on the comments channel |
| Pin to page | Places a numbered pin in the page iframe |
| Resolve | Signed-in author / owner / assignee |
| Deep link | #comment-<id> scrolls to a card |
Owners and collaborators can also manage threads from the Comments tab in the workspace Inspector.
Guest comments (no account)
Section titled “Guest comments (no account)”Clients reviewing through a link can comment without an account once the owner turns
guests on: PUT /v1/data/{artifactId}/comments/_config with { "guestComments": true }
(null follows the folder), or for a folder PUT /v1/workspaces/{wid}/folders/{fid}/guest-comments
{ "enabled": true } (admins and the folder’s creator). MCP: set_guest_comments.
In the app, a folder manager flips Allow guest comments in the folder’s Who has access panel, and the owner picks Follow folder / On / Off in the artifact’s Comments tab, which also shows whether guests can comment right now.
- Only where the guest can already open the page without an account: public, or a password page they unlocked. Private and workspace pages stay account-only.
- The toolbar shows Comment as guest (name, email, 6-digit code). The code buys an HttpOnly, SameSite=Strict cookie valid 12 hours for that artifact only.
- Guest comments show a guest badge (
authorType: "guest"). The email is stored only as a keyed hash and never returned to anyone. - Guests comment and reply; they cannot resolve, assign, react, approve or @mention. The owner can delete their comments.
- With guests on,
namedandauthenticatedrequire the code from anyone without an account;anonymousstill allows anonymous posts. - Rate limits per IP and per guest; writes must come from a page on this instance.
Endpoints: GET|DELETE …/comments/_guest, POST …/comments/_guest/code,
POST …/comments/_guest/verify — see the API reference.
File UIs
Section titled “File UIs”| Place | Behavior |
|---|---|
| Home → Assets | Modal thread on a deliverable (file:<id>) |
/shared portal |
External sharees with comment grant can read/post on shared files |
These UIs use the same REST API; they do not open a WebSocket.
Ask the AI from a comment
Section titled “Ask the AI from a comment”Write @AI (also @IA or @ShareOut) in a comment or reply, followed by the change:
@AI make the chart a bar chart and add a total. The page editor makes the change,
publishes it as a new version (undo it from version history) and answers in the
same thread: a “Working on it…” reply that turns into a summary of what changed, or
the reason it couldn’t.
- Only the artifact’s owner or an editor can trigger an edit. Anyone else (viewers, anonymous or external commenters) gets a reply saying edit access is needed.
- Counts against the same limits as other AI publishes: the per-user AI chat limit, the daily publish limit and the owner’s storage quota.
- Needs an AI editor configured for the page (same as the in-Studio edit).
- Not available on file comments.
Notifications
Section titled “Notifications”| Trigger | Channel |
|---|---|
@mention (emails in mentions[]) |
Email (+ Telegram if linked) |
| Reply to your comment | Email (+ Telegram if linked) |
| Assigned action item | Email (+ Telegram if linked) |
| Action item resolved | Email to requester |
| Someone approves a version | Owner: Home feed, email (+ Telegram) — once per approver per version |
| New versions published | Followers and collaborators: feed, email (+ Telegram) with each version’s note; one notice per artifact once publishing is quiet for 10 minutes (hourly sweep) |
None of these reach the person who acted. Email follows the product preference and its unsubscribe link; Telegram follows the same opt-out. A reply from a guest is marked (guest).
Only people already on the artifact — the owner, workspace members, and
collaborators, the same set /_people returns — are notifiable. mentions[] is
client input on a comment an anonymous visitor may be allowed to post, so an
address outside that set is stored and rendered but never mailed. Add someone as
a collaborator first if you want the mention to reach them.
Error codes (common)
Section titled “Error codes (common)”| Code | Status | Meaning |
|---|---|---|
COMMENTS_DISABLED |
403 | _comments_config.enabled is false |
AUTH_REQUIRED |
401 | Signed-in identity required for this action |
NAME_REQUIRED |
400 | identityMode: named without a display name |
GUEST_VERIFICATION_REQUIRED |
401 | Guests are on; confirm an email code first |
GUEST_COMMENTS_DISABLED |
403 | Guests are off here, or the page needs an account to open |
INVALID_CODE |
400 | Wrong, expired or used-up guest code |
FILE_CONTEXT_REQUIRED |
400 | Bucket list/post without file:… context |
FILE_NOT_FOUND |
404 | Context points at another bucket or missing file |
FORBIDDEN |
403 | No access to the file / not author-or-owner |
ASSIGNEE_NOT_FOUND |
400 | Assignee is not on this artifact’s people set |
MAX_DEPTH |
400 | Reply would exceed maxDepth |
REALTIME_UNAVAILABLE |
403 | WebSocket requested on a file bucket |
CONTENT_TOO_LONG |
400 | Body over 10 000 characters |
Self-host notes
Section titled “Self-host notes”- Enable feature
collab.comments(default on). - Bind Durable Object
COMMENTS(seewrangler.toml) for page realtime. - Outbound mail for mentions/assigns needs email configured.
- The overlay is controlled by
enabledandoverlayEnabledin_comments_config(cached briefly in KV ascmtcfg:{artifactId}).
