import type { SuperagentAgent, SuperagentAutomation, SuperagentAutomationCreditsSummary, SuperagentChannelStatus, SuperagentCollaborator, SuperagentConnector, SuperagentConnectorAccessMode, SuperagentConnectorActionInput, SuperagentIMessageActivation, SuperagentIMessageChannelStatus, SuperagentInviteCollaboratorsResult, SuperagentLineActivation, SuperagentModelChoice, SuperagentSandboxFileContent, SuperagentSecret, SuperagentSlackChannelStatus, SuperagentSlackConnectResult, SuperagentTelegramChannelStatus, SuperagentToolPermissionConfig, SuperagentWhatsAppChannelStatus, SuperagentWorkflow, } from '../types'; import { SUPERAGENT_CONNECTOR_CATALOG } from '../features/connectors/connectorCatalog'; import { DEFAULT_SANDBOX_FILE_PATHS, normalizeFilePaths } from '../features/editor/fileTreeUtils'; const AGENT_FIELDS = [ 'id', 'name', 'user_description', 'status', 'updated_date', 'logo_url', 'created_date', 'app_type', 'avatar_index', 'model', 'automation_model', 'organization_id', 'tools_permission_config', 'legacy_automations', 'owner_id', 'created_by', ].join(','); const USER_AGENT_AGENT_NAME = 'your_agent'; const TELEGRAM_DISPLAY_NAME_MAX_LENGTH = 64; const USER_AGENT_SANDBOX_LIST_PATHS = [ '.agents', '.agents/rules', '.agents/skills', '.agents/mcps', '.agents/hooks', '.agents/cron', '.agents/memory', 'incoming_files', ]; type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'; type HttpRequestOptions = { authRequired?: boolean; body?: unknown; headers?: Record; method: HttpMethod; }; export type SuperagentApiClientConfig = { baseUrl: string; currentUserId?: string | null; getAccessToken: () => Promise | string | null | undefined; getHeaders?: () => Promise | undefined> | Record | undefined; }; type UserAgentApp = { id: string; name?: string; user_description?: string; logo_url?: string; model?: SuperagentModelChoice | string | null; automation_model?: string | null; organization_id?: string | null; avatar_index?: number | null; tools_permission_config?: SuperagentToolPermissionConfig | null; updated_date?: string; created_date?: string; app_type?: string; legacy_automations?: boolean | null; owner_id?: string; created_by?: string; }; type AppIntegration = { integration_type: string; integration_account_identifier?: string | null; requires_connection_config?: boolean; scopes?: string[]; status?: string; }; type HttpResult = { success: boolean; data?: T; error?: { message: string; status?: number; }; }; type SuperagentHttpClient = ReturnType; export type SuperagentVoiceLiveSession = { sessionId: string; streamToken: string; websocketUrl: string; }; type VoiceLiveSessionResponse = { session_id?: string; stream_token?: string; }; type RuntimeAuthTokenResponse = { token?: string; }; type TelegramStatusResponse = { connected: boolean; bot_link?: string | null; bot_name?: string | null; bot_username?: string | null; }; type TelegramManagedBotCreateResponse = { link?: string; nonce?: string; supported?: boolean; }; type TelegramManagedBotStatusResponse = { bot_link?: string | null; bot_name?: string | null; bot_username?: string | null; status?: string; }; type LineActivationResponse = { add_friend_url?: string; code?: string; }; type IMessageStatusResponse = { connected: boolean; phone_number?: string | null; }; type WhatsAppStatusResponse = { connected: boolean; user_handle?: string | null; }; type IMessageActivationResponse = { code?: string; phone_number?: string; }; type SlackWorkspaceResponse = { team_id?: string | null; team_name?: string | null; usergroup_handle?: string | null; }; type SlackStatusResponse = { agent_display_name?: string | null; connected?: boolean; supported?: boolean; pending_cleanup?: boolean; team_id?: string | null; team_name?: string | null; usergroup_handle?: string | null; workspaces?: SlackWorkspaceResponse[] | null; }; type SlackConnectResponse = { url?: string | null; supported?: boolean; expires_in_seconds?: number | null; }; // /sandbox/files/list-entries returns rich entries (path + size/modified_at), and // honors include_all (binaries). Legacy sandbox drivers may still emit bare path // strings, so accept either. type SandboxFileEntry = { path: string }; type SandboxFileEntriesResponse = { files?: Array; }; export type SuperagentConnectorsResult = { availableConnectors: SuperagentConnector[]; connectedConnectors: SuperagentConnector[]; }; export type InitiateConnectorConnectionResult = { already_authorized?: boolean; connection_id?: string | null; error?: string | null; error_message?: string | null; other_user_email?: string | null; redirect_url?: string | null; }; export type ConnectorConnectionStatus = 'ACTIVE' | 'FAILED' | 'PENDING'; const OAUTH_INJECTED_SCOPES = new Set(['email', 'openid', 'profile', 'User.Read', 'offline_access']); export function createSuperagentApiClient(config: SuperagentApiClientConfig) { const agentsEndpoint = `${normalizeBaseUrl(config.baseUrl)}/api/agents`; const appsEndpoint = `${normalizeBaseUrl(config.baseUrl)}/api/apps`; const httpClient = createHttpClient(config); return { async listAgents(): Promise { // Match the web Superagent home/sidebar query: filter only by app_type and let // the backend default (all_apps_workspace) scope to apps the user can access — // owned AND those shared with them as an editor collaborator. Filtering the // query by owner_id / filter_mode=owned_by_me hides shared agents // (owned_by_me is also an admin-only mode on the backend). The backend already // enforces access, so this only widens the list to what web already shows. const endpoint = buildUrl(appsEndpoint, { fields: AGENT_FIELDS, limit: '100', q: JSON.stringify({app_type: 'user_agent'}), skip: '0', sort: '-updated_date', }); const response = await httpClient.get(endpoint, true); assertSuccess(response, 'Failed to load Superagents'); return (response.data ?? []).map(toAgent); }, async createAgent(): Promise { const response = await httpClient.post(appsEndpoint, {app_type: 'user_agent'}, true); assertSuccess(response, 'Failed to create Superagent'); if (!response.data?.id) { throw new Error('Superagent creation returned no app id'); } return toAgent(response.data); }, async renameAgent(agentId: string, name: string): Promise { return updateAgent(httpClient, appsEndpoint, agentId, {name}); }, async updateAgentModel(agentId: string, model: SuperagentModelChoice | string): Promise { return updateAgent(httpClient, appsEndpoint, agentId, {model}); }, async updateAgentAutomationModel(agentId: string, model: SuperagentModelChoice | string): Promise { return updateAgent(httpClient, appsEndpoint, agentId, {automation_model: model}); }, async updateToolPermissions(agentId: string, config: SuperagentToolPermissionConfig): Promise { return updateAgent(httpClient, appsEndpoint, agentId, {tools_permission_config: config}); }, async deleteAgent(agentId: string): Promise { const response = await httpClient.delete(`${appsEndpoint}/${encodeURIComponent(agentId)}`, true); assertSuccess(response, 'Failed to delete Superagent'); }, async listCollaborators(agentId: string): Promise { const endpoint = `${appsEndpoint}/${encodeURIComponent(agentId)}/users/collaborators`; const response = await httpClient.get(endpoint, true); assertSuccess(response, 'Failed to load shared chat members'); return response.data ?? []; }, async inviteCollaborators( agentId: string, emails: string[], addAsGuest = false, ): Promise { const endpoint = `${appsEndpoint}/${encodeURIComponent(agentId)}/users/invite-users`; const response = await httpClient.post( endpoint, { add_as_guest: addAsGuest, collaborator_role: 'editor', role: 'admin', user_emails: emails, }, true, ); assertSuccess(response, 'Failed to invite collaborators'); return response.data ?? {results: []}; }, async listSecrets(agentId: string): Promise { const response = await httpClient.get>(`${appsEndpoint}/${encodeURIComponent(agentId)}/secrets`, true); assertSuccess(response, 'Failed to load Superagent secrets'); return Object.entries(response.data ?? {}).map(([name, value]) => ({name, value: String(value)})); }, async saveSecret(agentId: string, name: string, value: string): Promise { const response = await httpClient.post( `${appsEndpoint}/${encodeURIComponent(agentId)}/secrets`, // Normalize only the key name — preserve the secret value byte-for-byte. // Credentials (PEM/private keys, tokens with a trailing newline, passwords) // can require exact leading/trailing whitespace; trimming would corrupt them. {[name.trim().toUpperCase()]: value}, true, ); assertSuccess(response, 'Failed to save Superagent secret'); }, async deleteSecret(agentId: string, name: string): Promise { const endpoint = buildUrl(`${appsEndpoint}/${encodeURIComponent(agentId)}/secrets`, { secret_name: name, }); const response = await httpClient.delete(endpoint, true); assertSuccess(response, 'Failed to delete Superagent secret'); }, async listConnectors(agentId: string): Promise { const endpoint = `${appsEndpoint}/${encodeURIComponent(agentId)}/external-auth/list`; const response = await httpClient.get<{integrations?: AppIntegration[]}>(endpoint, true); assertSuccess(response, 'Failed to load Superagent connectors'); const connectedConnectors = (response.data?.integrations ?? []) .filter((integration) => !isPaymentIntegration(integration.integration_type)) .map(toConnector); const connectedIds = new Set(connectedConnectors.map((connector) => connector.id)); const availableConnectors = SUPERAGENT_CONNECTOR_CATALOG.filter((connector) => !connectedIds.has(connector.id)); return { availableConnectors, connectedConnectors, }; }, async listAutomations(agentId: string): Promise { const endpoint = `${agentsEndpoint}/${encodeURIComponent(agentId)}/automations`; const response = await httpClient.get(endpoint, true); assertSuccess(response, 'Failed to load Superagent automations'); return (response.data ?? []).sort((a, b) => getAutomationTime(b) - getAutomationTime(a)); }, async listAutomationCredits(agentId: string): Promise> { const endpoint = `${agentsEndpoint}/${encodeURIComponent(agentId)}/automations/credits-summary`; const response = await httpClient.get>(endpoint, true); assertSuccess(response, 'Failed to load Superagent automation credits'); return response.data ?? {}; }, async listSandboxFiles(agentId: string): Promise { const rootFiles = await listSandboxFilesAt(httpClient, appsEndpoint, agentId, '.'); const agentFiles = await listUserAgentSandboxFilePaths(httpClient, appsEndpoint, agentId); if (normalizeFilePaths(rootFiles).length === 0) { return agentFiles; } return mergeSandboxFilePaths(rootFiles, agentFiles); }, async readSandboxFile(agentId: string, path: string): Promise { const endpoint = buildUrl(`${appsEndpoint}/${encodeURIComponent(agentId)}/sandbox/files/content`, { path, }); const response = await httpClient.get(endpoint, true); assertSuccess(response, 'Failed to open Superagent file'); if (typeof response.data?.content !== 'string') { throw new Error('The backend returned no file content.'); } return { content: response.data.content, path: response.data.path || path, }; }, async writeSandboxFile(agentId: string, path: string, content: string): Promise { const endpoint = `${appsEndpoint}/${encodeURIComponent(agentId)}/sandbox/files/content`; const response = await httpClient.request(endpoint, { authRequired: true, body: {content, path}, method: 'PUT', }); assertSuccess(response, 'Failed to save Superagent file'); }, async initiateConnectorConnection( agentId: string, connectorId: string, options: Pick = {}, ): Promise { const endpoint = `${appsEndpoint}/${encodeURIComponent(agentId)}/external-auth/initiate`; const scopes = options.scopes ?? getConnectorScopes(connectorId, options.accessMode); const response = await httpClient.post( endpoint, { force_reconnect: options.forceReconnect ?? false, integration_type: connectorId, scopes, }, true, ); assertSuccess(response, 'Failed to start connector authorization'); return response.data ?? {}; }, async getConnectorConnectionStatus( agentId: string, connectorId: string, connectionId: string, ): Promise { const endpoint = buildUrl(`${appsEndpoint}/${encodeURIComponent(agentId)}/external-auth/status`, { connection_id: connectionId, integration_type: connectorId, }); const response = await httpClient.get<{status?: ConnectorConnectionStatus}>(endpoint, true); assertSuccess(response, 'Failed to check connector authorization status'); return response.data?.status ?? 'PENDING'; }, async getRuntimeAuthToken(agentId: string): Promise { const endpoint = `${appsEndpoint}/${encodeURIComponent(agentId)}/auth/token`; const response = await httpClient.get(endpoint, true); assertSuccess(response, 'Failed to start Superagent realtime'); if (!response.data?.token) { throw new Error('Superagent realtime returned no auth token'); } return response.data.token; }, // The token rides in the query string here (unlike runtimeRequest's // Authorization header) on purpose: this URL is opened as a *browser // navigation* via nativeAdapters.openUrl (not an XHR), so headers can't be // attached — the backend must authenticate it from the `token` query param. // Hardening to a dedicated short-lived/single-use connect token is a backend // follow-up; until that exists this is the only auth channel for a nav URL. getWhatsAppConnectUrl(agentId: string, runtimeAuthToken: string): string { return buildUrl(`${appsEndpoint}/${encodeURIComponent(agentId)}/agents/${USER_AGENT_AGENT_NAME}/whatsapp`, { token: runtimeAuthToken, }); }, async getChannelStatus(agentId: string, runtimeAuthToken: string): Promise { const [telegram, imessage, whatsapp, slack] = await Promise.all([ this.getTelegramStatus(agentId, runtimeAuthToken).catch(() => ({connected: false})), this.getIMessageStatus(agentId, runtimeAuthToken).catch(() => ({connected: false})), // null signals the editor-gated status call failed (e.g. 403 for viewers). this.getWhatsAppStatus(agentId, runtimeAuthToken).catch(() => null), // null (not {connected:false}) = "status unknown" so loadChannels keeps the // prior state instead of clobbering a live connection on a failed fetch. this.getSlackStatus(agentId, runtimeAuthToken).catch(() => null), ]); return { imessage, line: {connected: false}, slack: slack ?? undefined, telegram, // Only expose connectUrl (which carries the runtime token) when the // editor-gated /whatsapp/status call succeeded. Viewers get a 403 there, so // omit it — otherwise they'd hold a working WhatsApp connect URL even though // the backend redirect doesn't re-check editor access. whatsapp: whatsapp ? {...whatsapp, connectUrl: this.getWhatsAppConnectUrl(agentId, runtimeAuthToken)} : {connected: false}, }; }, async getTelegramStatus(agentId: string, runtimeAuthToken: string): Promise { const response = await runtimeRequest(httpClient, appsEndpoint, agentId, runtimeAuthToken, '/telegram/status', { method: 'GET', }); assertSuccess(response, 'Failed to load Telegram status'); return normalizeTelegramStatus(response.data); }, async getIMessageStatus(agentId: string, runtimeAuthToken: string): Promise { const response = await runtimeRequest(httpClient, appsEndpoint, agentId, runtimeAuthToken, '/imessage/status', { method: 'GET', }); assertSuccess(response, 'Failed to load iMessage status'); return normalizeIMessageStatus(response.data); }, async getWhatsAppStatus(agentId: string, runtimeAuthToken: string): Promise { const response = await runtimeRequest(httpClient, appsEndpoint, agentId, runtimeAuthToken, '/whatsapp/status', { method: 'GET', }); assertSuccess(response, 'Failed to load WhatsApp status'); return normalizeWhatsAppStatus(response.data); }, // Unlike the other channels there's no runtime /whatsapp/disconnect route, so // disconnect goes through the personal-agent facade (/api/agents/.../channels/ // whatsapp) with the access token — the same endpoint the web builder uses. async disconnectWhatsApp(agentId: string): Promise { const endpoint = `${agentsEndpoint}/${encodeURIComponent(agentId)}/channels/whatsapp`; const response = await httpClient.delete(endpoint, true); assertSuccess(response, 'Failed to disconnect WhatsApp'); return normalizeWhatsAppStatus(response.data); }, async generateIMessageCode(agentId: string, runtimeAuthToken: string): Promise { const response = await runtimeRequest(httpClient, appsEndpoint, agentId, runtimeAuthToken, '/imessage/generate-code', { method: 'POST', }); assertSuccess(response, 'Failed to generate iMessage code'); if (!response.data?.code || !response.data.phone_number) { throw new Error('iMessage setup returned no activation code.'); } return { code: response.data.code, phoneNumber: response.data.phone_number, }; }, async disconnectIMessage(agentId: string, runtimeAuthToken: string): Promise { const response = await runtimeRequest(httpClient, appsEndpoint, agentId, runtimeAuthToken, '/imessage/disconnect', { method: 'DELETE', }); assertSuccess(response, 'Failed to disconnect iMessage'); return normalizeIMessageStatus(response.data); }, async setupTelegram(agentId: string, runtimeAuthToken: string, token: string): Promise { const response = await runtimeRequest(httpClient, appsEndpoint, agentId, runtimeAuthToken, '/telegram/setup', { body: {bot_token: token}, method: 'POST', }); assertSuccess(response, 'Failed to connect Telegram'); return normalizeTelegramStatus(response.data); }, async createManagedTelegramBot( agentId: string, runtimeAuthToken: string, input: { agentDisplayName?: string; agentProfilePhotoUrl?: string | null }, ): Promise<{ link: string; nonce: string }> { const response = await runtimeRequest( httpClient, appsEndpoint, agentId, runtimeAuthToken, '/telegram/managed-bot/create', { body: { agent_display_name: Array.from(input.agentDisplayName ?? '') .slice(0, TELEGRAM_DISPLAY_NAME_MAX_LENGTH) .join(''), agent_profile_photo_url: input.agentProfilePhotoUrl?.startsWith('http') ? input.agentProfilePhotoUrl : '', }, method: 'POST', }, ); assertSuccess(response, 'Failed to create Telegram bot'); if (!response.data?.link || !response.data.nonce) { throw new Error('Telegram bot creation returned no setup link.'); } return {link: response.data.link, nonce: response.data.nonce}; }, async getManagedTelegramBotStatus( agentId: string, runtimeAuthToken: string, nonce: string, ): Promise { const response = await runtimeRequest( httpClient, appsEndpoint, agentId, runtimeAuthToken, `/telegram/managed-bot/status/${encodeURIComponent(nonce)}`, {method: 'GET'}, ); assertSuccess(response, 'Failed to check Telegram bot creation'); if (!response.data?.status) { throw new Error('Telegram bot creation returned no status.'); } return response.data; }, async disconnectTelegram(agentId: string, runtimeAuthToken: string): Promise { const response = await runtimeRequest(httpClient, appsEndpoint, agentId, runtimeAuthToken, '/telegram/disconnect', { method: 'DELETE', }); assertSuccess(response, 'Failed to disconnect Telegram'); }, async generateLineCode(agentId: string, runtimeAuthToken: string): Promise { const response = await runtimeRequest(httpClient, appsEndpoint, agentId, runtimeAuthToken, '/line/generate-code', { method: 'POST', }); assertSuccess(response, 'Failed to generate LINE code'); if (!response.data?.code || !response.data.add_friend_url) { throw new Error('LINE setup returned no activation code.'); } return { addFriendUrl: response.data.add_friend_url, code: response.data.code, }; }, async getSlackStatus(agentId: string, runtimeAuthToken: string): Promise { const response = await runtimeRequest(httpClient, appsEndpoint, agentId, runtimeAuthToken, '/slack/status', { method: 'GET', }); assertSuccess(response, 'Failed to load Slack status'); return normalizeSlackStatus(response.data); }, // Returns a Slack OAuth authorize URL to open in a browser. There's no deep-link // callback, so completion is detected by polling getSlackStatus. async connectSlack(agentId: string, runtimeAuthToken: string): Promise { const response = await runtimeRequest(httpClient, appsEndpoint, agentId, runtimeAuthToken, '/slack/connect', { method: 'POST', }); assertSuccess(response, 'Failed to start Slack connection'); return { expiresInSeconds: response.data?.expires_in_seconds ?? null, supported: response.data?.supported ?? true, url: response.data?.url ?? null, }; }, async disconnectSlack(agentId: string, runtimeAuthToken: string): Promise { const response = await runtimeRequest(httpClient, appsEndpoint, agentId, runtimeAuthToken, '/slack/disconnect', { method: 'DELETE', }); assertSuccess(response, 'Failed to disconnect Slack'); return normalizeSlackStatus(response.data); }, async disconnectConnector(agentId: string, connectorId: string): Promise { const endpoint = `${appsEndpoint}/${encodeURIComponent(agentId)}/external-auth/integrations/${encodeURIComponent(connectorId)}`; const response = await httpClient.delete(endpoint, true); assertSuccess(response, 'Failed to disconnect connector'); }, async removeConnector(agentId: string, connectorId: string): Promise { const endpoint = `${appsEndpoint}/${encodeURIComponent(agentId)}/external-auth/integrations/${encodeURIComponent(connectorId)}/remove`; const response = await httpClient.delete(endpoint, true); assertSuccess(response, 'Failed to remove connector'); }, async toggleAutomation(agentId: string, automation: SuperagentAutomation): Promise { const response = await httpClient.post( `${getAutomationPath({ agentsEndpoint, appsEndpoint }, agentId, automation)}/toggle`, undefined, true, ); assertSuccess(response, 'Failed to update automation status'); return normalizeAutomationResponse(response.data, automation); }, async archiveAutomation(agentId: string, automation: SuperagentAutomation): Promise { const response = await httpClient.post( `${getAutomationPath({ agentsEndpoint, appsEndpoint }, agentId, automation)}/archive`, undefined, true, ); assertSuccess(response, 'Failed to archive automation'); return normalizeAutomationResponse(response.data, automation); }, async restoreAutomation(agentId: string, automation: SuperagentAutomation): Promise { const response = await httpClient.post( `${getAutomationPath({ agentsEndpoint, appsEndpoint }, agentId, automation)}/unarchive`, undefined, true, ); assertSuccess(response, 'Failed to restore automation'); return normalizeAutomationResponse(response.data, automation); }, async deleteAutomation(agentId: string, automation: SuperagentAutomation): Promise { const response = await httpClient.delete(getAutomationPath({ agentsEndpoint, appsEndpoint }, agentId, automation), true); assertSuccess(response, 'Failed to delete automation'); }, async runAutomationNow(agentId: string, automation: SuperagentAutomation): Promise { if (automation.automation_type !== 'scheduled') { throw new Error('Only scheduled automations can be run manually from mobile.'); } const response = await httpClient.post( `${getAutomationPath({ agentsEndpoint, appsEndpoint }, agentId, automation)}/run`, undefined, true, ); assertSuccess(response, 'Failed to run automation'); }, // ── Workflows (successor to automations; gated by agent.workflowsEnabled) ── // These hit the SAME builder endpoints the web agent-editor's WorkflowsTab // uses (backend/app/user_apps/workflows/api/router.py, under /api/apps) — see // the web `WorkflowsAPI` client — so no partner facade is needed. `agentId` // is the app id (the native `appsEndpoint` base is how the package already // reaches connectors/secrets/sandbox routes). async listWorkflows(agentId: string): Promise { // include_archived=true + a high cap so the Tasks list reflects every // workflow (matches the web WorkflowsTab's useWorkflows(appId, true, 200)). const endpoint = buildUrl(`${getWorkflowsBase(appsEndpoint, agentId)}`, { include_archived: 'true', limit: '200', }); const response = await httpClient.get(endpoint, true); assertSuccess(response, 'Failed to load Superagent workflows'); // Ordering is owned by the panel (sortWorkflows) — don't sort twice here. return response.data ?? []; }, async toggleWorkflow(agentId: string, workflow: SuperagentWorkflow): Promise<{ status?: string }> { const response = await httpClient.post<{ status?: string }>( `${getWorkflowPath(appsEndpoint, agentId, workflow)}/toggle-status`, undefined, true, ); assertSuccess(response, 'Failed to update workflow status'); return response.data ?? {}; }, async archiveWorkflow(agentId: string, workflow: SuperagentWorkflow): Promise { const response = await httpClient.delete(getWorkflowPath(appsEndpoint, agentId, workflow), true); assertSuccess(response, 'Failed to archive workflow'); }, async restoreWorkflow(agentId: string, workflow: SuperagentWorkflow): Promise<{ status?: string }> { const response = await httpClient.post<{ status?: string }>( `${getWorkflowPath(appsEndpoint, agentId, workflow)}/unarchive`, undefined, true, ); assertSuccess(response, 'Failed to restore workflow'); return response.data ?? {}; }, async runWorkflowNow(agentId: string, workflow: SuperagentWorkflow): Promise { // Native run-now only covers payload-less triggers (scheduled etc.); replay // (`replay_from_run_id`) isn't collected here, so the backend 400s on // triggers that require a previous run — surfaced as an alert by the caller. const response = await httpClient.post( `${getWorkflowPath(appsEndpoint, agentId, workflow)}/run-now`, {}, true, ); assertSuccess(response, 'Failed to run workflow'); }, async createVoiceLiveSession(agentId: string, conversationId: string | null): Promise { const endpoint = `${appsEndpoint}/${encodeURIComponent(agentId)}/user-agent/voice/live-session`; const response = await httpClient.post( endpoint, { conversation_id: conversationId, }, true, ); if (!response.success && response.error?.status === 404) { throw new Error( `Live Voice is not available on ${normalizeBaseUrl(config.baseUrl)}. Deploy the Superagent voice session endpoint or point the mobile app to a backend that has /user-agent/voice/live-session.`, ); } assertSuccess(response, 'Failed to start Live Voice'); if (!response.data?.session_id || !response.data.stream_token) { throw new Error('Live Voice returned no session token'); } return { sessionId: response.data.session_id, streamToken: response.data.stream_token, websocketUrl: buildVoiceStreamUrl(config.baseUrl, agentId, response.data.session_id), }; }, async deleteVoiceLiveSession(agentId: string, sessionId: string): Promise { const endpoint = `${appsEndpoint}/${encodeURIComponent(agentId)}/user-agent/voice/live-session/${encodeURIComponent(sessionId)}`; const response = await httpClient.delete(endpoint, true); assertSuccess(response, 'Failed to clean up Live Voice session'); }, }; } function createHttpClient(config: SuperagentApiClientConfig) { return { async request(endpoint: string, options: HttpRequestOptions): Promise> { const { authRequired = true, body, headers = {}, method, } = options; const requestHeaders: Record = { 'X-Client-Platform': 'mobile_native', ...(body === undefined ? {} : { 'Content-Type': 'application/json' }), ...(await config.getHeaders?.()), ...headers, }; if (authRequired) { const accessToken = await config.getAccessToken(); if (!accessToken) { return { success: false, error: {message: 'User not authenticated'}, }; } requestHeaders.Authorization = `Bearer ${accessToken}`; } try { const response = await fetch(endpoint, { body: body === undefined ? undefined : JSON.stringify(body), headers: requestHeaders, method, }); const data = response.status === 204 ? undefined : await parseJson(response); if (!response.ok) { return { success: false, error: { message: getResponseErrorMessage(data, response.status), status: response.status, }, }; } return { success: true, data: data as T, }; } catch (error) { return { success: false, error: {message: error instanceof Error ? error.message : 'Request failed'}, }; } }, get(endpoint: string, authRequired = true): Promise> { return this.request(endpoint, { authRequired, method: 'GET' }); }, post(endpoint: string, body?: unknown, authRequired = true): Promise> { return this.request(endpoint, { authRequired, body, method: 'POST' }); }, delete(endpoint: string, authRequired = true): Promise> { return this.request(endpoint, { authRequired, method: 'DELETE' }); }, }; } async function parseJson(response: Response) { try { return await response.json(); } catch { return undefined; } } function getResponseErrorMessage(data: unknown, status: number) { if (data && typeof data === 'object') { const body = data as { detail?: unknown; message?: unknown }; if (typeof body.message === 'string') { return body.message; } if (typeof body.detail === 'string') { return body.detail; } } return `Request failed with status ${status}`; } function normalizeBaseUrl(url: string) { return url.trim().replace(/\/+$/, ''); } function buildVoiceStreamUrl(baseUrl: string, agentId: string, sessionId: string) { const normalized = normalizeBaseUrl(baseUrl); const websocketBase = normalized .replace(/^https:/i, 'wss:') .replace(/^http:/i, 'ws:'); return `${websocketBase}/api/apps/${encodeURIComponent(agentId)}/user-agent/voice/live-session/${encodeURIComponent(sessionId)}/stream`; } async function listSandboxFilesAt( httpClient: SuperagentHttpClient, appsEndpoint: string, agentId: string, path: string, ): Promise { // Use list-entries, not list: /sandbox/files/list ignores include_all and // filters out binary assets (PNG/PDF/...), so they never reach the native tree. // recursive=true so nested files under each listed directory appear — the native // tree is built from this one listing and has no per-folder lazy fetch. const endpoint = buildUrl(`${appsEndpoint}/${encodeURIComponent(agentId)}/sandbox/files/list-entries`, { include_all: 'true', path, recursive: 'true', }); const response = await httpClient.get(endpoint, true); assertSuccess(response, 'Failed to load Superagent files'); return (response.data?.files ?? []).map((entry) => (typeof entry === 'string' ? entry : entry.path)); } async function listUserAgentSandboxFilePaths( httpClient: SuperagentHttpClient, appsEndpoint: string, agentId: string, ) { const paths = new Set(DEFAULT_SANDBOX_FILE_PATHS); await Promise.all( USER_AGENT_SANDBOX_LIST_PATHS.map(async (sandboxPath) => { try { const files = await listSandboxFilesAt(httpClient, appsEndpoint, agentId, sandboxPath); files.forEach((filePath) => paths.add(joinSandboxPath(sandboxPath, filePath))); } catch { // Missing optional directories should not make the Files tab look empty. } }), ); return [...paths]; } function mergeSandboxFilePaths(...pathGroups: string[][]) { return [...new Set(pathGroups.flat())]; } function joinSandboxPath(parentPath: string, childPath: string) { return `${parentPath.replace(/\/+$/, '')}/${childPath.replace(/^\/+/, '')}`; } async function updateAgent( httpClient: SuperagentHttpClient, appsEndpoint: string, agentId: string, body: Partial, ): Promise { const response = await httpClient.request(`${appsEndpoint}/${encodeURIComponent(agentId)}`, { authRequired: true, body, method: 'PUT', }); assertSuccess(response, 'Failed to update Superagent'); if (!response.data?.id) { throw new Error('Superagent update returned no app id'); } return toAgent(response.data); } function toAgent(app: UserAgentApp): SuperagentAgent { return { id: app.id, name: app.name || 'New Superagent', ownerId: app.owner_id, createdBy: app.created_by, description: app.user_description, logoUrl: app.logo_url, model: app.model ?? 'default', automationModel: app.automation_model ?? 'default', organizationId: app.organization_id ?? null, toolsPermissionConfig: app.tools_permission_config ?? undefined, avatarIndex: app.avatar_index, updatedAt: app.updated_date ?? app.created_date, workflowsEnabled: app.legacy_automations !== true, }; } function toConnector(integration: AppIntegration): SuperagentConnector { const meta = SUPERAGENT_CONNECTOR_CATALOG.find((connector) => connector.id === integration.integration_type); return { accessMode: detectAccessMode(integration.scopes ?? [], integration.integration_type), accountIdentifier: integration.integration_account_identifier, accessModes: meta?.accessModes, category: meta?.category, exampleScopes: meta?.exampleScopes, iconBackgroundColor: meta?.iconBackgroundColor, iconFallbackLabel: meta?.iconFallbackLabel, iconUrl: meta?.iconUrl, id: integration.integration_type, name: meta?.name ?? formatConnectorName(integration.integration_type), // Trust the live backend flag from /external-auth/list as well as the static // catalog metadata — either signalling config-required must surface in the UI. requiresConnectionConfig: meta?.requiresConnectionConfig || integration.requires_connection_config, scopes: integration.scopes, status: normalizeConnectorStatus(integration.status), subtitle: meta?.subtitle, supportsReadOnly: meta?.supportsReadOnly, }; } function detectAccessMode(scopes: string[], connectorId?: string): SuperagentConnectorAccessMode { const connector = connectorId ? SUPERAGENT_CONNECTOR_CATALOG.find((item) => item.id === connectorId) : undefined; if (connector?.accessModes) { const filtered = scopes.filter((scope) => !OAUTH_INJECTED_SCOPES.has(scope)); const readOnlyScopes = connector.accessModes.readOnly; const readOnlySet = new Set(readOnlyScopes); if (filtered.length === readOnlyScopes.length && filtered.every((scope) => readOnlySet.has(scope))) { return 'read_only'; } return 'full_access'; } const hasWriteScope = scopes.some((scope) => /write|send|create|edit|delete|manage|modify|repo/i.test(scope)); return hasWriteScope ? 'full_access' : 'read_only'; } function formatConnectorName(connectorId: string) { return connectorId .split(/[_-]/) .filter(Boolean) .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) .join(' '); } function getConnectorScopes(connectorId: string, accessMode?: SuperagentConnectorAccessMode) { const connector = SUPERAGENT_CONNECTOR_CATALOG.find((item) => item.id === connectorId); if (!connector) { return null; } if (connector.accessModes) { return accessMode === 'read_only' ? connector.accessModes.readOnly : connector.accessModes.fullAccess; } return connector.exampleScopes ?? null; } export function isPaymentIntegration(integrationType: string) { return integrationType === 'stripe' || integrationType === 'stripe_payment' || integrationType === 'wix_payments'; } function normalizeConnectorStatus(status?: string): SuperagentConnector['status'] { const normalized = status?.toLowerCase(); if (normalized === 'expired' || normalized === 'disconnected') { return normalized; } return 'active'; } function getAutomationPath( endpoints: { agentsEndpoint: string; appsEndpoint: string }, agentId: string, automation: SuperagentAutomation, ) { const type = getAutomationType(automation); // The personal-agent facade (/api/agents) only mounts scheduled/entity/ // connector lifecycle routes — in_app_agent lives only on the /api/apps // builder router. Mirror the web agent-editor, which routes every automation // subtype through /api/apps, so in_app_agent actions don't 404. (agentId is // the app id, the same base the workflow/connector/secret routes already use.) const baseEndpoint = type === 'in_app_agent' ? endpoints.appsEndpoint : endpoints.agentsEndpoint; return `${baseEndpoint}/${encodeURIComponent(agentId)}/automations/${type}/${encodeURIComponent(automation.id)}`; } function getAutomationType(automation: SuperagentAutomation) { if ( automation.automation_type === 'scheduled' || automation.automation_type === 'entity' || automation.automation_type === 'connector' || automation.automation_type === 'in_app_agent' ) { return automation.automation_type; } throw new Error(`Unsupported automation type: ${automation.automation_type}`); } function getWorkflowsBase(baseEndpoint: string, agentId: string) { return `${baseEndpoint}/${encodeURIComponent(agentId)}/workflows`; } function getWorkflowPath(baseEndpoint: string, agentId: string, workflow: SuperagentWorkflow) { return `${getWorkflowsBase(baseEndpoint, agentId)}/${encodeURIComponent(workflow.id)}`; } function normalizeAutomationResponse( updated: SuperagentAutomation | undefined, fallback: SuperagentAutomation, ): SuperagentAutomation { if (!updated?.id) { return fallback; } return { ...updated, automation_type: updated.automation_type ?? fallback.automation_type, }; } function normalizeTelegramStatus(status: TelegramStatusResponse | undefined): SuperagentTelegramChannelStatus { return { botLink: status?.bot_link, botName: status?.bot_name, botUsername: status?.bot_username, connected: !!status?.connected, }; } function normalizeIMessageStatus(status: IMessageStatusResponse | undefined): SuperagentIMessageChannelStatus { return { connected: !!status?.connected, phoneNumber: status?.phone_number ?? null, }; } function normalizeWhatsAppStatus(status: WhatsAppStatusResponse | undefined): SuperagentWhatsAppChannelStatus { return { connected: !!status?.connected, userHandle: status?.user_handle ?? null, }; } function normalizeSlackStatus(status: SlackStatusResponse | undefined): SuperagentSlackChannelStatus { return { agentDisplayName: status?.agent_display_name ?? null, connected: !!status?.connected, // absent => available; only an explicit false disables the card. supported: status?.supported ?? true, pendingCleanup: !!status?.pending_cleanup, teamId: status?.team_id ?? null, teamName: status?.team_name ?? null, usergroupHandle: status?.usergroup_handle ?? null, workspaces: (status?.workspaces ?? []).map((workspace) => ({ teamId: workspace?.team_id ?? null, teamName: workspace?.team_name ?? null, usergroupHandle: workspace?.usergroup_handle ?? null, })), }; } async function runtimeRequest( httpClient: SuperagentHttpClient, appsEndpoint: string, agentId: string, runtimeAuthToken: string, path: string, options: { body?: unknown; method: 'DELETE' | 'GET' | 'POST'; }, ) { return httpClient.request(`${appsEndpoint}/${encodeURIComponent(agentId)}${path}`, { authRequired: false, body: options.body, headers: { Authorization: `Bearer ${runtimeAuthToken}`, }, method: options.method, }); } function getAutomationTime(automation: SuperagentAutomation) { const dateValue = automation.updated_date ?? automation.created_date ?? automation.last_run_at; if (!dateValue) { return 0; } const timestamp = Date.parse(dateValue); return Number.isNaN(timestamp) ? 0 : timestamp; } function assertSuccess(response: HttpResult, fallback: string): asserts response is HttpResult & {success: true} { if (!response.success) { throw new Error(response.error?.message || fallback); } } function buildUrl(baseUrl: string, query: Record) { const queryString = Object.entries(query) .map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`) .join('&'); return `${baseUrl}?${queryString}`; }