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 AsyncDisposable —
await using tears it down on scope exit.
JaatoClient@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.
npm install @jaato/sdk
# Node 24, ESM, NodeNext module resolution
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ESNext"],
"strict": true
}
}
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.
$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.
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 };
# 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.
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.
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.
pluginsplugins 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.
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" },
},
},
});
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.
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).
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.
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.
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.
{
"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 }
}
}
}
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.
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.
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 (extract → summarize →
verify) 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.
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.
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`);
// 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
reconnecting → connected → closed.
Use it to surface connection health in your UI or logs.
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