Skip to main content
@asgard-js/core JavaScript SDK reference

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).

Using React?

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​

ClassWhat it is
AsgardServiceClientConnection configuration (botProviderEndpoint, apiKey, customHeaders)
ChannelOne conversation channel, talking to the Bot Provider over SSE
ConversationThe 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​

OptionTypeRequiredDescription
botProviderEndpointstringYesThe Bot Provider's base URL
apiKeystringNoAPI key (Direct Connect mode)
customHeadersRecord<string, string>NoCustom HTTP headers

Channel and file operations​

Beyond opening a connection, AsgardServiceClient offers these methods:

MethodSignatureDescription
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 }) => voidStop accepting new requests, then close once the in-flight ones finish
close() => voidClose 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.

ClassType guardThrown when
HttpErrorisHttpErrorAn HTTP request returns a non-2xx status
ChannelBusyErrorisChannelBusyErrorA run is already in flight on that channel
ChannelAwaitingConsentErrorisChannelAwaitingConsentErrorThe 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:

MethodSignature
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:

ConstantValueDescription
SOURCE_SET_MAX_PAGE_SIZE1000Maximum entries in one request
SOURCE_SET_DEFAULT_MAX_ENTRIES10000Default 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:

FunctionPurpose
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$ (RxJS Observables that emit only when that slice changes), plus the snapshot getters getTasks() / getSubagents() / getChannelTitle().
  • Building your own from conversation$: createDerivedStores(conversation$) returns { tasks$, subagents$, getTasks, getSubagents, teardown }; deriveTasks(conversation) and deriveSubagents(conversation) do a one-off derivation.
  • Joining an existing channel without clearing history: Channel.restore(config, options?) first asks client.channelMetadata() (GET /channel/metadata) whether the channel exists, and if it does replays the server transcript via client.rejoinSse() instead of sending RESET_CHANNEL.
React consumers

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:

ExportWhat it does
KEYSYMKeysym 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​