diff --git a/src/client.ts b/src/client.ts index 8aa63f3..cc68d4d 100644 --- a/src/client.ts +++ b/src/client.ts @@ -10,6 +10,7 @@ import { import { getAccessToken } from "./utils/auth-utils.js"; import { createFunctionsModule } from "./modules/functions.js"; import { createAgentsModule } from "./modules/agents.js"; +import { createSuperagentModule } from "./modules/superagent.js"; import { createAiGatewayModule } from "./modules/ai-gateway.js"; import { createAppLogsModule } from "./modules/app-logs.js"; import { createUsersModule } from "./modules/users.js"; @@ -200,8 +201,16 @@ export function createClient(config: CreateClientConfig): Base44Client { appId, userAuthModule, }), + // Deliberately not given the host app's token: superagent access is + // always anonymous against the sibling superagent app. + superagent: createSuperagentModule({ + serverUrl, + headers: optionalHeaders, + onError: options?.onError, + }), cleanup: () => { userModules.analytics.cleanup(); + userModules.superagent.cleanup(); if (socket) { socket.disconnect(); } diff --git a/src/client.types.ts b/src/client.types.ts index 611f81b..d456e5f 100644 --- a/src/client.types.ts +++ b/src/client.types.ts @@ -8,6 +8,7 @@ import type { } from "./modules/connectors.types.js"; import type { FunctionsModule } from "./modules/functions.types.js"; import type { AgentsModule } from "./modules/agents.types.js"; +import type { SuperagentModule } from "./modules/superagent.types.js"; import type { AiGatewayModule } from "./modules/ai-gateway.types.js"; import type { AppLogsModule } from "./modules/app-logs.types.js"; import type { AnalyticsModule } from "./modules/analytics.types.js"; @@ -104,6 +105,8 @@ export interface Base44Client { functions: FunctionsModule; /** {@link IntegrationsModule | Integrations module} for calling pre-built integration endpoints. */ integrations: IntegrationsModule; + /** {@link SuperagentModule | Superagent module} for anonymous chat with the app's linked public superagent. */ + superagent: SuperagentModule; /** Cleanup function to disconnect WebSocket connections. Call when you're done with the client. */ cleanup: () => void; diff --git a/src/index.ts b/src/index.ts index 3c445bb..8768822 100644 --- a/src/index.ts +++ b/src/index.ts @@ -100,6 +100,12 @@ export type { CreateConversationParams, } from "./modules/agents.types.js"; +export type { + SuperagentModule, + SuperagentHandle, + CreateSuperagentConversationParams, +} from "./modules/superagent.types.js"; + export type { AiGatewayModule, AiGatewayConnection, diff --git a/src/modules/superagent.ts b/src/modules/superagent.ts new file mode 100644 index 0000000..18c2b3d --- /dev/null +++ b/src/modules/superagent.ts @@ -0,0 +1,162 @@ +import { createAxiosClient } from "../utils/axios-client.js"; +import { RoomsSocket } from "../utils/socket-utils.js"; +import { ModelFilterParams } from "../types.js"; +import { AgentConversation, AgentMessage } from "./agents.types.js"; +import { + CreateSuperagentConversationParams, + SuperagentHandle, + SuperagentModule, + SuperagentModuleConfig, +} from "./superagent.types.js"; + +// A public superagent app always exposes its agent under this fixed name. +const SUPERAGENT_AGENT_NAME = "your_agent"; + +export function createSuperagentModule({ + serverUrl, + headers, + onError, +}: SuperagentModuleConfig): SuperagentModule { + const handles: Record = {}; + const sockets: Record = {}; + + const createHandle = (appId: string): SuperagentHandle => { + const baseURL = `/apps/${appId}/agents`; + + // Dedicated anonymous client for the superagent app: no Authorization + // token, so the request interceptor attaches X-Base44-Anonymous-Id and + // the backend serves the caller as an anonymous visitor of the sibling + // superagent app. + const axios = createAxiosClient({ + baseURL: `${serverUrl}/api`, + headers: { + ...headers, + "X-App-Id": String(appId), + }, + onError, + }); + + const getSocket = () => { + if (!sockets[appId]) { + sockets[appId] = RoomsSocket({ + config: { + serverUrl, + mountPath: "/ws-user-apps/socket.io/", + transports: ["websocket"], + appId, + anonymous: true, + }, + }); + } + return sockets[appId]; + }; + + // Track active conversations + const currentConversations: Record = + {}; + + const createConversation = ( + params: CreateSuperagentConversationParams = {} + ) => { + return axios.post(`${baseURL}/conversations`, { + agent_name: SUPERAGENT_AGENT_NAME, + ...params, + }); + }; + + const listConversations = (filterParams: ModelFilterParams = {}) => { + return axios.get(`${baseURL}/conversations`, { + params: filterParams, + }); + }; + + const getConversation = (conversationId: string) => { + return axios.get( + `${baseURL}/conversations/${conversationId}` + ); + }; + + const addMessage = async ( + conversation: AgentConversation, + message: Partial + ) => { + return axios.post( + `${baseURL}/conversations/v2/${conversation.id}/messages`, + message + ); + }; + + const subscribeToConversation = ( + conversationId: string, + onUpdate?: (conversation: AgentConversation) => void + ) => { + const room = `/agent-conversations/${conversationId}`; + const socket = getSocket(); + + // Store the promise for initial conversation state + const conversationPromise = getConversation(conversationId).then( + (conv) => { + currentConversations[conversationId] = conv; + return conv; + } + ); + + return socket.subscribeToRoom(room, { + connect: () => {}, + update_model: async ({ data: jsonStr }) => { + const data = JSON.parse(jsonStr); + + if (data._message) { + // Wait for initial conversation to be loaded + await conversationPromise; + const message = data._message as AgentMessage; + + // Update shared conversation state + const currentConversation = currentConversations[conversationId]; + if (currentConversation) { + const messages = currentConversation.messages || []; + const existingIndex = messages.findIndex( + (m) => m.id === message.id + ); + + const updatedMessages = + existingIndex !== -1 + ? messages.map((m, i) => (i === existingIndex ? message : m)) + : [...messages, message]; + + currentConversations[conversationId] = { + ...currentConversation, + messages: updatedMessages, + }; + onUpdate?.(currentConversations[conversationId]!); + } + } + }, + }); + }; + + return { + createConversation, + listConversations, + getConversation, + addMessage, + subscribeToConversation, + }; + }; + + const forApp = (appId: string) => { + if (!handles[appId]) { + handles[appId] = createHandle(appId); + } + return handles[appId]; + }; + + const cleanup = () => { + Object.values(sockets).forEach((socket) => socket.disconnect()); + }; + + return { + forApp, + cleanup, + }; +} diff --git a/src/modules/superagent.types.ts b/src/modules/superagent.types.ts new file mode 100644 index 0000000..d84b54a --- /dev/null +++ b/src/modules/superagent.types.ts @@ -0,0 +1,194 @@ +import { ModelFilterParams } from "../types.js"; +import { AgentConversation, AgentMessage } from "./agents.types.js"; + +// Superagent conversations share the same wire shapes as agent conversations. +// Re-export them so consumers can type superagent results without reaching +// into agents.types. +export type { + AgentConversation, + AgentMessage, + AgentMessageReasoning, + AgentMessageToolCall, + AgentMessageUsage, + AgentMessageCustomContext, + AgentMessageMetadata, +} from "./agents.types.js"; + +/** + * Parameters for creating a new superagent conversation. + * + * Unlike {@linkcode CreateConversationParams | agent conversations}, no agent + * name is accepted: a public superagent is always exposed under a single + * fixed agent name, which the SDK sets internally. + */ +export interface CreateSuperagentConversationParams { + /** Optional metadata to attach to the conversation. */ + metadata?: Record; +} + +/** + * Configuration for creating the superagent module. + * @internal + */ +export interface SuperagentModuleConfig { + /** Server URL */ + serverUrl: string; + /** Additional headers to include in API requests */ + headers?: Record; + /** Optional error handler for API errors */ + onError?: (error: Error) => void; +} + +/** + * A handle bound to a specific public superagent app. + * + * A public superagent is a separate Base44 app linked to a host app, serving + * the host app's end users as an anonymous-visitor chat agent. All requests + * made through a handle target the superagent app's ID and are sent + * anonymously: no Authorization token is attached, and the caller is + * identified by a stable anonymous visitor ID instead (the + * `X-Base44-Anonymous-Id` header on HTTP requests, and the `anonymous_id` + * handshake parameter on the realtime socket). + */ +export interface SuperagentHandle { + /** + * Creates a new conversation with the public superagent. + * + * The agent name is set internally — a public superagent always exposes a + * single fixed agent. + * + * @param params - Optional conversation parameters (metadata). + * @returns Promise resolving to the created conversation. + * + * @example + * ```typescript + * const agent = base44.superagent.forApp('superagent-app-id'); + * const conversation = await agent.createConversation({ + * metadata: { source: 'help-widget' } + * }); + * ``` + */ + createConversation( + params?: CreateSuperagentConversationParams + ): Promise; + + /** + * Lists the current anonymous visitor's conversations with the superagent. + * + * @param filterParams - Optional filter parameters for querying conversations. + * @returns Promise resolving to an array of conversations. + * + * @example + * ```typescript + * const conversations = await agent.listConversations(); + * ``` + */ + listConversations( + filterParams?: ModelFilterParams + ): Promise; + + /** + * Gets a specific conversation by ID, including its full message history. + * + * @param conversationId - The unique identifier of the conversation. + * @returns Promise resolving to the conversation, or undefined if not found. + */ + getConversation( + conversationId: string + ): Promise; + + /** + * Adds a message to a conversation. + * + * Sends a message to the superagent and updates the conversation. The + * agent's response is delivered through the realtime subscription (see + * {@linkcode subscribeToConversation | subscribeToConversation()}). + * + * @param conversation - The conversation to add the message to. + * @param message - The message to add. + * @returns Promise resolving to the created message. + * + * @example + * ```typescript + * await agent.addMessage(conversation, { + * role: 'user', + * content: 'How do I reset my password?' + * }); + * ``` + */ + addMessage( + conversation: AgentConversation, + message: Partial + ): Promise; + + /** + * Subscribes to realtime updates for a conversation. + * + * Establishes a dedicated anonymous WebSocket connection to the superagent + * app and receives instant updates when messages are added or updated. + * Returns an unsubscribe function to clean up the subscription. + * + * @param conversationId - The conversation ID to subscribe to. + * @param onUpdate - Callback invoked with the updated conversation. + * @returns Unsubscribe function to stop receiving updates. + * + * @example + * ```typescript + * const unsubscribe = agent.subscribeToConversation( + * conversation.id, + * (updated) => { + * const latest = updated.messages[updated.messages.length - 1]; + * console.log('New message:', latest.content); + * } + * ); + * + * // Later, clean up the subscription + * unsubscribe(); + * ``` + */ + subscribeToConversation( + conversationId: string, + onUpdate?: (conversation: AgentConversation) => void + ): () => void; +} + +/** + * Superagent module for chatting with an app's linked public superagent. + * + * A host app can have a linked "public superagent" — a separate Base44 app + * that serves the host app's end users as a chat agent without requiring + * them to sign in. This module provides anonymous access to that superagent + * app: it never uses the host app's authentication token. Instead, requests + * carry a stable anonymous visitor ID so the backend can group conversations + * per visitor. + * + * @example + * ```typescript + * const agent = base44.superagent.forApp('superagent-app-id'); + * + * const conversation = await agent.createConversation(); + * const unsubscribe = agent.subscribeToConversation( + * conversation.id, + * (updated) => console.log(updated.messages) + * ); + * await agent.addMessage(conversation, { role: 'user', content: 'Hi!' }); + * ``` + */ +export interface SuperagentModule { + /** + * Gets a handle bound to a public superagent app. + * + * Handles are cached per app ID, so repeated calls with the same ID return + * the same handle (and reuse its HTTP client and socket connection). + * + * @param appId - The superagent app's ID (not the host app's ID). + * @returns A handle for interacting with the superagent. + */ + forApp(appId: string): SuperagentHandle; + + /** + * Disconnects all superagent socket connections. + * @internal + */ + cleanup(): void; +} diff --git a/src/utils/socket-utils.ts b/src/utils/socket-utils.ts index e05f150..bb15e88 100644 --- a/src/utils/socket-utils.ts +++ b/src/utils/socket-utils.ts @@ -8,6 +8,12 @@ export interface RoomsSocketConfig { transports: string[]; appId: string; token?: string; + /** + * Force an anonymous handshake: never attach a token (including the stored + * access token fallback), so the connection is identified only by its + * anonymous visitor id. + */ + anonymous?: boolean; } export type TSocketRoom = string; @@ -42,7 +48,9 @@ function initializeSocket( // handshake so the backend can verify room access for anonymous agent // conversations (mirrors the X-Base44-Anonymous-Id HTTP header). Authenticated // clients are identified by their token instead. - const resolvedToken = config.token ?? getAccessToken(); + const resolvedToken = config.anonymous + ? undefined + : config.token ?? getAccessToken(); const query: Record = { app_id: config.appId, token: resolvedToken, diff --git a/tests/unit/superagent.test.ts b/tests/unit/superagent.test.ts new file mode 100644 index 0000000..ac504d5 --- /dev/null +++ b/tests/unit/superagent.test.ts @@ -0,0 +1,163 @@ +import { describe, test, expect, beforeEach, afterEach } from "vitest"; +import nock from "nock"; +import { createClient } from "../../src/index.ts"; + +describe("Superagent Module", () => { + let base44: ReturnType; + let scope: nock.Scope; + const hostAppId = "host-app-id"; + const superagentAppId = "superagent-app-id"; + const serverUrl = "https://api.base44.com"; + + beforeEach(() => { + base44 = createClient({ serverUrl, appId: hostAppId }); + scope = nock(serverUrl); + nock.disableNetConnect(); + }); + + afterEach(() => { + nock.cleanAll(); + nock.enableNetConnect(); + }); + + describe("forApp", () => { + test("should return the same handle for the same app id", () => { + const first = base44.superagent.forApp(superagentAppId); + const second = base44.superagent.forApp(superagentAppId); + expect(second).toBe(first); + }); + + test("should return distinct handles for distinct app ids", () => { + const first = base44.superagent.forApp(superagentAppId); + const other = base44.superagent.forApp("other-superagent-app-id"); + expect(other).not.toBe(first); + }); + }); + + describe("createConversation", () => { + test("should post to the superagent app path with agent_name your_agent", async () => { + const created = { id: "conv-new", agent_name: "your_agent", messages: [] }; + let capturedBody: any; + scope + .post( + `/api/apps/${superagentAppId}/agents/conversations`, + (body) => { + capturedBody = body; + return true; + } + ) + .reply(200, created); + + const agent = base44.superagent.forApp(superagentAppId); + const result = await agent.createConversation({ + metadata: { source: "help-widget" }, + }); + + expect(result).toEqual(created); + expect(capturedBody).toEqual({ + agent_name: "your_agent", + metadata: { source: "help-widget" }, + }); + }); + + test("should default to an empty payload with agent_name your_agent", async () => { + const created = { id: "conv-new", agent_name: "your_agent", messages: [] }; + let capturedBody: any; + scope + .post( + `/api/apps/${superagentAppId}/agents/conversations`, + (body) => { + capturedBody = body; + return true; + } + ) + .reply(200, created); + + const result = await base44.superagent + .forApp(superagentAppId) + .createConversation(); + + expect(result).toEqual(created); + expect(capturedBody).toEqual({ agent_name: "your_agent" }); + }); + }); + + describe("listConversations", () => { + test("should fetch conversations from the superagent app path", async () => { + const mockConversations = [ + { id: "conv-1", agent_name: "your_agent", messages: [] }, + { id: "conv-2", agent_name: "your_agent", messages: [] }, + ]; + scope + .get(`/api/apps/${superagentAppId}/agents/conversations`) + .reply(200, mockConversations); + + const result = await base44.superagent + .forApp(superagentAppId) + .listConversations(); + expect(result).toEqual(mockConversations); + }); + }); + + describe("getConversation", () => { + test("should fetch a specific conversation", async () => { + const mockConversation = { + id: "conv-1", + agent_name: "your_agent", + messages: [], + }; + scope + .get(`/api/apps/${superagentAppId}/agents/conversations/conv-1`) + .reply(200, mockConversation); + + const result = await base44.superagent + .forApp(superagentAppId) + .getConversation("conv-1"); + expect(result).toEqual(mockConversation); + }); + }); + + describe("addMessage", () => { + test("should post to the v2 messages endpoint", async () => { + const conversation = { + id: "conv-1", + agent_name: "your_agent", + messages: [], + } as any; + const response = { id: "msg-1", role: "assistant", content: "Hello!" }; + scope + .post( + `/api/apps/${superagentAppId}/agents/conversations/v2/conv-1/messages` + ) + .reply(200, response); + + const result = await base44.superagent + .forApp(superagentAppId) + .addMessage(conversation, { role: "user", content: "Hi" }); + expect(result).toEqual(response); + }); + }); + + describe("anonymous auth isolation", () => { + test("should send X-App-Id of the superagent app and no Authorization, even when the host client has a token", async () => { + const authedClient = createClient({ + serverUrl, + appId: hostAppId, + token: "host-app-token", + }); + + let capturedHeaders: Record | undefined; + scope + .get(`/api/apps/${superagentAppId}/agents/conversations`) + .reply(200, function () { + capturedHeaders = this.req.headers; + return []; + }); + + await authedClient.superagent.forApp(superagentAppId).listConversations(); + + expect(capturedHeaders?.["x-app-id"]).toBe(superagentAppId); + expect(capturedHeaders?.["authorization"]).toBeUndefined(); + }); + }); +});