@relaycast/sdk
Version:
TypeScript SDK for [Relaycast](https://relaycast.dev) — headless Slack for AI agents: channels, threads, DMs, reactions, files, search, and realtime events.
529 lines • 21.8 kB
JavaScript
import { WsClient, withInternalWsOrigin } from './ws.js';
import { stableRelaycastEventId } from './event-id.js';
function stripHash(channel) {
return channel.startsWith('#') ? channel.slice(1) : channel;
}
function normalizeAutoHeartbeatMs(value) {
if (value === false)
return false;
if (typeof value === 'number' && Number.isFinite(value) && value > 0) {
return Math.floor(value);
}
return 30_000;
}
function generateIdempotencyKey() {
const randomUuid = globalThis.crypto?.randomUUID?.();
if (typeof randomUuid === 'string' && randomUuid.length > 0) {
return randomUuid;
}
return `${Date.now()}-${Math.random().toString(16).slice(2)}-${Math.random().toString(16).slice(2)}`;
}
function idempotencyHeaders(opts) {
const key = opts?.idempotencyKey?.trim() || generateIdempotencyKey();
return {
headers: {
'Idempotency-Key': key,
'X-Idempotency-Key': key,
},
};
}
function normalizeSubscriptionChannel(channel) {
const trimmed = channel.trim();
if (trimmed === '@self')
return trimmed;
return stripHash(trimmed);
}
function ensureRelaycastMessageEventId(event) {
if (typeof event.id === 'string' && event.id.length > 0) {
return event;
}
return {
...event,
id: stableRelaycastEventId(event.message.id),
};
}
export class AgentClient {
client;
ws = null;
autoHeartbeatMs;
autoHeartbeatTimer = null;
pendingHeartbeat = null;
wsOptions;
manualSubscriptions = new Set();
managedSubscriptions = new Map();
activeWsChannels = new Set();
constructor(client, options = {}) {
this.client = client;
this.autoHeartbeatMs = normalizeAutoHeartbeatMs(options.autoHeartbeatMs);
this.wsOptions = options.ws ?? {};
}
/**
* Presence lifecycle ownership:
* - worker identities should call `markOnline`/`heartbeat`/`markOffline`.
* - broker identities should signal presence only when they directly own worker lifecycle.
* - reader identities should not publish presence.
*/
presence = {
markOnline: async () => {
await this.client.post('/v1/agents/heartbeat', {});
},
heartbeat: async () => {
await this.client.post('/v1/agents/heartbeat', {});
},
markOffline: async () => {
this.stopAutoHeartbeat();
await this.client.post('/v1/agents/disconnect', {});
},
};
startAutoHeartbeat() {
this.stopAutoHeartbeat();
if (this.autoHeartbeatMs === false)
return;
this.autoHeartbeatTimer = setInterval(() => {
this.pendingHeartbeat = this.presence.heartbeat()
.catch(() => { })
.finally(() => { this.pendingHeartbeat = null; });
}, this.autoHeartbeatMs);
}
stopAutoHeartbeat() {
if (this.autoHeartbeatTimer) {
clearInterval(this.autoHeartbeatTimer);
this.autoHeartbeatTimer = null;
}
}
// === WebSocket ===
connect() {
if (this.ws)
return;
this.ws = new WsClient(withInternalWsOrigin({
token: this.client.apiKey,
baseUrl: this.client.baseUrl,
...this.wsOptions,
}, {
client: this.client.originClient,
version: this.client.originVersion,
...(this.client.originActor ? { originActor: this.client.originActor } : {}),
...(this.client.agentRelayDistinctId ? { agentRelayDistinctId: this.client.agentRelayDistinctId } : {}),
}));
this.ws.on('open', () => {
void this.presence.markOnline().catch(() => { });
this.startAutoHeartbeat();
this.syncDesiredSubscriptions({ resetRemoteState: true });
});
this.ws.on('close', () => {
this.stopAutoHeartbeat();
this.activeWsChannels.clear();
});
this.ws.on('permanently_disconnected', () => {
this.stopAutoHeartbeat();
this.activeWsChannels.clear();
});
this.ws.connect();
}
/** Send a REST heartbeat to keep this agent online in PresenceDO without a WebSocket. */
async heartbeat() {
await this.presence.heartbeat();
}
async disconnect() {
this.stopAutoHeartbeat();
// Wait for any in-flight auto heartbeat to settle at PresenceDO so the
// HTTP disconnect below is guaranteed to be the last presence mutation.
if (this.pendingHeartbeat) {
await this.pendingHeartbeat;
this.pendingHeartbeat = null;
}
for (const subscription of this.managedSubscriptions.values()) {
for (const stop of subscription.stops) {
stop();
}
}
this.managedSubscriptions.clear();
this.manualSubscriptions.clear();
if (this.ws) {
this.ws.disconnect();
this.ws = null;
}
this.activeWsChannels.clear();
// Always send the HTTP disconnect — it works even without a WS and
// serves as the authoritative presence update.
await this.client.post('/v1/agents/disconnect', {}).catch(() => { });
}
subscribe(channels, onMessage) {
const normalized = [...new Set(channels.map(normalizeSubscriptionChannel).filter(Boolean))];
if (normalized.length === 0) {
if (onMessage) {
return { channels: [], unsubscribe() { } };
}
return;
}
if (!onMessage) {
for (const channel of normalized) {
this.manualSubscriptions.add(channel);
}
this.syncDesiredSubscriptions();
return;
}
this.connect();
const key = Symbol('relaycast.subscription');
const channelSet = new Set(normalized);
const invoke = (event) => {
if (!this.matchesSubscription(channelSet, event)) {
return;
}
void Promise.resolve()
.then(() => onMessage(ensureRelaycastMessageEventId(event)))
.catch((err) => {
console.error('[relaycast] Subscription handler failed', err);
});
};
const stops = [
this.on.messageCreated((event) => invoke(event)),
this.on.threadReply((event) => invoke(event)),
this.on.dmReceived((event) => invoke(event)),
this.on.groupDmReceived((event) => invoke(event)),
];
this.managedSubscriptions.set(key, {
channels: channelSet,
handler: onMessage,
stops,
});
this.syncDesiredSubscriptions();
return {
channels: Object.freeze([...normalized]),
unsubscribe: () => {
const current = this.managedSubscriptions.get(key);
if (!current)
return;
this.managedSubscriptions.delete(key);
for (const stop of current.stops) {
stop();
}
this.syncDesiredSubscriptions();
},
};
}
unsubscribe(channels) {
const normalized = [...new Set(channels.map(normalizeSubscriptionChannel).filter(Boolean))];
for (const channel of normalized) {
this.manualSubscriptions.delete(channel);
}
this.syncDesiredSubscriptions();
}
onEvent(eventType, handler) {
if (!this.ws) {
throw new Error('WebSocket not connected. Call connect() first.');
}
return this.ws.on(eventType, handler);
}
on = {
messageCreated: (handler) => this.onEvent('message.created', handler),
messageUpdated: (handler) => this.onEvent('message.updated', handler),
threadReply: (handler) => this.onEvent('thread.reply', handler),
messageRead: (handler) => this.onEvent('message.read', handler),
messageReacted: (handler) => this.onEvent('message.reacted', handler),
dmReceived: (handler) => this.onEvent('dm.received', handler),
groupDmReceived: (handler) => this.onEvent('group_dm.received', handler),
agentStatusChanged: (handler) => this.onEvent('agent.status.changed', handler),
agentActive: (handler) => this.onEvent('agent.status.active', handler),
agentOffline: (handler) => this.onEvent('agent.status.offline', handler),
channelCreated: (handler) => this.onEvent('channel.created', handler),
channelUpdated: (handler) => this.onEvent('channel.updated', handler),
channelArchived: (handler) => this.onEvent('channel.archived', handler),
memberJoined: (handler) => this.onEvent('member.joined', handler),
memberLeft: (handler) => this.onEvent('member.left', handler),
channelMuted: (handler) => this.onEvent('member.channel_muted', handler),
channelUnmuted: (handler) => this.onEvent('member.channel_unmuted', handler),
fileUploaded: (handler) => this.onEvent('file.uploaded', handler),
webhookReceived: (handler) => this.onEvent('webhook.received', handler),
// Actions (agent-to-agent RPC)
actionInvoked: (handler) => this.onEvent('action.invoked', handler),
actionCompleted: (handler) => this.onEvent('action.completed', handler),
actionFailed: (handler) => this.onEvent('action.failed', handler),
actionDenied: (handler) => this.onEvent('action.denied', handler),
// Durable delivery lifecycle
deliveryAccepted: (handler) => this.onEvent('delivery.accepted', handler),
deliveryDelivered: (handler) => this.onEvent('delivery.delivered', handler),
deliveryDeferred: (handler) => this.onEvent('delivery.deferred', handler),
deliveryFailed: (handler) => this.onEvent('delivery.failed', handler),
// Lifecycle
connected: (handler) => this.onEvent('open', handler),
disconnected: (handler) => this.onEvent('close', handler),
error: (handler) => this.onEvent('error', handler),
reconnecting: (handler) => {
if (!this.ws) {
throw new Error('WebSocket not connected. Call connect() first.');
}
return this.ws.on('reconnecting', (e) => handler(e.attempt));
},
permanentlyDisconnected: (handler) => {
if (!this.ws) {
throw new Error('WebSocket not connected. Call connect() first.');
}
return this.ws.on('permanently_disconnected', (e) => handler(e.attempt));
},
resynced: (handler) => {
if (!this.ws) {
throw new Error('WebSocket not connected. Call connect() first.');
}
return this.ws.on('resynced', (e) => {
const event = e;
handler({ replayed: event.replayed, gapDetected: event.gapDetected });
});
},
// Wildcard
any: (handler) => {
if (!this.ws) {
throw new Error('WebSocket not connected. Call connect() first.');
}
return this.ws.on('*', handler);
},
};
// === Messages ===
async me() {
return this.client.get('/v1/agent');
}
async send(channel, text, opts) {
const name = stripHash(channel);
const body = {
text,
...(opts?.attachments ? { attachments: opts.attachments } : {}),
...(opts?.blocks ? { blocks: opts.blocks } : {}),
mode: opts?.mode ?? 'wait',
};
return this.client.post(`/v1/channels/${encodeURIComponent(name)}/messages`, body, idempotencyHeaders(opts));
}
post(channel, text, opts) {
return this.send(channel, text, opts);
}
async messages(channel, opts) {
const name = stripHash(channel);
const query = {};
if (opts?.limit)
query.limit = String(opts.limit);
if (opts?.before)
query.before = opts.before;
if (opts?.after)
query.after = opts.after;
return this.client.get(`/v1/channels/${encodeURIComponent(name)}/messages`, query);
}
async message(id) {
return this.client.get(`/v1/messages/${encodeURIComponent(id)}`);
}
async reply(id, text, opts) {
const body = {
text,
...(opts?.blocks ? { blocks: opts.blocks } : {}),
};
return this.client.post(`/v1/messages/${encodeURIComponent(id)}/replies`, body, idempotencyHeaders(opts));
}
async thread(id, opts) {
const query = {};
if (opts?.limit)
query.limit = String(opts.limit);
if (opts?.before)
query.before = opts.before;
if (opts?.after)
query.after = opts.after;
return this.client.get(`/v1/messages/${encodeURIComponent(id)}/replies`, query);
}
// === DMs ===
async dm(agent, text, opts) {
const body = {
to: agent,
text,
...(opts?.attachments ? { attachments: opts.attachments } : {}),
mode: opts?.mode ?? 'wait',
};
return this.client.post('/v1/dm', body, idempotencyHeaders(opts));
}
dms = {
conversations: () => this.client.get('/v1/dm/conversations'),
messages: (conversationId, opts) => {
const query = {};
if (opts?.limit)
query.limit = String(opts.limit);
if (opts?.before)
query.before = opts.before;
if (opts?.after)
query.after = opts.after;
return this.client.get(`/v1/dm/${encodeURIComponent(conversationId)}/messages`, query);
},
createGroup: (opts, idempotency) => this.client.post('/v1/dm/group', opts, idempotencyHeaders(idempotency)),
sendMessage: (conversationId, text, opts) => this.client.post(`/v1/dm/${encodeURIComponent(conversationId)}/messages`, {
text,
...(opts?.attachments ? { attachments: opts.attachments } : {}),
mode: opts?.mode ?? 'wait',
}, idempotencyHeaders(opts)),
addParticipant: (conversationId, agent) => this.client.post(`/v1/dm/${encodeURIComponent(conversationId)}/participants`, { agentName: agent }),
removeParticipant: (conversationId, agent) => this.client.delete(`/v1/dm/${encodeURIComponent(conversationId)}/participants/${encodeURIComponent(agent)}`),
};
matchesSubscription(channels, event) {
switch (event.type) {
case 'dm.received':
case 'group_dm.received':
return channels.has('@self');
case 'message.created':
case 'thread.reply':
return channels.has(stripHash(event.channel));
default:
return false;
}
}
desiredWsChannels() {
const desired = new Set();
for (const channel of this.manualSubscriptions) {
if (channel !== '@self') {
desired.add(channel);
}
}
for (const subscription of this.managedSubscriptions.values()) {
for (const channel of subscription.channels) {
if (channel !== '@self') {
desired.add(channel);
}
}
}
return desired;
}
syncDesiredSubscriptions(options = {}) {
if (!this.ws) {
return;
}
const desired = this.desiredWsChannels();
if (!this.ws.connected) {
if (options.resetRemoteState) {
this.activeWsChannels.clear();
}
return;
}
if (options.resetRemoteState) {
this.activeWsChannels.clear();
}
const toSubscribe = [...desired].filter((channel) => !this.activeWsChannels.has(channel));
const toUnsubscribe = [...this.activeWsChannels].filter((channel) => !desired.has(channel));
if (toSubscribe.length > 0) {
this.ws.subscribe(toSubscribe);
}
if (toUnsubscribe.length > 0) {
this.ws.unsubscribe(toUnsubscribe);
}
this.activeWsChannels = desired;
}
// === Channels ===
channels = {
create: (data) => this.client.post('/v1/channels', data),
list: (opts) => {
const query = {};
if (opts?.includeArchived)
query.includeArchived = 'true';
return this.client.get('/v1/channels', query);
},
get: (name) => this.client.get(`/v1/channels/${encodeURIComponent(name)}`),
join: (name) => this.client.post(`/v1/channels/${encodeURIComponent(name)}/join`),
leave: async (name) => {
await this.client.post(`/v1/channels/${encodeURIComponent(name)}/leave`);
},
setTopic: (name, topic) => this.client.patch(`/v1/channels/${encodeURIComponent(name)}/topic`, { topic }),
archive: (name) => this.client.delete(`/v1/channels/${encodeURIComponent(name)}`),
invite: (channel, agent) => this.client.post(`/v1/channels/${encodeURIComponent(channel)}/invite`, { agentName: agent }),
members: (name) => this.client.get(`/v1/channels/${encodeURIComponent(name)}/members`),
update: (name, data) => this.client.patch(`/v1/channels/${encodeURIComponent(name)}`, data),
mute: async (name) => {
await this.client.post(`/v1/channels/${encodeURIComponent(name)}/mute`);
},
unmute: async (name) => {
await this.client.post(`/v1/channels/${encodeURIComponent(name)}/unmute`);
},
};
// === Reactions ===
async react(messageId, emoji) {
return this.client.post(`/v1/messages/${encodeURIComponent(messageId)}/reactions`, { emoji });
}
async unreact(messageId, emoji) {
return this.client.delete(`/v1/messages/${encodeURIComponent(messageId)}/reactions/${encodeURIComponent(emoji)}`);
}
async reactions(messageId) {
return this.client.get(`/v1/messages/${encodeURIComponent(messageId)}/reactions`);
}
// === Search ===
async search(query, opts) {
const params = { q: query };
if (opts?.channel)
params.channel = opts.channel;
if (opts?.from)
params.from = opts.from;
if (opts?.limit)
params.limit = String(opts.limit);
if (opts?.before)
params.before = opts.before;
if (opts?.after)
params.after = opts.after;
return this.client.get('/v1/search', params);
}
// === Inbox ===
async inbox(options) {
const params = {};
if (options?.limit != null)
params.limit = String(options.limit);
return this.client.get('/v1/inbox', params);
}
// === Read Receipts ===
async markRead(messageId) {
return this.client.post(`/v1/messages/${encodeURIComponent(messageId)}/read`);
}
async readers(messageId) {
return this.client.get(`/v1/messages/${encodeURIComponent(messageId)}/readers`);
}
async readStatus(channel) {
const name = stripHash(channel);
return this.client.get(`/v1/channels/${encodeURIComponent(name)}/read-status`);
}
// === Durable Delivery ===
/**
* List durable delivery items queued for this agent. Defaults to the
* non-terminal queue (queued + delivered) so an offline consumer can replay
* what it missed on reconnect. Each item carries the message payload.
*/
async deliveries(options) {
const params = {};
if (options?.status)
params.status = options.status;
if (options?.limit != null)
params.limit = String(options.limit);
return this.client.get('/v1/deliveries', params);
}
/** Idempotently acknowledge a delivery, transitioning it to `acked`. */
async ackDelivery(deliveryId) {
return this.client.post(`/v1/deliveries/${encodeURIComponent(deliveryId)}/ack`);
}
/** Idempotently record a delivery as `failed` with optional error/retryability. */
async failDelivery(deliveryId, options) {
return this.client.post(`/v1/deliveries/${encodeURIComponent(deliveryId)}/fail`, options ?? {});
}
/** Idempotently defer a delivery until `availableAt`. */
async deferDelivery(deliveryId, options) {
return this.client.post(`/v1/deliveries/${encodeURIComponent(deliveryId)}/defer`, options);
}
// === Actions ===
actions = {
invoke: (name, input) => this.client.post(`/v1/actions/${encodeURIComponent(name)}/invoke`, { input }),
completeInvocation: (name, invocationId, data) => this.client.post(`/v1/actions/${encodeURIComponent(name)}/invocations/${encodeURIComponent(invocationId)}/complete`, data),
getInvocation: (name, invocationId) => this.client.get(`/v1/actions/${encodeURIComponent(name)}/invocations/${encodeURIComponent(invocationId)}`),
};
// === Files ===
files = {
upload: (data) => this.client.post('/v1/files/upload', data),
complete: (fileId) => this.client.post(`/v1/files/${encodeURIComponent(fileId)}/complete`),
get: (fileId) => this.client.get(`/v1/files/${encodeURIComponent(fileId)}`),
delete: (fileId) => this.client.delete(`/v1/files/${encodeURIComponent(fileId)}`),
list: (opts) => {
const query = {};
if (opts?.uploadedBy)
query.uploadedBy = opts.uploadedBy;
if (opts?.limit)
query.limit = String(opts.limit);
return this.client.get('/v1/files', query);
},
};
}
//# sourceMappingURL=agent.js.map