Skip to content
brain.GitHub
Developer docs OPEN SOURCE
SDKs

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

Declare 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;
}
  • BrainError fields: status (HTTP status number), code (machine-readable string such as "conflict" or "overloaded", falling back to "http_error"), message, and requestId (from the response body's request_id).
  • Tool failures vs. HTTP errors: Failed or denied tool calls do not throw BrainError; they arrive in the event stream as tool.result events with result.is_error: true. Model provider or harness crashes arrive as session.error events. See Errors.
  • Timeouts: Standard requests default to AbortSignal.timeout(30_000) (30 seconds). flush() and restore() default to AbortSignal.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

MethodSignatureReturnsDescription
createcreate(input: CreateSession, signal?: AbortSignal)Promise<Session>Creates a session (POST /sessions). Default timeout: 30s.
getget(id: string, signal?: AbortSignal)Promise<Session>Fetches session metadata and status (GET /sessions/{id}). Default timeout: 30s.
listlist(after = "", limit = 100)Promise<{ sessions: Session[] }>Lists tenant sessions in ascending ID order (GET /sessions).
sendsend(id: string, input: Input, signal?: AbortSignal)Promise<Event>Queues a user.message and returns the recorded event (POST /sessions/{id}/events). Default timeout: 30s.
interruptinterrupt(id: string)Promise<void>Sends {"type": "user.interrupt"} to stop the active turn (POST /sessions/{id}/events).
historyhistory(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).
observeobserve(id: string, options?: { after?: string; signal?: AbortSignal })AsyncGenerator<Event>Streams retained and live events over SSE (GET /sessions/{id}/events).
resumeresume(id: string)Promise<Session>Moves an interrupted or failed session back to idle (POST /sessions/{id}/resume).
closeclose(id: string)Promise<void>Permanently closes a session (POST /sessions/{id}/close).
flushflush(id: string, signal?: AbortSignal)Promise<SyncStatus>Uploads local state to external storage immediately (POST /sessions/{id}/flush). Default timeout: 60s.
syncsync(id: string)Promise<SyncStatus>Reads background checkpoint sync status (GET /sessions/{id}/sync).
restorerestore(id: string, checkpoint: string, signal?: AbortSignal)Promise<Session>Replaces local session state with a specific external checkpoint (POST /sessions/{id}/restore). Default timeout: 60s.
catalogcatalog()Promise<{ tools: Tool[] }>Lists tools available to the caller's tenant (GET /catalog).

On this page