JavaScript SDK (@asgard-js/core)
@asgard-js/core is the framework-agnostic JavaScript SDK. It handles the SSE stream, channel management and conversation state, and works in Node.js, in the browser, and anywhere you are not using the @asgard-js/react components (headless, Vue, Svelte).
With React you usually do not need @asgard-js/core directly. Use the <Chatbot> component from @asgard-js/react instead; see the React SDK reference.
Installation
npm install @asgard-js/core
Core concepts
| Class | What it is |
|---|---|
AsgardServiceClient | Connection configuration (botProviderEndpoint, apiKey, customHeaders) |
Channel | One conversation channel, talking to the Bot Provider over SSE |
Conversation | The container holding message state |
Basic usage
import { AsgardServiceClient, Channel, Conversation } from '@asgard-js/core';
const client = new AsgardServiceClient({
botProviderEndpoint:
'https://api.asgard-ai.com/ns/{namespace}/bot-provider/{botProviderId}',
});
const conversation = new Conversation({ messages: new Map() });
const channel = await Channel.reset({
client,
customChannelId: 'channel-123',
conversation,
statesObserver: (states) => {
console.log('isConnecting:', states.isConnecting);
console.log(
'Messages:',
Array.from(states.conversation.messages.values()),
);
},
});
await channel.sendMessage({ text: 'Hello!' });
AsgardServiceClient configuration
| Option | Type | Required | Description |
|---|---|---|---|
botProviderEndpoint | string | Yes | The Bot Provider's base URL |
apiKey | string | No | API key (Direct Connect mode) |
customHeaders | Record<string, string> | No | Custom HTTP headers |
Channel and file operations
Beyond opening a connection, AsgardServiceClient offers these methods:
| Method | Signature | Description |
|---|---|---|
uploadFile | (file: File, customChannelId: string) => Promise<BlobUploadResponse> | Upload a file; the returned blob can be attached to a later message |
downloadChannelHomeFile | (relativePath: string, customChannelId: string) => Promise<ChannelHomeDownloadResult> | Download a file under that channel's home |
channelMetadata | (customChannelId: string) => Promise<ChannelMetadata | null> | Ask whether a channel exists; null when it does not |
suspendChannel | (customChannelId: string, options?) => Promise<void> | Stop the run currently executing on that channel |
deleteChannel | (customChannelId: string) => Promise<void> | Delete the channel |
detach | (options: { timeoutMs: number }) => void | Stop accepting new requests, then close once the in-flight ones finish |
close | () => void | Close immediately |
detach and close differ in how they treat requests already in flight. close shuts down straight away. detach marks the client as no longer accepting new requests: if nothing is in flight it closes immediately, otherwise it waits for those requests to finish, which makes it the right choice when tearing down before a page unload.
All of these need botProviderEndpoint to be configured, because each endpoint is derived from it. Without it they throw an error naming the endpoint that could not be derived.
Message feedback
When a user rates one reply, send it with sendMessageFeedback:
import {
FEEDBACK_COMMENT_MAX_BYTES,
feedbackCommentByteLength,
composeFeedbackMessage,
} from '@asgard-js/core';
await client.sendMessageFeedback({
customChannelId,
messageId, // the messageId of the reply being rated
verdict: 'BAD', // 'GOOD' | 'BAD'
comment: 'This answers a different question than the one I asked',
});
The returned messageId is the feedback entry's own id, distinct from the reply it rates. The response also carries the transcript seq, the position at which a rejoining client sees this feedback replayed as an asgard.message.feedback event.
The comment limit is 8 KiB, not 8192 characters
FEEDBACK_COMMENT_MAX_BYTES is 8 * 1024, measured in UTF-8 bytes. A CJK character takes three bytes, so a length check in characters lets through a comment the server then rejects with 400. Measure it with feedbackCommentByteLength(comment):
if (feedbackCommentByteLength(comment) > FEEDBACK_COMMENT_MAX_BYTES) {
// ask the user to shorten it
}
Sending the feedback to the AI as well
composeFeedbackMessage(verdict, comment?) builds the follow-up message to send into the conversation: the prefix for that verdict, then, only when the user wrote something, a blank line and the comment verbatim.
composeFeedbackMessage('BAD', 'This answers a different question');
// "[Response Feedback: Bad]\n\nThis answers a different question"
RESPONSE_FEEDBACK_PREFIX holds those two prefix strings ([Response Feedback: Good] / [Response Feedback: Bad]). They are a byte-for-byte platform contract: the backend's system prompt recognises exactly these strings and uses them to treat the message as an interlude about the previous reply, acknowledging briefly, adjusting where the verdict was Bad, then continuing. Do not rewrite the prefix.
Error handling
@asgard-js/core exports three error classes and a type guard for each. All extend Error, so instanceof works just as well as the guard.
| Class | Type guard | Thrown when |
|---|---|---|
HttpError | isHttpError | An HTTP request returns a non-2xx status |
ChannelBusyError | isChannelBusyError | A run is already in flight on that channel |
ChannelAwaitingConsentError | isChannelAwaitingConsentError | The channel is parked on a tool-call consent prompt |
import {
isHttpError,
isChannelBusyError,
isChannelAwaitingConsentError,
} from '@asgard-js/core';
try {
await channel.sendMessage({ text: input });
} catch (error) {
if (isChannelAwaitingConsentError(error)) {
// answer the consent prompt first; this turn cannot go out until then
} else if (isChannelBusyError(error)) {
// the previous turn has not finished, so keep the send button disabled
} else if (isHttpError(error) && error.status === 429) {
// read error.body to tell a spent quota from a rate limit
}
}
HttpError
Carries three readonly properties: status, statusText and body. body is the decoded error payload, and in practice it is how you tell apart different causes behind one status code, for example reading reason_code off it to distinguish a spent quota from a plain rate limit on a 429.
body may be undefined. A response does not always carry JSON, and a failed parse is a normal outcome rather than a caller mistake, so check before reading it.
ChannelBusyError
Channel.sendMessage() rejects with this when a run already holds the channel. The runKind property says which kind of run is holding it.
Runs execute in the background on the server, so dispatching a second one would leave two runs writing to the same transcript. Rejecting rather than dropping the message silently is what lets the caller know it never left.
The SDK's own composer already gates this, so in practice you only see this error on a path where you send messages programmatically.
ChannelAwaitingConsentError
Channel.sendMessage() and Channel.nudge() both reject with this while the channel is parked on a tool-call consent prompt. The server holds such a channel in a pause that only a consent reply resumes; every other turn is rejected, a nudge included.
That pause is invisible to the run-in-flight check: the consent frame arrives after the run terminal, so by the time the prompt is on screen the run has already ended and the channel looks idle. Answer with Channel.replyToolCallConsents() first, then send the next turn.
The processId property is the paused batch's process id, but it is an empty string on a rejoin. A prompt recovered after a reload is rebuilt by the backend from durable pause state, and that path deliberately leaves the process id blank, because detecting a consent pause is a matter of the prompt's presence rather than this id. Treat it as a diagnostic hint, never as a key.
Sandbox filesystem
The sandbox mounted for an agent run has a filesystem API, each method addressing a path within a named sandbox:
| Method | Signature |
|---|---|
sandboxFsList | (sandboxName, path) => Promise<SandboxFsListResult> |
sandboxFsStat | (sandboxName, path) => Promise<SandboxFsStatResult> |
sandboxFsRead | (sandboxName, path, options?) => Promise<SandboxFsReadResult> |
sandboxFsWrite | (sandboxName, path, ...) => Promise<void> |
sandboxFsMkdir | (sandboxName, path) => Promise<void> |
sandboxFsRemove | (sandboxName, path) => Promise<void> |
sandboxFsRemoveAll | (sandboxName, path) => Promise<void> |
sandboxFsCopy | (sandboxName, src, dst, ...) => Promise<void> |
sandboxFsMove | (sandboxName, src, dst, ...) => Promise<void> |
sandboxFsWatch | (sandboxName, path) => Observable<SandboxFsWatchEvent> |
sandboxFsWatch returns an RxJS Observable rather than a Promise: once subscribed it keeps emitting change events for that path, so unsubscribe when you are done.
generateSandboxBrowserOpenUrl(sandboxName) produces a URL that opens the sandbox in a browser.
resolveSandboxUri(uri) parses a sandbox URI into a SandboxUriIntent, returning null when it cannot. reconcileLaunched(metadata) tidies the list of launched sandboxes.
On the React side you do not call these directly: createSandboxFsProviders wraps them into the shape the file explorer expects. See the React SDK.
SourceSet
AsgardSourceSetClient reads a SourceSet's contents. Its constants:
| Constant | Value | Description |
|---|---|---|
SOURCE_SET_MAX_PAGE_SIZE | 1000 | Maximum entries in one request |
SOURCE_SET_DEFAULT_MAX_ENTRIES | 10000 | Default cap on entries fetched |
SOURCE_SET_VOLUME_ROOT | '' | The volume root, an empty string rather than / |
assertVolumePath(path, options?) validates a path and throws when it is not valid. The empty-string root is easy to get wrong, so use this function rather than comparing paths yourself.
Events and derived data
fetchSse(payload, options?) opens the SSE connection and returns an RxJS Subscription. rejoinSse(customChannelId, options?) rejoins an existing channel and replays its transcript. handleEvent(response) feeds one SSE event into the client's internal state, and is normally called by the SDK itself.
Helpers for deriving tasks and subagents from events:
| Function | Purpose |
|---|---|
isTaskTool(call) / isAgentTool(call) | Whether a tool call is a task or a subagent |
isSubagentChildTool(parentToolUseId?) | Whether a tool call sits under a subagent |
reduceTaskEvents(events) | Reduce TaskToolEvent[] to Task[] |
reduceSubagents(events) | Reduce SubagentEvent[] to Subagent[] |
conversationToSubagentEvents(messages) | Extract subagent events from conversation messages |
tasksEqual(a, b) / subagentsEqual(a, b) | Compare two lists, to avoid re-rendering when nothing changed |
Headless derived state (tasks, subagents, title)
Since 0.3.x, @asgard-js/core also exposes a framework-agnostic derived-state API, so headless, Vue or Svelte consumers can render the task list, subagent list and conversation title outside <Chatbot>:
- Reactive stores on
Channel:channel.tasks$,channel.subagents$,channel.channelTitle$(RxJSObservables that emit only when that slice changes), plus the snapshot gettersgetTasks()/getSubagents()/getChannelTitle(). - Building your own from
conversation$:createDerivedStores(conversation$)returns{ tasks$, subagents$, getTasks, getSubagents, teardown };deriveTasks(conversation)andderiveSubagents(conversation)do a one-off derivation. - Joining an existing channel without clearing history:
Channel.restore(config, options?)first asksclient.channelMetadata()(GET /channel/metadata) whether the channel exists, and if it does replays the server transcript viaclient.rejoinSse()instead of sendingRESET_CHANNEL.
In React, use @asgard-js/react's useTaskList / useSubagents / useChannelTitle hooks, which already bridge those stores into useSyncExternalStore.
Sandbox browser
The browser running inside a Sandbox can be embedded in your own UI and driven there, instead of being opened in a new tab.
createSandboxBrowserSession(sandboxName) gets streaming credentials from POST {botProviderEndpoint}/sandbox/{sandbox_name}/browser/session and returns { wsUrl, token }. Hand those to createSandboxBrowserTransport to open the connection; the screen and the input both travel over that WebSocket.
This is a second route alongside generateSandboxBrowserOpenUrl(sandboxName), not a replacement for it. That one hands you a one-time URL for the user to open in a new tab and is kept as the fallback; this one hands you a socket so the SDK can render the browser inside your page.
The remote desktop protocol speaks X11 keysyms rather than the browser's KeyboardEvent.key, so core also exports the mapping:
| Export | What it does |
|---|---|
KEYSYM | Keysym constants for the common keys |
charToKeysym(char) | A single character to a keysym |
keyToKeysym(key) | A KeyboardEvent.key to a keysym |
mapModifierKeysym(...) | Handles Shift / Ctrl / Alt and friends |
isPrintableKeysym(keysym) | Whether a keysym is a printable character |
Wheel events differ in units between browsers, so normalizeWheel(event) normalises them; WHEEL_MAX caps a single scroll and WHEEL_THROTTLE_MS is the send interval. Remote cursor frames are decoded with decodeCursorFrame.
None of these low-level helpers has to be touched if you use the React SDK's SandboxBrowserPanel — see React SDK.
Interactive demo
Headless usage and event handling for @asgard-js/core: SDK Demo — Headless
Further reading
- Frontend SDK guide — choosing an integration pattern, with the full walkthrough
- React SDK reference — the
@asgard-js/reactcomponents