UNPKG

@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
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