TypeScript SDK

@jaato/sdk is the TypeScript client for jaato. It talks to a long-lived jaato daemon over a WebSocket transport, so your Node (or browser) app drives real agent sessions without hosting the runtime itself.

Overview

Install the package and target a modern Node toolchain. The examples in this guide assume Node 24, ESM with "module": "NodeNext", and an ES2022 target — the await using syntax used throughout depends on it.

The mental model is one static factory: JaatoClient.session(options) opens a session against the daemon and resolves to a Session facade. That facade owns the send-and-wait recipe, so await s.ask(...) returns the answer with no event plumbing. The session is an AsyncDisposableawait using tears it down on scope exit.

This is not the Python JaatoClient
The TypeScript @jaato/sdk JaatoClient is a separate package over a WebSocket transport. It is not the Python in-process jaato.JaatoClient that runs the runtime inside your own process. Same name, different layer: the TS client is always a remote client of a daemon, like the Python jaato_sdk.IPCClient — but over wss:// instead of a local IPC socket.
Install
npm install @jaato/sdk
# Node 24, ESM, NodeNext module resolution
tsconfig.json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "lib": ["ESNext"],
    "strict": true
  }
}
ex01 — hello world
import { JaatoClient } from "@jaato/sdk";

await using s = await JaatoClient.session({
  url: "wss://<host>",
  token,
  profile: {
    model: "google/gemini-2.5-flash",
    provider: "openrouter",
    plugins: [],
    plugin_configs: {
      openrouter: { api_key: "pass://jaato/openrouter/api-key" },
    },
  },
});
console.log(await s.ask("Who are you? One sentence."));

Connect & Transport

