Core SDK (plain JavaScript / TypeScript)
September 3, 2026 · View on GitHub
The framework-agnostic heart of the SDK. Everything else (React, Vue, Angular)
is a thin layer over the AgoClient documented here. Use this directly in a
vanilla web app, a Node service, a web component, or any framework not covered
by a dedicated binding.
npm install @useago/sdk
import { AgoClient } from "@useago/sdk";
1. Create a client
const ago = new AgoClient({
baseUrl: "https://YOUR-DOMAIN.useago.com", // required
agent: "support-bot", // optional default agent (id or slug)
userEmail: "jane@acme.com", // optional end-user identity
debug: true, // optional verbose logging
});
baseUrl is the only required option. See
Configuration & auth for every option and the request
headers they map to.
Zero-config
If your page already exposes config (a window.AGO object, <meta name="ago-base-url">
tags, or data-ago-* attributes), let the SDK find it:
import { createAgo } from "@useago/sdk";
const ago = createAgo(); // auto-detects from the DOM
const ago2 = createAgo({ debug: true }); // detect, then override a few keys
createAgo() throws if it can't find a baseUrl. Use
autoDetectConfig() if you want the resolved config object (or null) without
constructing a client.
2. Send a message and stream the reply
sendMessage resolves with the final assistant message, but the text arrives
incrementally through events. Subscribe before you send.
ago.on("message:start", ({ conversationId, messageId }) => {
console.log("New message", messageId, "in", conversationId);
});
const outputEl = document.getElementById("output"); // any element on your page
ago.on("message:chunk", ({ content }) => {
outputEl.textContent += content; // token-by-token streaming
});
ago.on("message:complete", (message) => {
console.log("Final:", message.content, "sources:", message.sources);
});
ago.on("message:error", ({ error }) => console.error(error));
const reply = await ago.sendMessage("How do I reset my password?");
Stop the answer
stop() interrupts the turn being generated. It closes the stream so the UI
stops on the spot, and calls the backend to stop generating. Both halves
matter: closing the stream alone does not stop the agent, which keeps running
and hands you the full answer the next time the conversation is loaded.
document.getElementById("stop").onclick = () => void ago.stop();
const reply = await ago.sendMessage("Write a long report");
// Pressed Stop? This still resolves, with the partial text and status "CANCELED".
The text produced so far is kept and the message is finalized as CANCELED. The
in-flight sendMessage / continueMessage promise resolves with that
partial message rather than rejecting: message:complete fires with it, then
message:stopped.
Finalize your UI on message:stopped. It is the one event that always fires,
while message:complete is skipped when there is nothing to complete: a stop
that landed before the backend had named the message, and a turn stopped while
it was paused on client functions (no stream is open then).
stop() resolves with the backend's verdict, or null when nothing was
generating (or when the stop landed before the backend had named the message).
isGenerating() tells you whether there is anything to stop, so a button can
switch between Send and Stop. A turn paused on client functions
(WAITING_CLIENT) counts as generating and is still stoppable; stopping it also
cancels the automatic resume.
To stop a turn started elsewhere (one still running after a page reload, found
through getConversation), address it by id:
const result = await ago.stopMessage(messageId);
result.status; // "stopping" | "not_running" | "not_supported"
stopMessage is idempotent: stopping a turn that already finished answers
{ status: "not_running" } instead of failing. "not_supported" is a background
agent run, which cannot be stopped once started.
Continue a conversation
sendMessage returns the message, whose conversationId you reuse to keep the
thread going:
const first = await ago.sendMessage("Hi");
await ago.sendMessage("Tell me more", { conversationId: first.conversationId });
File attachments
await ago.sendMessage("Summarise this", {
files: [fileInput.files[0]], // File[], sent as multipart/form-data
conversationId,
});
Uploaded files come back on the message that carries them, under
message.attachments (an AgoAttachment[]):
const thread = await ago.getConversation(conversationId);
for (const msg of thread.messages ?? []) {
for (const file of msg.attachments ?? []) {
file.name; // "invoice.pdf"
file.url; // presigned, time-limited URL
file.isSafeImage; // backend verdict: safe to embed inline?
}
}
The widget and the React <Message> render these for you. They show the upload
on the user's own bubble right away (a local preview), then the presigned URL
once it loads.
Secure display. The SDK embeds a file inline as an <img> only when the
backend has verified it is a real, script-free image (isSafeImage === true).
Everything else (PDFs, documents, SVGs, and any unverified or spoofed type)
renders as a download link, never embedded. The rule is secure by default: if
isSafeImage is absent, the file is treated as a download. Building a custom UI?
Use the same gate via the exported helper:
import { canInlineImage } from "@useago/sdk";
canInlineImage(file)
? renderImage(file.url)
: renderDownloadLink(file.url, file.name);
Override the agent per message
await ago.sendMessage("Escalate this", { agentId: "human-handoff" });
3. Conversations
const conversations = await ago.getConversations();
// → [{ id, title, lastMessageDate }]
const thread = await ago.getConversation(conversations[0].id);
// → { id, title, lastMessageDate, messages: AgoMessage[] }
const messages = await ago.getMessages(conversations[0].id);
4. Let the agent run code in the browser
Register client-side functions and the agent can call them mid-conversation. This is the SDK's superpower; full guide in Client-side functions & context.
ago.registerFunction({
name: "lookupOrder",
description: "Look up an order by its ID",
parameters: {
type: "object",
properties: { id: { type: "string", description: "Order ID" } },
required: ["id"],
},
handler: async (args) => fetchOrder(args.id as string),
});
Navigation is a built-in convenience:
ago.registerNavigationFunction(
(path) => (window.location.href = path),
[
{ name: "pricing", path: "/pricing", description: "Pricing page" },
{ name: "docs", path: "/docs", description: "Documentation" },
{ name: "orderDetail", path: "/orders/:id", description: "One order's detail page" },
],
);
Paths can contain :param placeholders. Each placeholder becomes a top-level
argument of navigateToPage, so { page: "orderDetail", id: "42" } navigates
to /orders/42. One route covers every detail page of an entity.
This also reports the current page (by route name, plus URL and title) as context on every message, so the agent knows which page the user is on.
Page state is the mirror: let the agent change the current page's state (filters, sort, view mode…) and read it back.
ago.registerPageStateFunction([
{
name: "statusFilter",
description: "Filter the list by status",
schema: { type: "string", enum: ["all", "paid", "overdue"] },
get: () => filters.status,
set: (v) => { filters.status = v as string; },
},
]);
5. Give the agent context
Tell the agent what the user is currently doing so it answers in context. Sent with every message:
ago.setContext("order-page", {
name: "Order detail",
description: "User is viewing an order",
data: { orderId: "123", status: "shipped" },
});
// Re-evaluated on every send, great for live stores
// (cart / cartTotal() come from your outer scope or store):
ago.addDynamicContext("cart", () => ({
name: "Cart",
data: { itemCount: cart.length, total: cartTotal() },
}));
// One-liner to attach the current URL + page title:
ago.enableAutoPageContext();
Full details in Client context.
Hold live state with createStore
Dynamic context and client-side functions usually need to read and update some
shared state (a form being filled in, a draft request, the current selection).
createStore is a tiny observable holder (get / set / subscribe) so the
agent's functions and your UI react to the same source of truth.
import { createStore } from "@useago/sdk";
const store = createStore({ items: [] as string[] });
// Push to your UI / analytics on every change:
store.subscribe((state) => render(state));
// Feed it to the agent, re-read on every send:
ago.addDynamicContext("cart", () => ({ name: "Cart", data: store.get() }));
// A registered function can mutate it; subscribers fire synchronously:
store.set({ items: [...store.get().items, "SKU-1"] });
Persist across reloads
Pass a persist option with a storage key and the store hydrates from
storage on creation and writes back on every set: no manual subscribe
plumbing. It defaults to localStorage; unavailable storage degrades to an
in-memory store, and a malformed saved snapshot falls back to the initial value.
const store = createStore({ items: [] as string[] }, { key: "cart" });
Pass a storage backend (anything with getItem / setItem) to swap
localStorage, e.g. sessionStorage or a test double:
const store = createStore(initial, { key: "request", storage: sessionStorage });
In React and Vue, read the same store reactively with useAgoStore
(React ·
Vue).
Resume the visitor's last thread with createConversationSession
A returning visitor is identified by a single, stable anon id: the same id the
HTTP client sends as X-User-Anon-Id. One id, generated once and reused forever.
Alongside it, the session caches the last
active thread (its id + the time of its last message) so resuming on reload is a pure
front-side decision: no backend call just to check whether the thread is still fresh.
import { createConversationSession } from "@useago/sdk";
const session = createConversationSession();
// Resume on load: front-only, no request (null when none / stale / undated):
const conversationId = session.getLastActiveThread() ?? undefined;
const reply = await ago.sendMessage(text, { conversationId });
// Record the thread once a turn completes (drives the sliding TTL):
session.setActiveThread(reply.conversationId, reply.createdAt);
session.clear(); // forget it, e.g. a "new chat" button
console.log(session.widgetId); // exposed for debugging / correlation
The widget id is read from (or written to) ago_widget_id, generating a UUID on first
use, no per-agent key. The cached thread lives under ago_last_thread and is only
resumed when its last message is within ttlMs (default 2h, sliding); a thread that
is older (or has no recorded last-message time) is treated as stale and
getLastActiveThread returns null. Like createStore it defaults to localStorage
and is storage-injectable: pass sessionStorage for a tab-scoped session.
| Option | Default | Purpose |
|---|---|---|
storage | localStorage | Any { getItem, setItem }; e.g. sessionStorage. |
key | "ago_widget_id" | Storage key for the widget id (shared with the HTTP client). |
widgetId | — | Adopt (and persist) an explicit visitor id. |
threadKey | "ago_last_thread" | Storage key for the cached last active thread. |
ttlMs | 7200000 (2h) | Max idle age (vs the recorded last-message time) to still resume, checked on the front. Infinity = never. |
The vanilla widget wires this up for you: set
persistConversationonmountChatWidgetand it resumes the last active thread automatically.
6. Tool calls, feedback and lifecycle
// Tool calls the agent surfaces (forms, confirmations); see events below
await ago.submitToolCallForm(toolCallId, { quantity: 3 });
await ago.confirmToolCall(toolCallId);
await ago.rejectToolCall(toolCallId);
// The ticket form behind the agent's `ago_ticketing` tool: its fields come from
// the tenant config, the ticket is created, then the tool call is completed.
// The vanilla widget does all three for you (see widget.md, "Ticket form").
const { permissions } = await ago.getConfig();
const ticketForm = permissions[0]?.ticketForm;
const ticket = await ago.createTicket({
subject: "Login broken",
body: "I cannot log in since this morning.",
conversationId,
customFields: [{ id: "product", value: "app" }],
ticketFormId: ticketForm?.id,
});
await ago.submitToolCallForm(toolCallId, { success: true, ticket_id: ticket.id, ticket_url: ticket.url });
// Thumbs up / down on an assistant message
await ago.submitFeedback(messageId, "positive");
// "This answer doesn't work": the reasons and comment land in the AGO
// feedback dashboard, where someone can act on them
await ago.submitFeedback(messageId, "negative", {
reasons: ["inaccurate"],
comment: "The price it quoted is from last year.",
});
// "This whole conversation doesn't work": attached to its last answer,
// whose id is returned
await ago.submitConversationFeedback(conversationId, "negative", {
reasons: ["information_not_found"],
});
// Change config at runtime (e.g. after login)
ago.updateConfig({ userJwt: token });
// Clean up listeners, functions and context
ago.destroy();
7. Events
AgoClient is an event emitter. Subscribe with on, drop with off, and use
once / waitFor for one-shot needs.
ago.once("message:complete", (m) => console.log("first reply", m.content));
const msg = await ago.waitFor("message:complete", { timeout: 10_000 });
| Event | Payload |
|---|---|
message:start | { conversationId, messageId } |
message:chunk | { content, conversationId, messageId } |
message:answer-complete | AgoMessage: main answer done, follow-up replies may still be pending; fires before message:complete |
message:complete | AgoMessage |
message:stopped | { conversationId, messageId }: the turn was stopped with stop(); fires after the message:complete carrying the partial answer with status CANCELED, or on its own when there was nothing to complete |
message:error | { error, code?, conversationId?, messageId? } |
conversation:loaded | Conversation: full conversation loaded from the server (e.g. after a page reload) |
context:changed | ContextSnapshot | null: client-side context changed |
toolCall:received | ToolCallData |
toolCall:form | ToolCallData (only when type === "form") |
function:invoke | { invocationId, functionName, arguments, conversationId } |
function:result | { invocationId, result, error? } |
connection:status | { connected } |
Prefer callbacks over raw events? See the streaming helpers and async generator.
AgoClient API reference
Messaging
sendMessage(content, options?)→Promise<AgoMessage>options.conversationId?·options.agentId?·options.files?: File[]
stop()→Promise<StopMessageResult | null>: stop the turn being generated (nullwhen nothing was generating)stopMessage(messageId)→Promise<StopMessageResult>: stop a turn by idisGenerating()→boolean
Conversations
getConversations()→Promise<Conversation[]>getConversation(id)→Promise<Conversation>(includesmessages)getMessages(conversationId)→Promise<AgoMessage[]>
Functions
registerFunction(definition)/registerFunction(name, handler, schema)register(definitionOrArray): short alias, accepts an arrayunregisterFunction(name)→booleangetRegisteredFunctions()→ClientFunctionSchema[]registerNavigationFunction(navigate, routes)·unregisterNavigationFunction()(also publishes a "Current page" context entry)registerPageStateFunction(controls, opts?)·unregisterPageStateFunction(functionName?)attachAutoContinueAfterNavigation(client, options?)→ detach fn (standalone export, see Client functions & context)
Context
setContext(key, entry)·removeContext(key)addDynamicContext(key, provider)·removeDynamicContext(key)enableAutoPageContext()getContextSnapshot()→ContextSnapshot | nullnotifyContextChanged(): re-emitcontext:changedwith a fresh snapshot (for stateful helpers that mutate their own store)
Tool calls & feedback
submitToolCallForm(toolCallId, formData)confirmToolCall(toolCallId)·rejectToolCall(toolCallId)getConfig()→Promise<SdkConfig>: the tenant's per-permission agents, ticket form (ticketForm), and file-attachment flag (GET /config)createTicket({ subject, body, priority?, typology?, conversationId?, email?, customFields?, files?, ticketFormId? })→Promise<{ id, url? }>: file a support ticket (POST /tickets, multipart)getUserIdentity()→{ email?, hasJwt }: how the client identifies the visitor (the ticket form asks for an email when it has neither)submitFormCollector(name, values)→Promise<unknown>: relay form values through the backend, which resolves the destination from the named form's stored definition (used bycreateFormCollectorin{ via: "backend" }mode)submitFeedback(messageId, "positive" | "negative", details?)details.reasons?: FeedbackReason[]·details.comment?: string
submitConversationFeedback(conversationId, rating, details?)→Promise<string>: the id of the answer it was attached to. Passdetails.lastMessageIdwhen you already have it to skip the lookup.
A rating on its own is a reaction (the thumbs counted in the dashboard). Add a
reason or a comment and the report also lands in the feedback list, the
analytics and the CSV export. reasons accepts any of "inaccurate",
"incomplete", "information_not_found", "technical_issue" (exported as
FEEDBACK_REASONS); an unknown value is refused with a 422, as is a list
longer than the four reasons. The SDK de-duplicates the list before sending, so
repeating one never trips that limit. A comment is trimmed and capped at 5000
characters.
Events
on(event, handler)·off(event, handler)·once(event, handler)waitFor(event, { timeout? })→Promise<payload>
Lifecycle
updateConfig(partialConfig)destroy()
Key types
interface AgoMessage {
id: string;
conversationId: string;
content: string;
role: "user" | "assistant";
status: "IN_PROGRESS" | "DONE" | "ERROR" | "TODO" | "CANCELED";
agent?: AgoAgent;
sources?: AgoSource[]; // knowledge-base citations
toolCalls?: ToolCallData[];
followUpReplies?: string[]; // suggested next questions
createdAt: Date;
}
interface Conversation {
id: string;
title: string;
lastMessageDate: Date;
messages?: AgoMessage[];
}
interface StopMessageResult {
status: "stopping" | "not_running" | "not_supported";
messageStatus?: AgoMessage["status"]; // status when the stop was requested
}
interface ToolCallData {
id: string;
type: "form" | "confirmation_input" | "status_message" | "progress_indicator" | "client_function" | "reasoning" | "mcp_ui_resource";
status: string;
toolName: string;
toolDisplayName?: string;
message?: string;
formSchema?: FormSchema;
data?: Record<string, unknown>; // after a submit: { success, ticket: { ticket_id, ticket_url } }
displayMode?: "collapsible" | "display" | "both";
// Ticketing `form` tool calls (toolName "ago_ticketing")
askToTalkToHuman?: boolean;
allowedToCreateTicket?: boolean;
ticket?: { subject?; body?; typology?; priority?; tag?; custom_fields?: Record<string, string> };
mode?: "form" | "embed";
ticketFormId?: string;
embedHtml?: string;
embedDescription?: string;
}
All types are exported from @useago/sdk.
Conversational forms
createFormCollector(options) builds a framework-agnostic form collector: it
registers update_<name> / submit_<name> client functions so the agent can
fill a form field-by-field during the conversation.
import { createFormCollector } from "@useago/sdk";
const collector = createFormCollector({
name: "contact", // becomes update_contact / submit_contact
description: "Collect the visitor's contact details",
schema: {
properties: {
email: { type: "string", description: "Work email" },
company: { type: "string" },
},
required: ["email"],
},
submit: { via: "backend" }, // optional; default: collect only
initialValues: { company: "ACME" },
});
collector.install(ago); // register functions + context on a client
-
submitaccepts{ via: "client", url }(a bare string is shorthand),{ via: "backend" }(the destination is resolved server-side from the form's stored definition; the client callsago.submitFormCollector()), orfalse/omitted to only collect values. -
autoSubmit(defaulttruewhen asubmittarget is set) submits the form on its own as soon as every required field is filled (at most once), so there is no confirmation step and nosubmit_<name>function is exposed. PassautoSubmit: falseto keep the manual flow (the agent callssubmit_<name>after the user confirms). Collect-only forms never auto-submit. -
Fields can declare
requiredWhenconditions; requirements are re-evaluated as values change. A condition is a single leaf ({ property, op?, value }) or a boolean combination viaanyOf(OR) /allOf(AND), which nest. For example, requireassurancewhen the applicant has a property loan or is a tenant:assurance: { type: "string", requiredWhen: { anyOf: [ { property: "nb_credit_immo", op: ">=", value: "1" }, { property: "locataire", value: "1" }, ], }, }, -
deriveFormStatus(schema, values)→FormCollectorStatuscomputes which required fields are still missing, useful for custom UIs. -
React users: see
useFormCollectorin the React guide.
Advanced exports
These are exported from @useago/sdk for advanced integrations; most apps
never need them directly:
FunctionRegistry: the registry behindregisterFunction; useful to manage a function set outside of a client instance.ClientContextRegistry: the registry behind the context API (setContext, dynamic providers, snapshots).EventEmitter: the minimal typed emitterAgoClientextends.logger: the SDK's internal logger (silent unless enabled vialogger.enable(); errors always log).