Skip to content

Sessions & Memory

Understand session isolation, streaming completion, memory persistence, channels, and the fetch-native runtime.

Updated View as Markdown

Every XsafAgent owns a Hono application. XSAF mounts routes and exposes a web-standard fetch handler, but it never opens a network listener.

Fetch and channels

Built-in routes

Route Behavior
GET /health Returns { "ok": true }; it is not a dependency health check.
POST /invoke Accepts { prompt, sessionId? } and returns a collected model result.
/mcp Mounted only after .serve().
/chat Default route of @xsaf/agent/channel/http when that channel is registered.

Blank prompts and malformed JSON sent to /invoke return 400. The default HTTP session ID is "http".

Deploy the fetch handler

await bot.start();

export default {
  fetch(request: Request) {
    return bot.fetch(request);
  },
};

Node.js deployments need a server adapter that translates incoming requests to web-standard Request and Response objects. XSAF intentionally does not choose or start that listener.

Compose with Hono

bot.app.get("/ready", (context) => {
  return context.json({ ready: true });
});

await bot.start();

The same Hono app hosts user routes, channel routes, and MCP. Mount custom middleware after bot.start() and before accepting requests when route order matters. Hono’s MCP host and Origin protections remain enabled for the MCP endpoint.

Channel contract

interface XsafChannelDriver {
  name: string;
  listen(context: ChannelContext): void | Promise<void>;
  send(target: ChannelTarget, payload: ChannelPayload): Promise<void>;
  close?(): Promise<void>;
}

Inbound channel messages dispatch through the normal request path. The result is sent back through that channel and message.sent is emitted.

HTTP channel

import { agent } from "@xsaf/agent";
import http from "@xsaf/agent/channel/http";

const channel = http({ name: "web", path: "/chat", apiKey: process.env.API_KEY });
const bot = agent(config).channel(channel);
POST /chat
Accept: text/event-stream
Authorization: Bearer <api-key>
Content-Type: application/json

{"sessionId":"customer-1","text":"Hello"}

Requests accepting text/event-stream receive typed events for response chunks and tool/delegate activity. Other clients retain JSON responses for string and object results. A missing session ID is generated with crypto.randomUUID(). When apiKey is configured, the route requires the matching bearer token.

The HTTP channel tracks pending responses by session in FIFO order. Its send() target must be a session ID, and closing the channel rejects pending requests. Use @xsaf/agent/channel/mock for deterministic in-process tests; see Testing.

Chat SDK channel

The Chat SDK channel bridges the universal chat package to natively support Slack, Teams, Discord, Telegram, Google Chat, and other platforms.

import { Chat } from "chat";
import { createSlackAdapter } from "@chat-adapter/slack";
import { agent } from "@xsaf/agent";
import chatSdk from "@xsaf/agent/channel/chat-sdk";

const bot = new Chat({
  userName: "mybot",
  adapters: { slack: createSlackAdapter() },
  // ... state adapter, etc.
});

// Wire webhooks to your HTTP framework separately:
// app.post("/webhooks/slack", bot.webhooks.slack);

const assistant = agent(config).channel(chatSdk(bot));

The driver registers inbound handlers on bot at listen() time and dispatches each incoming message into the XSAF request path. Outbound payloads are posted back to the originating platform thread.

Sessions and streaming

Session IDs define both memory history and concurrency ordering. Direct invocation defaults to "default"; the built-in /invoke route defaults to "http".

Session serialization

await Promise.all([bot.invoke("First", "customer-1"), bot.invoke("Second", "customer-1")]);

The second request waits for the first request’s memory and model work to finish. Calls for different session IDs can run concurrently. ask() participates in the same session locking.

Consume a stream

const result = await bot.invoke("Write a summary", "customer-1");

if ("textStream" in result) {
  for await (const chunk of result.textStream) {
    process.stdout.write(chunk);
  }

  const completed = await result.completed;
  console.log(completed.usage);
} else {
  console.log(result.text);
}
interface AgentStreamResult {
  textStream: AsyncIterable<string>;
  completed: Promise<AgentResult>;
}

XSAF wraps rather than eagerly buffers the provider iterable, preserving backpressure. It collects the final text while the consumer advances the stream, then persists the assistant message and resolves completed.

POST /invoke consumes the model stream internally and returns a single JSON response. The bundled HTTP channel returns an AsyncIterable as Server-Sent Events, so clients receive chunks as they are consumed.

Tool calls receive a combined timeout and caller signal through ToolExecutionContext.signal. Cancellation is cooperative: adapters and tool code must observe the signal. Timed-out executions are not retried, but host-process code that ignores its signal can continue running.

Memory

XSAF stores inbound user messages and completed assistant responses through one memory driver. The default is process-local in-memory storage.

Use the default

No configuration is required. For an explicit instance:

import { inMemory } from "@xsaf/agent/memory/in-memory";

const memory = inMemory();

The in-memory driver is non-durable. get() returns a new array, but message objects are not deep-cloned.

Use db0 (durable SQL)

import { createDatabase } from "db0";
import sqlite from "db0/connectors/bun-sqlite";
import { db0 } from "@xsaf/agent/memory/db0";

const memory = db0(createDatabase(sqlite({ name: "xsaf-memory" })));
const bot = agent(config).memory(memory);

const hits = await memory.search({ query: "weather", limit: 10 });
const scoped = await memory.search({ query: "weather", sessionId: "customer-1" });

db0 is an optional peer dependency. Messages are stored as rows in xsaf_messages (session_id, seq, role, content, …). .search() runs a parameterized LIKE query across all sessions unless sessionId is set; corrupt or invalid rows fail the request. Call db0(database, { dispose: false }) when another owner still needs the Database after agent shutdown.

On Nitro, enable experimental.database and pass useDatabase() into db0(...).

Use unstorage (durable KV)

import { createStorage } from "unstorage";
import fsDriver from "unstorage/drivers/fs";
import { unstorage } from "@xsaf/agent/memory/unstorage";

const memory = unstorage(
  createStorage({
    driver: fsDriver({ base: "./data/memory" }),
  }),
);

const bot = agent(config).memory(memory);

unstorage is an optional peer dependency. Session history is stored as a JSON Message[] under xsaf:session:<encodedSessionId>. Loaded payloads are validated; corrupt data fails the request. Call unstorage(storage, { dispose: false }) when another owner still needs the Storage after agent shutdown.

Supply a driver

interface XsafMemoryDriver {
  get(sessionId: string): Promise<Message[]>;
  append(sessionId: string, message: Message): Promise<void>;
  clear(sessionId: string): Promise<void>;
  close?(): Promise<void>;
}
const bot = agent(config).memory(durableMemory);

Only one memory driver can be configured. XSAF treats memory failures as request failures rather than silently continuing with incomplete context.

interface Message {
  role: "system" | "user" | "assistant" | "tool";
  content: string;
  name?: string;
  toolCallId?: string;
  meta?: Readonly<Record<string, unknown>>;
}

The agent persona is inserted as a system message in each model request and is not persisted in memory. Channel metadata and stored content are untrusted data; a durable driver should apply its own storage security and retention policy.

For non-streaming requests, XSAF persists the assistant response before invocation resolves. For streaming requests, persistence occurs after successful stream consumption. Memory append failures reject completed and keep the request failure visible.

Alpha ships @xsaf/agent/memory/db0 for SQL backends via db0 (searchable) and @xsaf/agent/memory/unstorage for KV backends via unstorage. Custom drivers remain structural integrations and must implement the contract above.

See also

Navigation

Type to search…

↑↓ navigate↵ selectEsc close