A session needs two connection knobs: url (the daemon's wss:// endpoint) and token (a bearer token). The transport authenticates by appending the token as a query parameter — wss://<host>/?token=<token>. In the browser the query parameter is the only option; on Node a Bearer header is also supported.

For a self-signed development certificate, trust the CA out-of-band through Node's NODE_EXTRA_CA_CERTS environment variable — typically set by a run wrapper, not in code. This is the clean path: TLS verification stays on. Never disable certificate verification to make a dev cert work.

Keep secrets out of source
The bearer token and the CA path come from the environment (e.g. $HOME/.jaato/ws.token and $HOME/.jaato/certs/ca.crt), not from committed literals. Centralise them in one small config module so the JaatoClient.session call shape stays clean.
config.ts — connection knobs
import { readFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";

// wss endpoint of the daemon + bearer token (read from the environment).
const url = "wss://<host>";
const token = readFileSync(
  join(homedir(), ".jaato", "ws.token"),
  "utf8",
).trim();

export const CONN = { url, token };
Trust the dev CA (run wrapper)
# Self-signed dev cert: trusted out-of-band, verification stays ON.
export NODE_EXTRA_CA_CERTS="$HOME/.jaato/certs/ca.crt"
node --import tsx src/ex01_basic_ask.ts

The Session Facade

JaatoClient.session(...) returns a Session with four entry points plus an escape hatch:

  • s.ask(prompt) Promise<string>
    Send a prompt, wait for the full reply text.
  • s.stream(prompt) AsyncIterable<string>
    Iterate reply chunks as they arrive.
  • s.complete(prompt) Promise<object | null>
    Return a typed, server-validated completion payload (or null).
  • s.client low-level client
    Escape hatch to the raw client / event API.

For a throwaway call, the module-level ask(prompt, options) helper opens a session, asks, and tears it down in one line.

The session is an AsyncDisposable. With await using s = await JaatoClient.session(...) the session is disposed automatically when the enclosing scope exits — no manual close() in the happy path or on error.

ask / stream / complete
import { JaatoClient, ask } from "@jaato/sdk";

// Facade ask.
await using s = await JaatoClient.session({ ...CONN, profile });
console.log(await s.ask("Who are you? One sentence."));

// One-shot module helper.
console.log(await ask("Who are you? One sentence.", { ...CONN, profile }));

// Streaming (ex02).
for await (const chunk of s.stream("Tell me a short story.")) {
  process.stdout.write(chunk);
}

// Typed completion (ex04).
const person = await s.complete("Alice is 30."); // object | null
console.log(person?.name, person?.age);

Session Establishment & Timeout

JaatoClient.session(...) does not resolve the moment the socket opens. Internally it waits for the daemon to assign a session id before handing back the facade, so the returned Session never races on a missing id — by the time you call s.ask(...) the session is fully established.

That wait is bounded by sessionTimeoutMs (default 60000). If the id does not arrive within the window, opening the session rejects rather than returning a half-initialised facade. Raise it for a slow link, or lower it to fail fast.

sessionTimeoutMs
await using s = await JaatoClient.session({
  ...CONN,
  profile,
  sessionTimeoutMs: 60000, // default; bounds the wait for the session id
});

// session() has already awaited the session id here —
// no need to poll or guard before the first ask().
console.log(await s.ask("Ready when you are."));

Inline vs Declarative Profiles

profile takes two shapes: an inline object ({ model, provider, plugins, plugin_configs }) or the name of a declarative asset (a string) that the daemon resolves from a .jaato tree.

Inline specs require plugins
An inline profile must carry a plugins key — use [] for a tool-less session. Credentials go in plugin_configs as a pass:// resolver knob (pass://jaato/<provider>/api-key), never a raw key and never an environment variable — that is a project convention.

Declarative profiles and agents resolve from a workspace's .jaato. The catch: a WebSocket connection auto-provisions a fresh per-session workspace, and the daemon resolves named assets from that workspace — not from workspacePath alone. So a named profile / agent needs configRoot pointed at the .jaato that holds the assets (and workspacePath for any files they reference). workspacePath maps to the session's working_dir; configRoot maps to config_root.

Inline (ex01) — plugins required
await using s = await JaatoClient.session({
  ...CONN,
  profile: {
    model: "google/gemini-2.5-flash",
    provider: "openrouter",
    plugins: [], // REQUIRED on an inline spec
    plugin_configs: {
      openrouter: { api_key: "pass://jaato/openrouter/api-key" },
    },
  },
});
Declarative (ex04) — needs configRoot
import { join } from "node:path";

// Point configRoot at the .jaato that holds the named asset, because a WS
// session resolves declarative assets from its auto-provisioned workspace.
const CONFIG_ROOT = join(WORKSPACE, ".jaato");

await using s = await JaatoClient.session({
  ...CONN,
  workspacePath: WORKSPACE, // -> working_dir
  configRoot: CONFIG_ROOT,  // -> config_root (resolves "person-extractor")
  profile: "person-extractor",
});

Client (Host) Tools

clientTools exposes functions that run in your process. Each entry is { name, description, parameters, handler }: the daemon's agent loop sees the JSON-schema parameters, decides to call the tool, and calls back over the socket; your handler executes locally and returns a result the model consumes.

This is the right home for anything that must touch your machine, app state, or private APIs — the daemon never sees the implementation, only the schema and the returned value.

ex05 — a host tool
await using s = await JaatoClient.session({
  ...CONN,
  profile,
  clientTools: [
    {
      name: "get_weather",
      description: "Return the weather for a city.",
      parameters: {
        type: "object",
        properties: { city: { type: "string" } },
        required: ["city"],
      },
      handler: (args) => ({
        weather: `${args.city as string}: sunny, 24C`,
      }), // runs in YOUR process
    },
  ],
});
console.log(await s.ask("Weather in Paris?"));

Server-Side Plugins

Where client tools run locally, plugins on the profile select tools the daemon hosts and drives — for example ["cli", "web_search", "todo"]. You send one message; the model → tool-call → result → model loop runs entirely in the runner. The daemon is the loop; you receive the final answer (and, if you subscribe, the intermediate events).

WebSocket file ops are sandboxed
File-touching plugins (e.g. cli) operate inside the WS session's auto-provisioned workspace, not your project directory. This isolation is intentional — a remote client should not get ambient write access to the host. Assert on the model's output, or read results back through a tool, rather than expecting a file to appear locally.
ex06 — daemon-hosted tool loop
await using s = await JaatoClient.session({
  ...CONN,
  workspacePath: WORKSPACE,
  configRoot: CONFIG_ROOT,
  profile: {
    model: "google/gemini-2.5-flash",
    provider: "openrouter",
    plugins: ["cli", "web_search", "todo"],
    plugin_configs: {
      openrouter: { api_key: "pass://jaato/openrouter/api-key" },
      permission: { policy: { defaultPolicy: "allow" } },
    },
  },
});
console.log(
  await s.ask(
    "Using the shell, get the date and an `ls` listing, " +
      "then write both into report.txt. Just do it.",
  ),
);

Human-in-the-Loop Permissions

Gate tool calls with onPermission: a callback that receives the permission event and returns "y" to allow or "n" to deny. In a UI it would prompt a human; headless, it can log and auto-decide.

Whether a given tool call is gated is set by plugin_configs.permission. A defaultPolicy of "ask" routes every otherwise ungoverned call through onPermission; "allow" runs them without asking. The mechanism is identical for any gated tool, so set "ask" to see the gate fire deterministically.

ex07 — onPermission + ask policy
function approve(toolName: string): boolean {
  console.log(`[permission] ${toolName} -> approve`);
  return true; // a UI would prompt here
}

await using s = await JaatoClient.session({
  ...CONN,
  workspacePath: WORKSPACE,
  configRoot: CONFIG_ROOT,
  profile: {
    model: "google/gemini-2.5-flash",
    provider: "openrouter",
    plugins: ["cli"],
    plugin_configs: {
      openrouter: { api_key: "pass://jaato/openrouter/api-key" },
      permission: { policy: { defaultPolicy: "ask" } },
    },
  },
  onPermission: (ev) =>
    approve((ev as { tool_name: string }).tool_name) ? "y" : "n",
});
console.log(await s.ask("Run `date` in the shell and report it."));

Typed Completion Gate

For structured output, declare a completion_payload_schema on the profile. The daemon then forces the model to end the turn by calling signal_completion with a payload, validates that payload against the schema, and s.complete(prompt) resolves to the validated object — or null if no valid payload was produced.

This is a server-side gate, not a prompt convention: the model cannot "finish" by chatting; it must satisfy the schema. The same mechanism backs both single-shot structured extraction (below) and the cross-stage handoff in cascades.

person-extractor.json (declarative)
{
  "name": "person-extractor",
  "model": "google/gemini-2.5-flash",
  "provider": "openrouter",
  "plugins": [],
  "completion_payload_schema": {
    "type": "object",
    "additionalProperties": false,
    "required": ["name", "age"],
    "properties": {
      "name": { "type": "string" },
      "age": { "type": "integer", "minimum": 0 }
    }
  }
}
ex04 / ex08 — s.complete()
await using s = await JaatoClient.session({
  ...CONN,
  workspacePath: WORKSPACE,
  configRoot: CONFIG_ROOT,
  profile: "person-extractor",
});
const person = await s.complete("Alice is 30."); // validated object | null
console.log(person?.name, person?.age); // "Alice" 30

Subagent Delegation & the Event API

A lead agent can delegate to subagents through the subagent plugin: agent: "lead", plugins: ["subagent"], and a budget_control ladder with an abort rung so the session always terminates. Delegation spans many turns, so this is where you drop from the facade to the event API via s.client.subscribe(EventTypeValue.AGENT_OUTPUT, ...) to collect streamed output as it is produced.

Which terminal event? It depends on the agent
The event you wait on is not universal. A completion-gated lead (one whose profile declares a completion_payload_schema) terminates with SESSION_TERMINATED — that is what ex08 waits on, because its lead carries a blurb schema. A plain single-shot turn instead ends with turn.completed. Pick the one that matches your agent; do not assume SESSION_TERMINATED always fires.

Whether the lead actually calls spawn_subagent is model-dependent. The wiring is complete either way; a given model may answer directly instead of delegating.

ex08 — delegation via the event API
import { JaatoClient, EventTypeValue } from "@jaato/sdk";

await using s = await JaatoClient.session({
  ...CONN,
  workspacePath: WORKSPACE,
  configRoot: CONFIG_ROOT,
  agent: "lead",
  profile: {
    model: "google/gemini-2.5-flash",
    provider: "openrouter",
    plugins: ["subagent"],
    budget_control: {
      limits: { turns: 8 },
      degrade: [{ at: 100, action: "abort" }],
    },
    completion_payload_schema: {
      type: "object",
      additionalProperties: false,
      required: ["blurb"],
      properties: { blurb: { type: "string" } },
    },
    plugin_configs: {
      openrouter: { api_key: "pass://jaato/openrouter/api-key" },
    },
  },
});

const out: string[] = [];
s.client.subscribe(EventTypeValue.AGENT_OUTPUT, (e: { text?: string }) => {
  if (e.text) out.push(e.text);
});
await new Promise<void>((resolve) => {
  // This lead is completion-gated -> SESSION_TERMINATED (NOT turn.completed).
  s.client.subscribeOnce(EventTypeValue.SESSION_TERMINATED, () => resolve());
  void s.client.sendMessage(
    "Research tide pools, then write a blurb from the findings.",
  );
});
console.log(out.join(""));

Reactor-Driven Cascade

A cascade is a multi-stage pipeline that runs decoupled in the daemon. The client only triggers stage 1: open a session with a cascadeDriverId and call s.complete(...). From there a reactor chain (extractsummarizeverify) advances stage to stage server-side. The .jaato reactor assets and stage scripts are daemon-side and language-agnostic — the same assets serve a TypeScript or a Python trigger.

Typed handoff requires the producer's schema
Cross-stage typed data does not flow automatically. The producer stage's profile must declare a completion_payload_schema with a top-level field; that makes the model call signal_completion(field=…), and the daemon attaches the validated payload to the bus event the reactor receives so the next stage can read it with event.get("field"). Without the producer schema, the downstream payload is null/empty. The chain is: producer completion_payload_schema → flat signal_completion(field=…) → consumer reads the field.
ex09 — client triggers stage 1
import { randomUUID } from "node:crypto";
import { JaatoClient } from "@jaato/sdk";

const cid = randomUUID();
await using s = await JaatoClient.session({
  ...CONN,
  workspacePath: WORKSPACE,
  configRoot: CONFIG_ROOT,
  agent: "extract",
  profile: "extract",       // producer: declares a `facts` schema
  cascadeDriverId: cid,
});
await s.complete("Extract the facts from this doc: ...");
console.log(`stage 1 done; cascade ${cid} continues in the daemon`);
Producer schema -> reactor -> consumer (daemon-side)
// extract.json (PRODUCER): schema exposes a top-level `facts` field
// "completion_payload_schema": { "required": ["facts"],
//   "properties": { "facts": { "type": "string" } } }

// .jaato/reactors/cascade.json: on extract done, run the next stage
// { "match": { "where": "source_agent == 'extract'" },
//   "action": { "script": "scripts/spawn_summarize.py" } }

// spawn_summarize.py (CONSUMER, runs in the daemon):
//   facts = event.get("facts")          # hoisted typed payload
//   ctx.create_session(agent="summarize", profile="summarize",
//       initial_prompt=f"Summarise these findings: {facts}")

Recovery & Reattach

Pass recovery: {} to make the session resilient across a daemon bounce or a dropped connection: the client auto-reconnects and re-attaches to the same session, so an in-flight s.ask(...) survives the interruption instead of failing.

Observe the transition with onStatusChange, which fires with a status whose state moves through reconnectingconnectedclosed. Use it to surface connection health in your UI or logs.

Opaque "Session not found" is intentional
If a reattach targets a session the daemon no longer holds, the failure is deliberately opaque — the boundary does not leak whether the id was wrong, expired, or belongs to someone else. Treat it as a signal to open a fresh session.
ex10 — recovery + onStatusChange
await using s = await JaatoClient.session({
  ...CONN,
  profile,
  recovery: {}, // auto-reconnect + auto reattach across a daemon bounce
  onStatusChange: (st) => console.log(st.state),
  // reconnecting / connected / closed
});
console.log(await s.ask("Long task...")); // survives a daemon restart