TypeScript SDK
Create sessions, send messages, and stream events in Node.js 24+ with the TypeScript client.
Brain is a zero-dependency TypeScript client for the Brain HTTP API and server-sent event (SSE) streams. It runs on Node.js 24+ using standard fetch and AsyncGenerator streams.
Install
npm install --save-exact @aexhq/brain@2.0.0Declare runtime tools
Pass ordinary functions when creating a session. The SDK registers descriptors and opens a callback connection before admitting the session. Functions and captured credentials stay in your Node process.
import { Brain } from "@aexhq/brain";
const client = new Brain("http://127.0.0.1:8080", process.env.BRAIN_API_KEY!);
const session = await client.create({
harness: "pi",
config: { model: "openai/gpt-4.1-mini", tools: [] },
runtime_transport: "sse",
runtime_tools: [{
name: "lookup", description: "Read application data",
input_schema: { type: "object", additionalProperties: false },
output_schema: { type: "object", required: ["value"] },
async run(_args, { signal, scope, progress }) {
signal.throwIfAborted();
await progress({ stage: "reading" });
return { value: `data authorized for ${scope.tenant_id}` };
},
}],
});
await client.send(session.id, { type: "user.message", text: "Use runtime_lookup." });
for await (const event of client.observe(session.id)) {
console.log(event);
if (event.type === "session.status" && (event.data as { status: string }).status === "idle") break;
}
await client.close(session.id);
await client.dispose();The alias runtime prefixes each function name. Return a JSON value; the SDK places it in structured_content and renders it as text. Throw an error to return a tool failure. Honor signal to stop local work after cancellation. Use runtime_transport: "rpc" for the WebSocket carrier.
Keep the client process alive while callbacks are needed. dispose() closes its executors without closing saved sessions. After restart or restore, call client.rebind(sessionID, matchingTools, "sse") and explicitly resume/send as required by the session status. Rebinding changes the connection, never replays an action. An unavailable executor fails before dispatch; a connection lost after dispatch reports an unknown outcome. Reading history or observing events cannot execute callbacks.
JavaScript and TypeScript use the same ESM package, including generated type declarations. No checkout or compilation is needed. Version 1 replaces the predecessor's incompatible 0.x SDK; use it with the current Go session server. Pin the client and matching server commit from the release workflow.
Create a client
Pass the Brain server base URL and your bearer token to new Brain(url, token, fetchImpl?):
import { Brain } from '@aexhq/brain';
const brain = new Brain('http://127.0.0.1:8080', process.env.BRAIN_API_KEY!);You can pass a custom fetch implementation as the third argument to add custom headers, logging, or test doubles.
Create a session and send a message
Call create() with a harness name and the session configuration: model, tools, and optional instructions and reasoning_effort. Then call send() to queue a user.message:
const session = await brain.create({
harness: 'pi',
config: {
model: 'openai/gpt-4.1-mini',
instructions: 'Answer in two sentences or fewer.',
reasoning_effort: 'low',
tools: [],
},
});
const input = await brain.send(session.id, {
type: 'user.message',
text: 'Explain append-only storage.',
key: 'message-1',
});send() returns the recorded user.message event once Brain writes it to disk (202 Accepted). Keep input.id to match status updates and errors produced during that turn.
Stream events
Call observe(session.id) to iterate over the session's SSE stream. Brain replays retained events first, then streams live events across turns. The stream stays open until you break out of the loop or abort the signal, even after the session closes, so stop when you see the status you're waiting for.
Run this .mjs script from the repository root while Brain is running (see examples/session.mjs for the CLI version):
import { Brain } from '@aexhq/brain';
const brain = new Brain('http://127.0.0.1:8080', process.env.BRAIN_API_KEY);
const session = await brain.create({
harness: 'pi',
config: { model: 'openai/gpt-4.1-mini', tools: [] },
});
const input = await brain.send(session.id, {
type: 'user.message',
text: 'Explain append-only storage.',
key: 'message-1',
});
for await (const event of brain.observe(session.id)) {
if (event.type === 'assistant.delta') {
process.stdout.write(event.data.text);
}
if (event.type === 'assistant.message') {
console.log(event.data.text);
}
if (event.input_id === input.id && event.type === 'session.error') {
throw new Error(event.data.message);
}
if (
event.input_id === input.id &&
event.type === 'session.status' &&
['idle', 'failed', 'interrupted', 'closed'].includes(event.data.status)
) {
break;
}
}Breaking out of observe()
Exiting the for await loop cancels the client's HTTP stream without stopping the session on the server. To stop an active turn, call await brain.interrupt(session.id).
In TypeScript, event.data is typed as unknown. Check event.type, then cast event.data to that event's payload from Events & streaming. All wire types (Session, SessionConfig, CreateSession, Input, Event, EventList, SessionList, Tool, ToolCatalog, Call, Content, Result, ToolResult, Checkpoint, Restore, Status, SyncStatus, ErrorDetail, ErrorResponse) are re-exported from the package entrypoint.
Reconnect with cursors
Every retained event includes an opaque event.cursor. Save the latest non-empty cursor as you consume the stream, and pass it in { after } to resume from that exact log position without re-reading earlier history:
let cursor: string | undefined;
const controller = new AbortController();
for await (const event of brain.observe(session.id, {
after: cursor,
signal: controller.signal,
})) {
if (event.cursor) cursor = event.cursor;
if (event.input_id !== input.id) continue;
if (event.type === 'session.status' && (event.data as { status: string }).status === 'idle') break;
}Live-only events (assistant.delta and tool.progress) do not carry a cursor. You can also fetch paginated retained history without opening an SSE stream by calling await brain.history(session.id, after, limit).
Handle errors and timeouts
Non-2xx HTTP responses throw BrainError:
import { Brain, BrainError } from '@aexhq/brain';
try {
await brain.send(session.id, { type: 'user.message', text: 'Continue.' });
} catch (err) {
if (err instanceof BrainError) {
console.error(err.status, err.code, err.message, err.requestId);
}
throw err;
}BrainErrorfields:status(HTTP status number),code(machine-readable string such as"conflict"or"overloaded", falling back to"http_error"),message, andrequestId(from the response body'srequest_id).- Tool failures vs. HTTP errors: Failed or denied tool calls do not throw
BrainError; they arrive in the event stream astool.resultevents withresult.is_error: true. Model provider or harness crashes arrive assession.errorevents. See Errors. - Timeouts: Standard requests default to
AbortSignal.timeout(30_000)(30 seconds).flush()andrestore()default toAbortSignal.timeout(60_000)(60 seconds).observe()has no overall wall-clock timeout and rejects individual SSE events larger than 4 MiB or streams that end mid-frame. - No automatic retries or redirects: The client sets
redirect: "error"and never retries failed requests automatically.
Methods reference
| Method | Signature | Returns | Description |
|---|---|---|---|
create | create(input: CreateSession, signal?: AbortSignal) | Promise<Session> | Creates a session (POST /sessions). Default timeout: 30s. |
get | get(id: string, signal?: AbortSignal) | Promise<Session> | Fetches session metadata and status (GET /sessions/{id}). Default timeout: 30s. |
list | list(after = "", limit = 100) | Promise<{ sessions: Session[] }> | Lists tenant sessions in ascending ID order (GET /sessions). |
send | send(id: string, input: Input, signal?: AbortSignal) | Promise<Event> | Queues a user.message and returns the recorded event (POST /sessions/{id}/events). Default timeout: 30s. |
interrupt | interrupt(id: string) | Promise<void> | Sends {"type": "user.interrupt"} to stop the active turn (POST /sessions/{id}/events). |
history | history(id: string, after = "", limit = 100) | Promise<{ events: Event[]; next_cursor: string }> | Returns a page of retained events and the next cursor (GET /sessions/{id}/events). |
observe | observe(id: string, options?: { after?: string; signal?: AbortSignal }) | AsyncGenerator<Event> | Streams retained and live events over SSE (GET /sessions/{id}/events). |
resume | resume(id: string) | Promise<Session> | Moves an interrupted or failed session back to idle (POST /sessions/{id}/resume). |
close | close(id: string) | Promise<void> | Permanently closes a session (POST /sessions/{id}/close). |
flush | flush(id: string, signal?: AbortSignal) | Promise<SyncStatus> | Uploads local state to external storage immediately (POST /sessions/{id}/flush). Default timeout: 60s. |
sync | sync(id: string) | Promise<SyncStatus> | Reads background checkpoint sync status (GET /sessions/{id}/sync). |
restore | restore(id: string, checkpoint: string, signal?: AbortSignal) | Promise<Session> | Replaces local session state with a specific external checkpoint (POST /sessions/{id}/restore). Default timeout: 60s. |
catalog | catalog() | Promise<{ tools: Tool[] }> | Lists tools available to the caller's tenant (GET /catalog). |