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({ transport: "http" }). |
/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
const agent = await builder.start();
export default {
fetch(request: Request) {
return agent.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
const agent = builder.asAgent(); // builder was configured with name: "http_agent"
agent.app.get("/ready", (context) => {
return context.json({ ready: true });
});
await agent.start();The same Hono app hosts user routes, channel routes, and MCP. A sealed XsafAgent exposes its app before startup, so mount custom middleware on it before agent.start() when route order matters. A builder exposes .app only after builder.start(). 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 http from "@xsaf/agent/channel/http";
const channel = http({ name: "web", path: "/chat" });
const builder = xsaf.agent(config).channel(channel);POST /chat
Content-Type: application/json
{"sessionId":"customer-1","text":"Hello"}String and object results return JSON. Streaming results return Server-Sent Events with one data event per chunk. A missing session ID is generated with crypto.randomUUID().
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.
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([agent.invoke("First", "customer-1"), agent.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 agent.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 InMemoryMemory.
Use the default
No configuration is required. For an explicit instance:
import { InMemoryMemory, inMemory } from "@xsaf/agent";
const memory = inMemory();
const another = new InMemoryMemory();The in-memory driver is non-durable. get() returns a new array, but message objects are not deep-cloned.
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 builder = xsaf.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.
The package does not include SQLite or another durable memory adapter in alpha. Durable drivers remain structural integrations and must implement the contract above.