This document describes the implementation approach for adding Message Annotations support to the Ably CLI. Annotations allow clients to append metadata (reactions, tags, read receipts, etc.) to existing messages on a channel.
Reference documentation: https://ably.com/docs/messages/annotations.md
ably channels annotations publish <channelName> <msgSerial> <annotationType>
ably channels annotations delete <channelName> <msgSerial> <annotationType>
ably channels annotations get <channelName> <msgSerial>
ably channels annotations subscribe <channelName>
The Ably JS SDK (ably@^2.14.0) already provides full annotations support:
| SDK API | Description |
|---|---|
channel.annotations.publish(messageSerial, annotation) |
Publish an annotation (action = annotation.create) |
channel.annotations.delete(messageSerial, annotation) |
Delete an annotation (action = annotation.delete) |
channel.annotations.subscribe(listener) |
Subscribe to individual annotation events |
channel.annotations.subscribe(type, listener) |
Subscribe to annotation events filtered by type |
channel.annotations.unsubscribe(listener) |
Deregister a specific annotation listener |
channel.annotations.unsubscribe() |
Deregister all annotation listeners |
channel.annotations.get(messageSerial, params) |
Get annotations for a message (paginated) |
Note on
delete: The TypeScript declaration file (ably.d.ts) only exposesdeleteonRealtimeAnnotations, not onRestAnnotations. However, the runtime source code inrestannotations.tsdoes implementdeleteby delegating topublishwithaction = 'annotation.delete'. The same pattern is used inrealtimeannotations.ts. For the CLI, we use the Realtime client forpublishanddelete(consistent with other channel commands), and the REST client forget.
OutboundAnnotation—Partial<Annotation> & { type: string }— used for publish/deleteAnnotation— full annotation with fields:id: string— unique ID assigned by AblyclientId?: string— publisher's client IDname?: string— annotation name (used by most aggregation types)count?: number— optional count (formultiple.v1)data?: any— arbitrary publisher-provided payloadencoding?: string— encoding of the payloadtimestamp: number— when annotation was received by Ably (ms since Unix epoch)action: AnnotationAction—'annotation.create'or'annotation.delete'serial: string— this annotation's unique serialmessageSerial: string— serial of the message being annotatedtype: string— annotation type (e.g.,"emoji:distinct.v1")extras: any— JSON object for metadata/ancillary payloads
AnnotationAction—'annotation.create' | 'annotation.delete'GetAnnotationsParams—{ limit?: number }(default 100, max 1000)PaginatedResult<Annotation>— paginated result withitems: Annotation[],hasNext(),next(),first(),current(),isLast()- Channel mode
ANNOTATION_PUBLISH— required for publishing annotations - Channel mode
ANNOTATION_SUBSCRIBE— required for subscribing to individual annotation events
RestAnnotations.delete()setsaction = 'annotation.delete'then delegates topublish()RealtimeAnnotations.delete()same pattern — setsaction = 'annotation.delete'then delegates topublish()RealtimeAnnotations.get()delegates toRestAnnotations.prototype.get(REST call under the hood)RealtimeAnnotations._processIncoming()emits onannotation.type(notannotation.action), sosubscribe(type, listener)filters by annotation type stringRealtimeAnnotations.subscribe()checks forANNOTATION_SUBSCRIBEchannel mode flag and throwsErrorInfo(93001)if not set- Annotations are not encrypted — data needs to be parsed by the server for summarisation (see
annotation.ts:69)
The annotation type string follows the format namespace:summarization.version (e.g., reactions:flag.v1).
The summarization method determines which additional parameters are required:
| Summarization | Required Fields | Notes |
|---|---|---|
total.v1 |
type only |
Simple count, no client attribution |
flag.v1 |
type only |
Per-client flag, requires identified client |
distinct.v1 |
type + name |
Per-name distinct client tracking |
unique.v1 |
type + name |
Like distinct but client can only contribute to one name at a time |
multiple.v1 |
type + name + count |
Per-name per-client count tracking |
The CLI must parse the annotationType argument to extract the summarization method and validate accordingly. The shared validation utility (see Section 6) provides two functions:
extractSummarizationType(annotationType)— parses"namespace:summarization.version"and returns the summarization method (e.g.,"distinct")validateAnnotationParams(summarization, { name, count, isDelete })— returns an array of error messages if required params are missing
// Usage in publish command:
const summarization = extractSummarizationType(args.annotationType);
const errors = validateAnnotationParams(summarization, { name: flags.name, count: flags.count });
if (errors.length > 0) {
this.error(errors.join('\n'));
}
// Usage in delete command (isDelete skips count validation):
const summarization = extractSummarizationType(args.annotationType);
const errors = validateAnnotationParams(summarization, { name: flags.name, isDelete: true });
if (errors.length > 0) {
this.error(errors.join('\n'));
}The CLI uses oclif with a directory-based command structure. Nested commands map to directory hierarchies:
src/commands/channels/
├── index.ts # BaseTopicCommand — "ably channels"
├── publish.ts # "ably channels publish"
├── subscribe.ts # "ably channels subscribe"
├── history.ts # "ably channels history"
├── occupancy/
│ ├── get.ts # "ably channels occupancy get"
│ └── subscribe.ts # "ably channels occupancy subscribe"
└── presence/
├── enter.ts # "ably channels presence enter"
└── subscribe.ts # "ably channels presence subscribe"
Following the same pattern, annotations commands will be placed in:
src/commands/channels/annotations/
├── index.ts # BaseTopicCommand — "ably channels annotations"
├── publish.ts # "ably channels annotations publish"
├── delete.ts # "ably channels annotations delete"
├── get.ts # "ably channels annotations get"
├── subscribe.ts # "ably channels annotations subscribe"
└── validation.ts # Shared validation utility
test/unit/commands/channels/annotations/
├── publish.test.ts
├── delete.test.ts
├── get.test.ts
├── subscribe.test.ts
└── validation.test.ts
All annotation commands extend AblyBaseCommand which provides:
createAblyRealtimeClient(flags)— creates authenticated Realtime clientcreateAblyRestClient(flags)— creates authenticated REST clientsetupConnectionStateLogging()— connection state event loggingsetupChannelStateLogging()— channel state event logginglogCliEvent()— structured event logging (verbose/JSON modes)formatJsonOutput()— JSON output formattingshouldOutputJson()— check for--json/--pretty-jsonflagssetupCleanupHandler()— resource cleanup with timeoutjsonError()— emit structured JSON error
Per the project's flag conventions in src/flags.ts, commands must use composable flag sets:
productApiFlags— core global flags + hidden product API flags (for all annotation commands)clientIdFlag—--client-idflag (forpublish,delete, andsubscribesince they create realtime connections)timeRangeFlags— not needed for annotations (annotations don't have time-range queries)
Output helpers from src/utils/output.ts:
progress(message)— progress indicator (appends...automatically)success(message)— green ✓ success message (must end with.)listening(description)— dim listening message with "Press Ctrl+C to exit."resource(name)— cyan resource name (never quoted)formatTimestamp(ts)— dim[timestamp]for event streams
File: src/commands/channels/annotations/publish.ts
Usage:
ably channels annotations publish <channelName> <msgSerial> <annotationType> [flags]
Arguments:
| Argument | Type | Required | Description |
|---|---|---|---|
channelName |
string |
Yes | The channel name (must have annotations enabled) |
msgSerial |
string |
Yes | The serial of the message to annotate |
annotationType |
string |
Yes | Annotation type (e.g., reactions:flag.v1) |
Flags:
| Flag | Type | Required | Description |
|---|---|---|---|
--name |
string |
Conditional | Annotation name (required for distinct, unique, multiple) |
--count |
integer |
Conditional | Count value (required for multiple) |
--data |
string |
No | Optional data payload (JSON string) |
--client-id |
string |
No | Override default client ID |
--json |
boolean |
No | Output in JSON format |
--pretty-json |
boolean |
No | Output in colorized JSON format |
Implementation approach:
import { Args, Flags } from '@oclif/core';
import * as Ably from 'ably';
import { AblyBaseCommand } from '../../../base-command.js';
import { clientIdFlag, productApiFlags } from '../../../flags.js';
import { resource, success } from '../../../utils/output.js';
import { extractSummarizationType, validateAnnotationParams } from './validation.js';
export default class ChannelsAnnotationsPublish extends AblyBaseCommand {
static override description = 'Publish an annotation on a message';
static override args = {
channel: Args.string({ description: 'Channel name', required: true }),
msgSerial: Args.string({ description: 'Message serial to annotate', required: true }),
annotationType: Args.string({ description: 'Annotation type (e.g., reactions:flag.v1)', required: true }),
};
static override flags = {
...productApiFlags,
...clientIdFlag,
name: Flags.string({ description: 'Annotation name (required for distinct/unique/multiple types)' }),
count: Flags.integer({ description: 'Count value (required for multiple type)' }),
data: Flags.string({ description: 'Optional data payload (JSON string)' }),
};
async run(): Promise<void> {
const { args, flags } = await this.parse(ChannelsAnnotationsPublish);
try {
// 1. Extract and validate summarization type
const summarization = extractSummarizationType(args.annotationType);
const errors = validateAnnotationParams(summarization, {
name: flags.name,
count: flags.count,
});
if (errors.length > 0) {
this.error(errors.join('\n'));
}
// 2. Build OutboundAnnotation
const annotation: Ably.OutboundAnnotation = { type: args.annotationType };
if (flags.name) annotation.name = flags.name;
if (flags.count !== undefined) annotation.count = flags.count;
if (flags.data) {
try {
annotation.data = JSON.parse(flags.data);
} catch {
this.error('Invalid JSON in --data flag. Please provide valid JSON.');
}
}
// 3. Create Ably Realtime client and publish
const client = await this.createAblyRealtimeClient(flags);
if (!client) return;
const channel = client.channels.get(args.channel);
await channel.annotations.publish(args.msgSerial, annotation);
// 4. Output success
if (this.shouldOutputJson(flags)) {
this.log(this.formatJsonOutput({
success: true,
channel: args.channel,
messageSerial: args.msgSerial,
annotationType: args.annotationType,
name: flags.name || null,
count: flags.count ?? null,
}, flags));
} else {
this.log(success(`Annotation published to channel ${resource(args.channel)}.`));
}
} catch (error) {
const errorMsg = `Error publishing annotation: ${error instanceof Error ? error.message : String(error)}`;
if (this.shouldOutputJson(flags)) {
this.jsonError({ error: errorMsg, success: false }, flags);
} else {
this.error(errorMsg);
}
}
}
}File: src/commands/channels/annotations/delete.ts
Usage:
ably channels annotations delete <channelName> <msgSerial> <annotationType> [flags]
Arguments & Flags: Same as publish (minus --count since delete doesn't use it).
| Flag | Type | Required | Description |
|---|---|---|---|
--name |
string |
Conditional | Annotation name (required for distinct, unique, multiple) |
--data |
string |
No | Optional data payload (JSON string) |
--client-id |
string |
No | Override default client ID |
--json |
boolean |
No | Output in JSON format |
--pretty-json |
boolean |
No | Output in colorized JSON format |
Implementation approach:
import { Args, Flags } from '@oclif/core';
import * as Ably from 'ably';
import { AblyBaseCommand } from '../../../base-command.js';
import { clientIdFlag, productApiFlags } from '../../../flags.js';
import { resource, success } from '../../../utils/output.js';
import { extractSummarizationType, validateAnnotationParams } from './validation.js';
export default class ChannelsAnnotationsDelete extends AblyBaseCommand {
static override description = 'Delete an annotation from a message';
static override args = {
channel: Args.string({ description: 'Channel name', required: true }),
msgSerial: Args.string({ description: 'Message serial of the annotated message', required: true }),
annotationType: Args.string({ description: 'Annotation type (e.g., reactions:flag.v1)', required: true }),
};
static override flags = {
...productApiFlags,
...clientIdFlag,
name: Flags.string({ description: 'Annotation name (required for distinct/unique/multiple types)' }),
data: Flags.string({ description: 'Optional data payload (JSON string)' }),
};
async run(): Promise<void> {
const { args, flags } = await this.parse(ChannelsAnnotationsDelete);
try {
// 1. Validate (same as publish, but count not needed for delete via isDelete flag)
const summarization = extractSummarizationType(args.annotationType);
const errors = validateAnnotationParams(summarization, {
name: flags.name,
isDelete: true,
});
if (errors.length > 0) {
this.error(errors.join('\n'));
}
// 2. Build OutboundAnnotation
const annotation: Ably.OutboundAnnotation = { type: args.annotationType };
if (flags.name) annotation.name = flags.name;
if (flags.data) {
try {
annotation.data = JSON.parse(flags.data);
} catch {
this.error('Invalid JSON in --data flag. Please provide valid JSON.');
}
}
// 3. Create client and delete
const client = await this.createAblyRealtimeClient(flags);
if (!client) return;
const channel = client.channels.get(args.channel);
await channel.annotations.delete(args.msgSerial, annotation);
// 4. Output success
if (this.shouldOutputJson(flags)) {
this.log(this.formatJsonOutput({
success: true,
channel: args.channel,
messageSerial: args.msgSerial,
annotationType: args.annotationType,
name: flags.name || null,
}, flags));
} else {
this.log(success(`Annotation deleted from channel ${resource(args.channel)}.`));
}
} catch (error) {
const errorMsg = `Error deleting annotation: ${error instanceof Error ? error.message : String(error)}`;
if (this.shouldOutputJson(flags)) {
this.jsonError({ error: errorMsg, success: false }, flags);
} else {
this.error(errorMsg);
}
}
}
}File: src/commands/channels/annotations/get.ts
Usage:
ably channels annotations get <channelName> <msgSerial> [flags]
Arguments:
| Argument | Type | Required | Description |
|---|---|---|---|
channelName |
string |
Yes | The channel name |
msgSerial |
string |
Yes | The serial of the message to get annotations for |
Flags:
| Flag | Type | Required | Description |
|---|---|---|---|
--limit |
integer |
No | Maximum number of results to return (default: 100, max: 1000) |
--json |
boolean |
No | Output in JSON format |
--pretty-json |
boolean |
No | Output in colorized JSON format |
SDK method: channel.annotations.get(messageSerial, params) returns Promise<PaginatedResult<Annotation>>.
The GetAnnotationsParams interface only has one field:
limit?: number— upper limit on annotations returned (default: 100, max: 1000)
Implementation approach:
This is a REST-style paginated query, similar to channels history. It uses a REST client since annotations.get() is a REST call under the hood (even on Realtime, it delegates to RestAnnotations.prototype.get).
import { Args, Flags } from '@oclif/core';
import * as Ably from 'ably';
import chalk from 'chalk';
import { AblyBaseCommand } from '../../../base-command.js';
import { productApiFlags } from '../../../flags.js';
import { formatTimestamp, resource } from '../../../utils/output.js';
export default class ChannelsAnnotationsGet extends AblyBaseCommand {
static override description = 'Get annotations for a message';
static override examples = [
'$ ably channels annotations get my-channel msg-serial-123',
'$ ably channels annotations get my-channel msg-serial-123 --limit 50',
'$ ably channels annotations get my-channel msg-serial-123 --json',
];
static override args = {
channel: Args.string({ description: 'Channel name', required: true }),
msgSerial: Args.string({ description: 'Message serial to get annotations for', required: true }),
};
static override flags = {
...productApiFlags,
limit: Flags.integer({
default: 100,
description: 'Maximum number of results to return (default: 100)',
}),
};
async run(): Promise<void> {
const { args, flags } = await this.parse(ChannelsAnnotationsGet);
try {
// 1. Create REST client (get is a REST operation)
const client = await this.createAblyRestClient(flags);
if (!client) return;
// 2. Get channel and fetch annotations
const channel = client.channels.get(args.channel);
const params: Ably.GetAnnotationsParams = {};
if (flags.limit !== undefined) {
params.limit = flags.limit;
}
const result = await channel.annotations.get(args.msgSerial, params);
const annotations = result.items;
// 3. Output results
if (this.shouldOutputJson(flags)) {
this.log(this.formatJsonOutput(annotations.map((annotation, index) => ({
index: index + 1,
id: annotation.id,
action: annotation.action,
type: annotation.type,
name: annotation.name || null,
clientId: annotation.clientId || null,
count: annotation.count ?? null,
data: annotation.data ?? null,
messageSerial: annotation.messageSerial,
serial: annotation.serial,
timestamp: annotation.timestamp
? new Date(annotation.timestamp).toISOString()
: null,
})), flags));
} else {
if (annotations.length === 0) {
this.log(`No annotations found for message ${resource(args.msgSerial)} on channel ${resource(args.channel)}.`);
return;
}
this.log(`Annotations for message ${resource(args.msgSerial)} on channel ${resource(args.channel)}:\n`);
for (const [index, annotation] of annotations.entries()) {
const timestamp = annotation.timestamp
? new Date(annotation.timestamp).toISOString()
: new Date().toISOString();
const actionLabel = annotation.action === 'annotation.create'
? chalk.green('CREATE')
: chalk.red('DELETE');
this.log(`${chalk.dim(`[${index + 1}]`)} ${formatTimestamp(timestamp)} ${actionLabel} | ${chalk.dim('Type:')} ${annotation.type} | ${chalk.dim('Name:')} ${annotation.name || '(none)'} | ${chalk.dim('Client:')} ${annotation.clientId ? chalk.blue(annotation.clientId) : '(none)'}`);
if (annotation.count !== undefined) {
this.log(` ${chalk.dim('Count:')} ${annotation.count}`);
}
if (annotation.data) {
this.log(` ${chalk.dim('Data:')} ${JSON.stringify(annotation.data)}`);
}
}
if (annotations.length === flags.limit) {
this.log('');
this.log(chalk.yellow(`Showing maximum of ${flags.limit} annotations. Use --limit to show more.`));
}
}
} catch (error) {
const errorMsg = `Error retrieving annotations: ${error instanceof Error ? error.message : String(error)}`;
if (this.shouldOutputJson(flags)) {
this.jsonError({ error: errorMsg, success: false }, flags);
} else {
this.error(errorMsg);
}
}
}
}File: src/commands/channels/annotations/subscribe.ts
Usage:
ably channels annotations subscribe <channelName> [flags]
Arguments:
| Argument | Type | Required | Description |
|---|---|---|---|
channelName |
string |
Yes | The channel name to subscribe to annotation events |
Flags:
| Flag | Type | Required | Description |
|---|---|---|---|
--duration |
integer |
No | Automatically exit after N seconds |
--client-id |
string |
No | Override default client ID |
--json |
boolean |
No | Output in JSON format |
--pretty-json |
boolean |
No | Output in colorized JSON format |
Implementation approach:
This is a long-running command that listens for annotation events. It follows the same pattern as channels subscribe and channels occupancy subscribe.
Important SDK detail: The SDK's _processIncoming() emits events keyed by annotation.type (not annotation.action). When calling subscribe(listener) without a type filter, the listener receives all annotation events regardless of type.
The subscribe command must auto-unsubscribe on cleanup (per improvement notes). When the command exits (via Ctrl+C or --duration timeout), it should call channel.annotations.unsubscribe() to clean up listeners.
import { Args, Flags } from '@oclif/core';
import * as Ably from 'ably';
import chalk from 'chalk';
import { AblyBaseCommand } from '../../../base-command.js';
import { clientIdFlag, productApiFlags } from '../../../flags.js';
import { waitUntilInterruptedOrTimeout } from '../../../utils/long-running.js';
import {
formatTimestamp,
listening,
progress,
resource,
success,
} from '../../../utils/output.js';
export default class ChannelsAnnotationsSubscribe extends AblyBaseCommand {
static override description = 'Subscribe to annotation events on a channel';
static override args = {
channel: Args.string({ description: 'Channel name', required: true }),
};
static override flags = {
...productApiFlags,
...clientIdFlag,
duration: Flags.integer({
description: 'Automatically exit after N seconds',
char: 'D',
}),
};
async run(): Promise<void> {
const { args, flags } = await this.parse(ChannelsAnnotationsSubscribe);
try {
// 1. Create Realtime client
const client = await this.createAblyRealtimeClient(flags);
if (!client) return;
// 2. Get channel with ANNOTATION_SUBSCRIBE mode
const channel = client.channels.get(args.channel, {
modes: ['ANNOTATION_SUBSCRIBE'],
});
// 3. Setup connection & channel state logging
this.setupConnectionStateLogging(client, flags, { includeUserFriendlyMessages: true });
this.setupChannelStateLogging(channel, flags, { includeUserFriendlyMessages: true });
if (!this.shouldOutputJson(flags)) {
this.log(progress(`Attaching to channel: ${resource(args.channel)}`));
}
// 4. Subscribe to annotations
await channel.annotations.subscribe((annotation: Ably.Annotation) => {
const timestamp = annotation.timestamp
? new Date(annotation.timestamp).toISOString()
: new Date().toISOString();
const event = {
action: annotation.action, // 'annotation.create' or 'annotation.delete'
channel: args.channel,
clientId: annotation.clientId || null,
count: annotation.count ?? null,
data: annotation.data ?? null,
messageSerial: annotation.messageSerial,
name: annotation.name || null,
serial: annotation.serial,
timestamp,
type: annotation.type,
};
if (this.shouldOutputJson(flags)) {
this.log(this.formatJsonOutput(event, flags));
} else {
// Human-readable output using project output helpers
const actionLabel = annotation.action === 'annotation.create'
? chalk.green('CREATE')
: chalk.red('DELETE');
this.log(`${formatTimestamp(timestamp)} ${actionLabel} | ${chalk.dim('Type:')} ${annotation.type} | ${chalk.dim('Name:')} ${annotation.name || '(none)'} | ${chalk.dim('Client:')} ${annotation.clientId ? chalk.blue(annotation.clientId) : '(none)'}`);
if (annotation.data) {
this.log(` ${chalk.dim('Data:')} ${JSON.stringify(annotation.data)}`);
}
this.log('');
}
});
// 5. Show success message
if (!this.shouldOutputJson(flags)) {
this.log(success(`Subscribed to annotations on channel ${resource(args.channel)}.`));
this.log(listening('Listening for annotation events.'));
this.log('');
}
// 6. Wait until interrupted or timeout
const exitReason = await waitUntilInterruptedOrTimeout(flags.duration);
// 7. Auto-unsubscribe on cleanup
channel.annotations.unsubscribe();
this.logCliEvent(flags, 'annotations:subscribe', 'cleanup', 'Unsubscribed from annotations', {
exitReason,
});
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
if (this.shouldOutputJson(flags)) {
this.jsonError({ error: errorMsg, success: false }, flags);
} else {
this.error(`Error: ${errorMsg}`);
}
}
}
}File: src/commands/channels/annotations/index.ts
This is the topic command that lists available annotation subcommands when run without arguments.
import { BaseTopicCommand } from '../../../base-topic-command.js';
export default class ChannelsAnnotations extends BaseTopicCommand {
protected topicName = 'channels:annotations';
protected commandGroup = 'Channel annotations';
static override description = 'Manage annotations on Ably channel messages';
static override examples = [
"<%= config.bin %> <%= command.id %> publish my-channel msg-serial-123 reactions:flag.v1",
"<%= config.bin %> <%= command.id %> delete my-channel msg-serial-123 reactions:flag.v1",
"<%= config.bin %> <%= command.id %> get my-channel msg-serial-123",
"<%= config.bin %> <%= command.id %> subscribe my-channel",
];
}To avoid code duplication between publish and delete, extract the validation logic into a shared utility:
File: src/commands/channels/annotations/validation.ts
/**
* Extract the summarization method from an annotation type string.
* Format: "namespace:summarization.version" → returns "summarization"
*/
export function extractSummarizationType(annotationType: string): string {
const colonIndex = annotationType.indexOf(':');
if (colonIndex === -1) {
throw new Error(
'Invalid annotation type format. Expected "namespace:summarization.version" (e.g., "reactions:flag.v1")'
);
}
const summarizationPart = annotationType.slice(colonIndex + 1);
const dotIndex = summarizationPart.indexOf('.');
if (dotIndex === -1) {
throw new Error(
'Invalid annotation type format. Expected "namespace:summarization.version" (e.g., "reactions:flag.v1")'
);
}
return summarizationPart.slice(0, dotIndex);
}
/** Summarization types that require a `name` parameter */
const NAME_REQUIRED_TYPES = ['distinct', 'unique', 'multiple'];
/** Summarization types that require a `count` parameter */
const COUNT_REQUIRED_TYPES = ['multiple'];
/**
* Validate that the required parameters are present for the given summarization type.
*/
export function validateAnnotationParams(
summarization: string,
options: { name?: string; count?: number; isDelete?: boolean },
): string[] {
const errors: string[] = [];
if (NAME_REQUIRED_TYPES.includes(summarization) && !options.name) {
errors.push(`--name is required for "${summarization}" annotation types`);
}
// count is only required for publish, not delete
if (!options.isDelete && COUNT_REQUIRED_TYPES.includes(summarization) && options.count === undefined) {
errors.push(`--count is required for "${summarization}" annotation types`);
}
return errors;
}The existing MockRealtimeChannel interface needs to be extended to include an annotations property:
Changes to test/helpers/mock-ably-realtime.ts
// Add new interface
export interface MockAnnotations {
publish: Mock;
delete: Mock;
subscribe: Mock;
unsubscribe: Mock;
get: Mock;
_emitter: AblyEventEmitter;
_emit: (annotation: Ably.Annotation) => void;
}
// Update MockRealtimeChannel to include annotations
export interface MockRealtimeChannel {
// ... existing fields ...
annotations: MockAnnotations;
}Add a createMockAnnotations() factory function following the same pattern as createMockPresence():
function createMockAnnotations(): MockAnnotations {
const emitter = new EventEmitter();
const annotations: MockAnnotations = {
publish: vi.fn().mockImplementation(async () => {}),
delete: vi.fn().mockImplementation(async () => {}),
subscribe: vi.fn((typeOrCallback, callback?) => {
const cb = callback ?? typeOrCallback;
const event = callback ? typeOrCallback : null;
emitter.on(event, cb);
}),
unsubscribe: vi.fn((typeOrCallback?, callback?) => {
if (!typeOrCallback) {
emitter.off();
} else if (typeof typeOrCallback === 'function') {
emitter.off(null, typeOrCallback);
} else if (callback) {
emitter.off(typeOrCallback, callback);
}
}),
get: vi.fn().mockResolvedValue({ items: [], hasNext: () => false, isLast: () => true }),
_emitter: emitter,
// Note: SDK emits on annotation.type, not annotation.action
_emit: (annotation) => {
emitter.emit(annotation.type || '', annotation);
},
};
return annotations;
}Then add annotations: createMockAnnotations() to the createMockChannel() function.
Each command gets a dedicated test file following the pattern in test/unit/commands/channels/publish.test.ts:
| Test Case | Description |
|---|---|
Publish with total.v1 type |
Verify publish with only type arg succeeds |
Publish with flag.v1 type |
Verify publish with only type arg succeeds |
Publish with distinct.v1 + --name |
Verify publish with name flag succeeds |
Publish with unique.v1 + --name |
Verify publish with name flag succeeds |
Publish with multiple.v1 + --name + --count |
Verify publish with both flags succeeds |
Missing --name for distinct.v1 |
Verify validation error |
Missing --count for multiple.v1 |
Verify validation error |
| Invalid annotation type format | Verify format validation error |
| JSON output mode | Verify structured JSON output |
| API error handling | Verify error propagation |
With --data flag |
Verify data payload is included |
| Test Case | Description |
|---|---|
Delete with flag.v1 type |
Verify delete with only type arg succeeds |
Delete with distinct.v1 + --name |
Verify delete with name flag succeeds |
Missing --name for unique.v1 |
Verify validation error |
| JSON output mode | Verify structured JSON output |
| API error handling | Verify error propagation |
| Test Case | Description |
|---|---|
| Get annotations with default limit | Verify channel.annotations.get() is called with { limit: 100 } |
Get annotations with custom --limit |
Verify limit param is passed correctly |
| Empty result set | Verify "No annotations found" message |
| Multiple annotations returned | Verify all annotations are displayed |
| JSON output mode | Verify structured JSON output with all annotation fields |
| API error handling | Verify error propagation |
| Limit hint message | Verify hint shown when result count equals limit |
| Test Case | Description |
|---|---|
| Subscribe to channel | Verify channel.annotations.subscribe() is called |
Receive annotation.create event |
Verify create event is displayed |
Receive annotation.delete event |
Verify delete event is displayed |
| JSON output mode | Verify structured JSON output for events |
Channel with ANNOTATION_SUBSCRIBE mode |
Verify channel mode is set correctly |
| Duration flag | Verify auto-exit after timeout |
| Auto-unsubscribe on cleanup | Verify channel.annotations.unsubscribe() is called on exit |
| Test Case | Description |
|---|---|
| Parse valid annotation types | Various valid formats |
| Reject invalid formats | Missing colon, missing dot, etc. |
Validate total.v1 params |
No extra params needed |
Validate distinct.v1 params |
Name required |
Validate multiple.v1 params |
Name + count required |
| Unknown summarization type | Should pass (forward compatibility) |
-
Create shared validation utility
-
Create topic index command
-
Create
publishcommand -
Create
deletecommand -
Create
getcommand -
Create
subscribecommand -
Update mock helpers
- Add
MockAnnotationstotest/helpers/mock-ably-realtime.ts - Add
annotationsproperty to mock channels
- Add
-
Write unit tests
-
Run mandatory workflow
pnpm prepare # Build + update manifest/README pnpm exec eslint . # Lint (must be 0 errors) pnpm test:unit # Run unit tests
✓ Annotation published to channel my-channel.
{
"success": true,
"channel": "my-channel",
"messageSerial": "01ARZ3NDEKTSV4RRFFQ69G5FAV@1614556800000-0",
"annotationType": "reactions:flag.v1",
"name": null,
"count": null
}✓ Annotation deleted from channel my-channel.
Annotations for message 01ARZ3NDEKTSV4RRFFQ69G5FAV@1614556800000-0 on channel my-channel:
[1] [2026-03-05T09:00:00.000Z] CREATE | Type: reactions:flag.v1 | Name: (none) | Client: user-123
[2] [2026-03-05T09:00:01.000Z] CREATE | Type: reactions:distinct.v1 | Name: thumbsup | Client: user-456
Data: {"emoji": "👍"}
[
{
"index": 1,
"id": "ann-001",
"action": "annotation.create",
"type": "reactions:flag.v1",
"name": null,
"clientId": "user-123",
"count": null,
"data": null,
"messageSerial": "01ARZ3NDEKTSV4RRFFQ69G5FAV@1614556800000-0",
"serial": "01ARZ3NDEKTSV4RRFFQ69G5FAV@1614556800001-0",
"timestamp": "2026-03-05T09:00:00.000Z"
}
]Attaching to channel: my-channel...
✓ Subscribed to annotations on channel my-channel.
Listening for annotation events. Press Ctrl+C to exit.
[2026-03-05T09:00:00.000Z] CREATE | Type: reactions:flag.v1 | Name: (none) | Client: user-123
[2026-03-05T09:00:05.000Z] DELETE | Type: reactions:flag.v1 | Name: (none) | Client: user-123
{
"action": "annotation.create",
"channel": "my-channel",
"type": "reactions:flag.v1",
"name": null,
"clientId": "user-123",
"messageSerial": "01ARZ3NDEKTSV4RRFFQ69G5FAV@1614556800000-0",
"serial": "01ARZ3NDEKTSV4RRFFQ69G5FAV@1614556800001-0",
"count": null,
"data": null,
"timestamp": "2026-03-05T09:00:00.000Z"
}Error: --name is required for "distinct" annotation types.
Usage:
ably channels annotations publish <channelName> <msgSerial> <annotationType> [--name <name>]
Annotation types that require --name: distinct.v1, unique.v1, multiple.v1
Annotation types that require --count: multiple.v1
-
Forward compatibility: Unknown summarization types should be allowed (no validation error) since new types may be added server-side.
-
Client ID requirement:
flag.v1,distinct.v1, andunique.v1require identified clients. The CLI auto-generates aclientIdviasetClientId()unless--client-id noneis specified. A warning should be shown if--client-id noneis used with these types. -
Channel mode for subscribe: The subscribe command must request
ANNOTATION_SUBSCRIBEmode viaChannelOptions.modes. This is distinct from regular message subscription. The SDK will throwErrorInfo(93001)if you try to subscribe without this mode. -
Data payload parsing: The
--dataflag accepts a JSON string. Invalid JSON should produce a clear error message. -
REST vs Realtime transport: Both
RestAnnotationsandRealtimeAnnotationssupportpublish,delete, andget. Thedeletemethod delegates topublishwithaction = 'annotation.delete'in both cases (seerestannotations.ts:96andrealtimeannotations.ts:51). Thegetmethod on Realtime delegates toRestAnnotations.prototype.get(seerealtimeannotations.ts:99). For the CLI:publishanddelete→ use Realtime client (consistent with other channel commands)get→ use REST client (it's a REST call, similar tochannels history)subscribe→ use Realtime client (requires persistent connection)
-
Annotations are not encrypted: The SDK does not encrypt annotation data (see
annotation.ts:69) because the server needs to parse data for summarisation. No--cipher-keyflag is needed. -
Subscribe event emission: The SDK emits annotation events keyed by
annotation.type(notannotation.action). See_processIncoming():this.subscriptions.emit(annotation.type || '', annotation). This meanssubscribe(type, listener)filters by annotation type string, whilesubscribe(listener)receives all. -
Auto-unsubscribe on cleanup: The subscribe command must call
channel.annotations.unsubscribe()when exiting to clean up listeners properly. -
Web CLI mode: Annotations commands should work in web CLI mode since they are data-plane operations, similar to existing channel commands.
- No new npm dependencies required — the
ably@^2.14.0SDK already includes full annotations support. - No changes to
src/services/control-api.ts— annotations use the product API (Realtime/REST), not the Control API. - No changes to
src/base-command.ts— all needed utilities are already available.
| File | Purpose |
|---|---|
src/commands/channels/annotations/index.ts |
Topic command (lists subcommands) |
src/commands/channels/annotations/publish.ts |
Publish annotation command |
src/commands/channels/annotations/delete.ts |
Delete annotation command |
src/commands/channels/annotations/get.ts |
Get annotations for a message (paginated) |
src/commands/channels/annotations/subscribe.ts |
Subscribe to annotation events command |
src/commands/channels/annotations/validation.ts |
Shared validation utility |
test/unit/commands/channels/annotations/publish.test.ts |
Publish unit tests |
test/unit/commands/channels/annotations/delete.test.ts |
Delete unit tests |
test/unit/commands/channels/annotations/get.test.ts |
Get unit tests |
test/unit/commands/channels/annotations/subscribe.test.ts |
Subscribe unit tests |
test/unit/commands/channels/annotations/validation.test.ts |
Validation unit tests |
| File | Change |
|---|---|
test/helpers/mock-ably-realtime.ts |
Add MockAnnotations interface and annotations property to mock channels |
This section documents all changes made to the original implementation plan based on thorough SDK source code review:
| # | Area | Change | Reason |
|---|---|---|---|
| 1 | New command | Added ably channels annotations get (Section 5.3) |
Missing command for channel.annotations.get(messageSerial, params) |
| 2 | Flag architecture | Changed ...AblyBaseCommand.globalFlags → ...productApiFlags + ...clientIdFlag |
Per project conventions in src/flags.ts and .claude/CLAUDE.md |
| 3 | Output helpers | Use progress(), success(), listening(), resource(), formatTimestamp() from src/utils/output.ts |
Per project output conventions |
| 4 | Subscribe cleanup | Added channel.annotations.unsubscribe() on exit |
Per annotations-improvments.txt |
| 5 | SDK event emission | Documented that SDK emits on annotation.type not annotation.action |
Source code review of realtimeannotations.ts:91 |
| 6 | Key Types | Added encoding, extras fields to Annotation; added GetAnnotationsParams, PaginatedResult, ANNOTATION_PUBLISH |
Complete type documentation from ably.d.ts |
| 7 | SDK API table | Added subscribe(type, listener), unsubscribe(listener), unsubscribe() overloads |
Complete API surface from type declarations |
| 8 | REST delete | Documented that RestAnnotations.delete() exists at runtime but not in .d.ts |
Source code review of restannotations.ts:96 |
| 9 | Transport choice | Clarified: get uses REST client, publish/delete use Realtime, subscribe uses Realtime |
get is REST under the hood; publish/delete consistent with other commands |
| 10 | No encryption | Added note that annotations are not encrypted | Source code review of annotation.ts:69 |
| 11 | Mock _emit |
Fixed to emit on annotation.type (not annotation.action) |
Matches SDK behavior in _processIncoming() |
| 12 | Mock get return |
Added hasNext() and isLast() to mock return value |
Matches PaginatedResult interface |
| 13 | Topic examples | Added get subcommand to index topic examples |
Complete subcommand listing |
| 14 | Error handling | Added try/catch with jsonError() pattern to all commands |
Per project conventions |
| 15 | SDK internals | Added new "SDK Internal Details" subsection in Section 2 | Useful implementation reference from source code review |