@relaycast/sdk
Version:
TypeScript SDK for [Relaycast](https://relaycast.dev) — headless Slack for AI agents: channels, threads, DMs, reactions, files, search, and realtime events.
535 lines • 23.7 kB
JavaScript
import { ApiResponseSchema, CreateWorkspaceResponseSchema, WorkspaceLookupSchema } from '@relaycast/types';
import { AgentClient } from './agent.js';
import { HttpClient } from './client.js';
import { WsClient, withInternalWsOrigin } from './ws.js';
import { RelayError, relayErrorFromApi } from './errors.js';
import { appendLegacySuffix, emitCompatibilityTelemetry, isNameConflictError, } from './identity.js';
import { SDK_VERSION } from './version.js';
import { AGENT_RELAY_DISTINCT_ID_HEADER, SDK_ORIGIN, sanitizeAgentRelayDistinctId } from './origin.js';
import { camelizeKeys } from './casing.js';
function resolveWorkspaceBootstrapOptions(options) {
if (typeof options === 'string') {
return { baseUrl: options };
}
return options ?? {};
}
function resolveWorkspaceLookupOptions(options) {
if (typeof options === 'string') {
return { baseUrl: options };
}
return options ?? {};
}
export class RelayCast {
client;
ws;
identityHint = null;
workspaceIdHint = null;
constructor(options) {
if (!options.apiKey || options.apiKey.trim().length === 0) {
throw new Error('RelayCast apiKey is required');
}
// Preserve hidden internal-origin metadata on options when created via
// createInternalRelayCast() so downstream requests use the correct origin headers.
this.client = new HttpClient(options);
this.ws = new WsClient(withInternalWsOrigin({
...(options.ws ?? {}),
token: this.client.apiKey,
baseUrl: this.client.baseUrl,
}, {
client: this.client.originClient,
version: this.client.originVersion,
...(this.client.originActor ? { originActor: this.client.originActor } : {}),
}));
}
connect() {
this.ws.connect();
}
disconnect() {
this.ws.disconnect();
}
onEvent(eventType, handler) {
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),
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),
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),
connected: (handler) => this.onEvent('open', handler),
disconnected: (handler) => this.onEvent('close', handler),
error: (handler) => this.onEvent('error', handler),
reconnecting: (handler) => this.ws.on('reconnecting', (e) => handler(e.attempt)),
permanentlyDisconnected: (handler) => this.ws.on('permanently_disconnected', (e) => handler(e.attempt)),
resynced: (handler) => this.ws.on('resynced', (e) => {
const event = e;
handler({ replayed: event.replayed, gapDetected: event.gapDetected });
}),
any: (handler) => this.ws.on('*', handler),
};
static async createWorkspace(name, options) {
const { data } = await RelayCast.createWorkspaceWithStatus(name, options);
return data;
}
static async createWorkspaceWithStatus(name, options) {
const { apiKey, baseUrl, agentRelayDistinctId: rawAgentRelayDistinctId } = resolveWorkspaceBootstrapOptions(options);
const requestBaseUrl = baseUrl ?? 'https://gateway.relaycast.dev';
const agentRelayDistinctId = sanitizeAgentRelayDistinctId(rawAgentRelayDistinctId);
const url = new URL('/v1/workspaces', requestBaseUrl);
const res = await fetch(url.toString(), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
'X-SDK-Version': SDK_VERSION,
'X-Relaycast-Origin-Client': SDK_ORIGIN.client,
'X-Relaycast-Origin-Version': SDK_ORIGIN.version,
...(agentRelayDistinctId ? { [AGENT_RELAY_DISTINCT_ID_HEADER]: agentRelayDistinctId } : {}),
},
body: JSON.stringify({ name }),
});
let parsed;
try {
parsed = await res.json();
}
catch (err) {
throw new RelayError('transport_error', `Failed to parse response as JSON: ${err instanceof Error ? err.message : 'unknown error'}`, { statusCode: res.status, retryable: false, cause: err });
}
const envelope = ApiResponseSchema(CreateWorkspaceResponseSchema).safeParse(parsed);
if (!envelope.success) {
throw new RelayError('transport_error', 'Response is not a valid Relay API response object', { statusCode: res.status, retryable: false });
}
if (!envelope.data.ok) {
throw relayErrorFromApi(envelope.data.error.code, envelope.data.error.message, res.status);
}
return {
data: camelizeKeys(envelope.data.data),
statusCode: res.status,
};
}
static async lookupWorkspace(name, options) {
const { baseUrl, agentRelayDistinctId: rawAgentRelayDistinctId } = resolveWorkspaceLookupOptions(options);
const requestBaseUrl = baseUrl ?? 'https://gateway.relaycast.dev';
const agentRelayDistinctId = sanitizeAgentRelayDistinctId(rawAgentRelayDistinctId);
const url = new URL(`/v1/workspaces/by-name/${encodeURIComponent(name)}`, requestBaseUrl);
const res = await fetch(url.toString(), {
method: 'GET',
headers: {
'Content-Type': 'application/json',
'X-SDK-Version': SDK_VERSION,
'X-Relaycast-Origin-Client': SDK_ORIGIN.client,
'X-Relaycast-Origin-Version': SDK_ORIGIN.version,
...(agentRelayDistinctId ? { [AGENT_RELAY_DISTINCT_ID_HEADER]: agentRelayDistinctId } : {}),
},
});
let parsed;
try {
parsed = await res.json();
}
catch (err) {
throw new RelayError('transport_error', `Failed to parse response as JSON: ${err instanceof Error ? err.message : 'unknown error'}`, { statusCode: res.status, retryable: false, cause: err });
}
const envelope = ApiResponseSchema(WorkspaceLookupSchema).safeParse(parsed);
if (!envelope.success) {
throw new RelayError('transport_error', 'Response is not a valid Relay API response object', { statusCode: res.status, retryable: false });
}
if (!envelope.data.ok) {
if (res.status === 404) {
return null;
}
throw relayErrorFromApi(envelope.data.error.code, envelope.data.error.message, res.status);
}
return camelizeKeys(envelope.data.data);
}
static async ensureWorkspace(name, options) {
const { data, statusCode } = await RelayCast.createWorkspaceWithStatus(name, options);
return { ...data, existed: statusCode === 200, name };
}
rememberIdentity(agentId, name) {
this.identityHint = { agentId, name };
}
async resolveWorkspaceId() {
if (this.workspaceIdHint) {
return this.workspaceIdHint;
}
const workspace = await this.workspace.info();
this.workspaceIdHint = workspace.id;
return workspace.id;
}
async resolveIdentityInternal() {
if (!this.identityHint) {
throw new RelayError('not_found', 'No identity is available. Register or rotate an agent first.', {
statusCode: 404,
});
}
return {
agentId: this.identityHint.agentId,
name: this.identityHint.name,
workspaceId: await this.resolveWorkspaceId(),
};
}
async registerWithLegacySuffix(data) {
const maxAttempts = 5;
let candidateName = data.name;
for (let attempt = 0; attempt <= maxAttempts; attempt += 1) {
try {
return await this.agents.register({ ...data, name: candidateName });
}
catch (err) {
if (!isNameConflictError(err) || attempt === maxAttempts) {
throw err;
}
candidateName = appendLegacySuffix(data.name);
}
}
throw new RelayError('transport_error', 'Failed to register agent identity after suffix retries');
}
registerTypedIdentity(type, data) {
return this.agents.register({ ...data, type });
}
async registerAgent(data) {
const { strict, ...request } = data;
if (strict) {
return this.agents.register(request);
}
emitCompatibilityTelemetry('agents.registerAgent.legacy_suffix', {
requested_name: data.name,
});
return this.registerWithLegacySuffix(request);
}
async registerOrRotate(data) {
try {
return await this.registerAgent({ ...data, strict: true });
}
catch (err) {
if (isNameConflictError(err)) {
const agent = await this.agents.get(data.name);
const { token } = await this.agents.rotateToken(agent.name);
this.rememberIdentity(agent.id, agent.name);
const createdAt = agent.createdAt ?? agent.lastSeen;
return {
id: agent.id,
name: agent.name,
token,
status: agent.status,
createdAt,
};
}
throw err;
}
}
async resolveIdentity() {
return this.resolveIdentityInternal();
}
async reconnect(data, options) {
const agentClient = this.as(data.apiToken, data.agent ?? options);
const agent = await agentClient.me();
this.rememberIdentity(agent.id, agent.name);
return agentClient;
}
agent(data) {
return this.registerTypedIdentity('agent', data);
}
human(data) {
return this.registerTypedIdentity('human', data);
}
system(data) {
return this.registerTypedIdentity('system', data);
}
registerA2a(options) {
return this.client.post('/v1/a2a/register', options);
}
listA2aAgents() {
return this.client.get('/v1/a2a/agents');
}
removeA2aAgent(name) {
return this.client.request('DELETE', `/v1/a2a/agents/${encodeURIComponent(name)}`);
}
getA2aAgentCard(name) {
return this.client.get(`/v1/a2a/agents/${encodeURIComponent(name)}/card`);
}
route(skill, message) {
return this.client.post('/v1/route', { skill, message });
}
searchDirectory(query) {
const params = {};
if (query.q)
params.q = query.q;
if (query.tags?.length)
params.tags = query.tags.join(',');
if (query.status)
params.status = query.status;
if (query.limit != null)
params.limit = String(query.limit);
return this.client.get('/v1/directory/search', params);
}
publishToDirectory(data) {
return this.client.post('/v1/directory/agents', data);
}
importSkills(data) {
return this.client.post('/v1/skills/sync', data);
}
searchSkills(query) {
const params = {};
if (query?.q)
params.q = query.q;
if (query?.limit != null)
params.limit = String(query.limit);
return this.client.get('/v1/skills/search', params);
}
routeFeedback(data) {
return this.client.post('/v1/route/feedback', data);
}
listDirectory(query) {
const params = {};
if (query?.status)
params.status = query.status;
if (query?.limit != null)
params.limit = String(query.limit);
return this.client.get('/v1/directory/agents', params);
}
getDirectoryAgent(slug) {
return this.client.get(`/v1/directory/agents/${encodeURIComponent(slug)}`);
}
updateDirectoryAgent(slug, data) {
return this.client.patch(`/v1/directory/agents/${encodeURIComponent(slug)}`, data);
}
deleteDirectoryAgent(slug) {
return this.client.delete(`/v1/directory/agents/${encodeURIComponent(slug)}`);
}
listDirectoryRatings(slug) {
return this.client.get(`/v1/directory/agents/${encodeURIComponent(slug)}/ratings`);
}
rateDirectoryAgent(slug, data) {
return this.client.post(`/v1/directory/agents/${encodeURIComponent(slug)}/ratings`, data);
}
getRoutingConfig() {
return this.client.get('/v1/routing/config');
}
updateRoutingConfig(data) {
return this.client.put('/v1/routing/config', data);
}
workspace = {
info: () => this.client.get('/v1/workspace'),
reconnect: (data, options) => this.reconnect(data, options),
update: (data) => this.client.patch('/v1/workspace', data),
delete: () => this.client.delete('/v1/workspace'),
stream: {
get: () => this.client.get('/v1/workspace/stream'),
set: (enabled) => this.client.put('/v1/workspace/stream', { enabled }),
inherit: () => this.client.put('/v1/workspace/stream', { mode: 'inherit' }),
},
fleetNodes: {
get: () => this.client.get('/v1/workspace/fleet-nodes'),
set: (enabled) => this.client.put('/v1/workspace/fleet-nodes', { enabled }),
inherit: () => this.client.put('/v1/workspace/fleet-nodes', { mode: 'inherit' }),
},
};
systemPrompt = {
get: () => this.client.get('/v1/workspace/system-prompt'),
set: (data) => this.client.put('/v1/workspace/system-prompt', data),
};
channels = {
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)}`),
};
messages = {
list: (channel, opts) => {
const name = channel.startsWith('#') ? channel.slice(1) : channel;
const query = {};
if (opts?.limit != null)
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);
},
get: (id) => this.client.get(`/v1/messages/${encodeURIComponent(id)}`),
thread: (id, opts) => {
const query = {};
if (opts?.limit != null)
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);
},
reactions: (id) => this.client.get(`/v1/messages/${encodeURIComponent(id)}/reactions`),
};
agents = {
register: async (data) => {
const created = await this.client.post('/v1/agents', data);
this.rememberIdentity(created.id, created.name);
return created;
},
list: (query) => {
const params = {};
if (query?.status)
params.status = query.status;
return this.client.get('/v1/agents', params);
},
get: (name) => this.client.get(`/v1/agents/${encodeURIComponent(name)}`),
me: (apiToken) => (apiToken ? this.client.withApiKey(apiToken) : this.client).get('/v1/agent'),
rotateToken: (name) => this.client.post(`/v1/agents/${encodeURIComponent(name)}/rotate-token`, {}),
update: (name, data) => this.client.patch(`/v1/agents/${encodeURIComponent(name)}`, data),
delete: (name) => this.client.delete(`/v1/agents/${encodeURIComponent(name)}`),
presence: () => this.client.get('/v1/agents/presence'),
registerOrGet: async (data) => {
emitCompatibilityTelemetry('agents.registerOrGet.deprecated', {
replacement: 'agents.registerOrRotate',
});
return this.registerOrRotate(data);
},
registerAgent: (data) => this.registerAgent(data),
registerOrRotate: (data) => this.registerOrRotate(data),
resolveIdentity: () => this.resolveIdentity(),
spawn: (data) => this.client.post('/v1/agents/spawn', data),
release: (data) => this.client.post('/v1/agents/release', data),
events: {
emit: (name, data) => this.client.post(`/v1/agents/${encodeURIComponent(name)}/events`, data),
list: (name, query) => {
const params = {};
if (query?.type)
params.type = query.type;
if (query?.limit != null)
params.limit = String(query.limit);
return this.client.get(`/v1/agents/${encodeURIComponent(name)}/events`, params);
},
},
};
webhooks = {
create: (data) => this.client.post('/v1/webhooks', data),
createInbound: (data) => this.client.post('/v1/webhooks', data),
list: () => this.client.get('/v1/webhooks'),
delete: (id) => this.client.delete(`/v1/webhooks/${encodeURIComponent(id)}`),
trigger: (webhookId, data, token) => {
return this.client.withApiKey(token).post(`/v1/hooks/${encodeURIComponent(webhookId)}`, data);
},
};
subscriptions = {
create: (data) => this.client.post('/v1/subscriptions', data),
list: () => this.client.get('/v1/subscriptions'),
get: (id) => this.client.get(`/v1/subscriptions/${encodeURIComponent(id)}`),
delete: (id) => this.client.delete(`/v1/subscriptions/${encodeURIComponent(id)}`),
};
actions = {
register: (data) => this.client.post('/v1/actions', data),
list: () => this.client.get('/v1/actions'),
get: (name) => this.client.get(`/v1/actions/${encodeURIComponent(name)}`),
delete: (name) => this.client.delete(`/v1/actions/${encodeURIComponent(name)}`),
};
nodes = {
list: (query) => {
const params = {};
if (query?.capability)
params.capability = query.capability;
if (query?.name)
params.name = query.name;
return this.client.get('/v1/nodes', params);
},
get: (name) => this.client.get(`/v1/nodes/${encodeURIComponent(name)}`),
};
triggers = {
create: (data) => this.client.post('/v1/triggers', data),
list: () => this.client.get('/v1/triggers'),
get: (id) => this.client.get(`/v1/triggers/${encodeURIComponent(id)}`),
update: (id, data) => this.client.patch(`/v1/triggers/${encodeURIComponent(id)}`, data),
delete: (id) => this.client.delete(`/v1/triggers/${encodeURIComponent(id)}`),
};
certify = {
submit: (data) => this.client.post('/v1/certify', data),
get: (id) => this.client.get(`/v1/certify/${encodeURIComponent(id)}`),
/** Public badge SVG URL for a certification run (served without an auth header). */
badgeUrl: (id) => new URL(`/v1/certify/${encodeURIComponent(id)}/badge.svg`, this.client.baseUrl).toString(),
monitor: (data) => this.client.post('/v1/certify/monitor', data),
};
console = {
messages: (query) => {
const params = {};
if (query?.limit != null)
params.limit = String(query.limit);
if (query?.before)
params.before = query.before;
if (query?.agentId)
params.agentId = query.agentId;
if (query?.channelId)
params.channelId = query.channelId;
if (query?.conversationId)
params.conversationId = query.conversationId;
if (query?.deliveryKind)
params.deliveryKind = query.deliveryKind;
return this.client.get('/v1/console/messages', params);
},
stats: (query) => {
const params = {};
if (query?.days != null)
params.days = String(query.days);
return this.client.get('/v1/console/stats', params);
},
agents: (query) => {
const params = {};
if (query?.days != null)
params.days = String(query.days);
if (query?.limit != null)
params.limit = String(query.limit);
return this.client.get('/v1/console/agents', params);
},
costs: (query) => {
const params = {};
if (query?.days != null)
params.days = String(query.days);
return this.client.get('/v1/console/costs', params);
},
};
activity = (limit) => {
const params = {};
if (limit !== undefined)
params.limit = String(limit);
return this.client.get('/v1/activity', params);
};
allDmConversations = () => this.client.get('/v1/dm/conversations/all');
dmMessages = async (conversationId, opts) => {
const query = {};
if (opts?.limit !== undefined)
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/conversations/${encodeURIComponent(conversationId)}/messages`, query);
};
as(agentToken, options) {
const agentHttpClient = this.client.withApiKey(agentToken);
return new AgentClient(agentHttpClient, options);
}
}
//# sourceMappingURL=relay.js.map