Skip to content
robotadocs

Embedding agent-framework

@robota-sdk/agent-framework runs outside the CLI — in HTTP servers, bots, serverless functions and batch jobs. This guide shows the pattern for each context. It assumes you know the basics of InteractiveSession and createQuery() from Using the SDK.

API selection

Use caseAPINotes
Questions from a script or CI jobcreateQueryOne conversation per query function
Streaming server (SSE, WebSocket)createAgentRuntime + runtime.createSession()Full event stream per session
Custom tools with streamingruntime.createSession({ additionalTools, allowedTools })Tools and events together
Bot that remembers conversationscreateAgentRuntime({ sessionStore }) + resumeSessionIdResumes a saved session per channel
Serverless, nothing persistedcreateStatelessRuntimeNo session store; sessions default to bare
Batch processingOne createQuery per item, run with Promise.allIndependent conversations in parallel
Structured JSON outputresponseFormatSupport depends on the provider

Before you deploy: tools and permissions

Every framework session gets the default tools — Shell, Bash, Read, Write, Edit, Glob, Grep, WebFetch, WebSearch, AskUserQuestion — with file tools confined to cwd. On a server, decide what the model may do before it can do it:

  • Keep permissionMode: 'default' (the default). Reads and searches proceed; writing files, running commands and calling your own tools need approval, and a request nobody answers is denied.
  • Pre-approve your own tools by name with allowedTools: ['calculate'], or declare what they do with registerToolPermissionProfile('calculate', { riskClass: 'inspect' }) from agent-core.
  • Remove built-in tools the agent must not have with deniedTools: ['Shell', 'Bash', 'Write', 'Edit']. A tool denied by bare name is hidden from the model entirely.
  • permissionMode: 'bypassPermissions' lets the model run any command and edit any file under cwd. Use it only where that is acceptable, such as a disposable sandbox.

The framework reads no settings file and no project instructions unless you pass them, so a server session behaves the same wherever it runs. See What a session loads.

Layer overview

createZodFunctionTool / createFunctionTool  →  @robota-sdk/agent-tools      (tool definitions)
Robota, FunctionTool                        →  @robota-sdk/agent-core       (engine; no sessions)
createAgentRuntime / InteractiveSession     →  @robota-sdk/agent-framework  (events, permissions, sessions)
createQuery()                               →  @robota-sdk/agent-framework  (prompt-in, text-out wrapper)

createQuery — questions from code

createQuery() returns an async function. Each call is a new turn in the same conversation, so follow-up questions see earlier answers. There is no model option: the model comes from the settings files, and without one the session asks the provider for claude-opus-4-5. Use createQuery with the Anthropic provider, or use InteractiveSession, which takes an explicit model, for other providers.

import { createQuery } from '@robota-sdk/agent-framework';
import { AnthropicProvider } from '@robota-sdk/agent-provider-anthropic';
 
const query = createQuery({
  provider: new AnthropicProvider({ apiKey: process.env.ANTHROPIC_API_KEY! }),
  onTextDelta: (delta) => process.stdout.write(delta), // optional streaming
});
 
const answer = await query('What files are in the project?');

With your own tools, approve them with a permissionHandler (a query function has no allowedTools option):

import { z } from 'zod';
import { createQuery } from '@robota-sdk/agent-framework';
import { createZodFunctionTool } from '@robota-sdk/agent-tools';
import type { IAIProvider } from '@robota-sdk/agent-core';
 
declare const provider: IAIProvider;
 
const calculatorTool = createZodFunctionTool(
  'calculate',
  'Add two numbers',
  z.object({ a: z.number(), b: z.number() }),
  async ({ a, b }) => ({ result: a + b }),
);
 
const query = createQuery({
  provider,
  additionalTools: [calculatorTool],
  permissionHandler: async (toolName) => toolName === 'calculate',
});
 
const answer = await query('What is 1234 + 5678?');

Things to know about a query function:

  • Await one call before making the next. The function wraps one session. A call made while another is still running waits in that session's queue, and a newer waiting call replaces an older one. For parallel work, create one query function per task.
  • It has no shutdown. The session lives as long as the function. When you need to end sessions explicitly (per request, per job), use createAgentRuntime and call shutdown().

createAgentRuntime — streaming server

A runtime holds the shared configuration (cwd, provider, optional session store and command modules); runtime.createSession() builds an InteractiveSession from it with per-session options.

import { createAgentRuntime } from '@robota-sdk/agent-framework';
import { AnthropicProvider } from '@robota-sdk/agent-provider-anthropic';
 
declare const apiKey: string;
 
const runtime = createAgentRuntime({
  cwd: process.cwd(),
  provider: new AnthropicProvider({ apiKey }),
});
 
// Next.js App Router route handler
export async function POST(request: Request): Promise<Response> {
  const { message } = (await request.json()) as { message: string };
  const encoder = new TextEncoder();
 
  const stream = new ReadableStream({
    async start(controller) {
      const send = (data: Record<string, unknown>): void => {
        controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`));
      };
      const session = runtime.createSession({
        bare: true,
        deniedTools: ['Shell', 'Bash', 'Write', 'Edit'],
      });
      session.on('text_delta', (delta) => send({ text: delta }));
 
      try {
        const handle = await session.submit(message);
        const result = await handle.completed;
        send({ done: true, interrupted: result.interrupted === true });
      } catch (error) {
        send({ error: error instanceof Error ? error.message : String(error) });
      } finally {
        controller.close();
        await session.shutdown();
      }
    },
  });
 
  return new Response(stream, {
    headers: { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache' },
  });
}

handle.completed resolves with the turn's result, or rejects with the error the turn failed on. You can also listen for the complete, interrupted and error events instead.

Custom tools with streaming

runtime.createSession() accepts additionalTools; pre-approve them with allowedTools:

import type { IAgentRuntime } from '@robota-sdk/agent-framework';
import type { IToolWithEventService } from '@robota-sdk/agent-core';
 
declare const runtime: IAgentRuntime;
declare const calculatorTool: IToolWithEventService;
declare const dbLookupTool: IToolWithEventService;
 
const session = runtime.createSession({
  bare: true,
  additionalTools: [calculatorTool, dbLookupTool],
  allowedTools: ['calculate', 'db_lookup'],
  deniedTools: ['Shell', 'Bash', 'Write', 'Edit'],
});
 
session.on('tool_start', ({ toolName }) => console.log('calling', toolName));
session.on('tool_end', ({ toolName, result }) => console.log('done', toolName, result));
 
const handle = await session.submit('What is 10% of our Q4 revenue?');
console.log((await handle.completed).response);

Bot pattern — resuming conversations

Bots receive each message in a separate request or webhook call. Give the runtime a session store, and resume the saved session for each channel with resumeSessionId. A session is saved after every completed turn and again on shutdown.

import { createAgentRuntime, createNodeHostSessionStore } from '@robota-sdk/agent-framework';
import type { IAIProvider } from '@robota-sdk/agent-core';
 
declare const provider: IAIProvider;
 
const runtime = createAgentRuntime({
  cwd: process.cwd(),
  provider,
  sessionStore: createNodeHostSessionStore('/var/lib/my-bot/sessions'),
});
 
// Channel or thread id → session id
const sessions = new Map<string, string>();
 
async function handleMessage(channelId: string, text: string): Promise<string> {
  const session = runtime.createSession({
    bare: true,
    deniedTools: ['Shell', 'Bash', 'Write', 'Edit'],
    resumeSessionId: sessions.get(channelId), // undefined for the first message
  });
  try {
    const handle = await session.submit(text);
    const result = await handle.completed;
    sessions.set(channelId, session.sessionId);
    return result.response;
  } finally {
    await session.shutdown();
  }
}

In production, keep the channel-to-session map somewhere durable too.

createStatelessRuntime — serverless

createStatelessRuntime({ provider, cwd? }) is a runtime with no session store and with settings reads and writes from commands turned into no-ops. Its sessions default to bare: true. It has no project access, so its sessions never read project files.

The default tools are still there, so deny the ones your environment should not offer:

import { createStatelessRuntime } from '@robota-sdk/agent-framework';
import { AnthropicProvider } from '@robota-sdk/agent-provider-anthropic';
 
declare const apiKey: string;
 
const runtime = createStatelessRuntime({
  provider: new AnthropicProvider({ apiKey }),
});
 
export const handler = async (event: { prompt: string }): Promise<string> => {
  const session = runtime.createSession({
    deniedTools: ['Shell', 'Bash', 'Read', 'Write', 'Edit', 'Glob', 'Grep'],
  });
  try {
    const handle = await session.submit(event.prompt);
    return (await handle.completed).response;
  } finally {
    await session.shutdown();
  }
};

Session lifecycle

WhenDo
A connection or conversation startsCreate a session
The same user sends a follow-upReuse the session, or resume it by id
The connection closes or the conversation endsCall session.shutdown()
A request times outCall session.shutdown() (it aborts a running turn)

A session's history grows with every turn and is sent to the model each time. Create a fresh session per conversation rather than sharing one across users.

shutdown() stops background work, saves the session if a store is configured, and removes all listeners. Always call it when you are done:

import type { IAgentRuntime } from '@robota-sdk/agent-framework';
 
declare const runtime: IAgentRuntime;
declare const prompt: string;
 
const session = runtime.createSession({ bare: true });
try {
  const handle = await session.submit(prompt);
  await handle.completed;
} finally {
  await session.shutdown();
}

Structured output (responseFormat)

responseFormat asks the provider for JSON. How it reaches the model depends on the provider:

  • { type: 'json_object' } is sent as OpenAI's native JSON mode. The Anthropic provider has no equivalent and ignores it.
  • { type: 'json_schema', name, schema } is sent as native structured output by both the OpenAI and the Anthropic providers. runtime.createSession() accepts it; createQuery() accepts only text and json_object.

Parse the reply defensively either way.

import { createQuery } from '@robota-sdk/agent-framework';
import { OpenAIProvider } from '@robota-sdk/agent-provider-openai';
 
const query = createQuery({
  provider: new OpenAIProvider({ apiKey: process.env.OPENAI_API_KEY }),
  responseFormat: { type: 'json_object' },
});
 
const raw = await query(
  'Classify "TypeScript is great for large codebases." Reply as JSON with sentiment and topic.',
);
const result = JSON.parse(raw) as { sentiment: string; topic: string };
import type { IAgentRuntime } from '@robota-sdk/agent-framework';
 
declare const runtime: IAgentRuntime;
 
const session = runtime.createSession({
  bare: true,
  responseFormat: {
    type: 'json_schema',
    name: 'classification',
    schema: {
      type: 'object',
      properties: {
        sentiment: { type: 'string', enum: ['positive', 'negative', 'neutral'] },
        topic: { type: 'string' },
      },
      required: ['sentiment', 'topic'],
    },
  },
});

For typed, validated objects with automatic retries, use Robota.run(prompt, { output }) from agent-core; see Building Agents.

WebSocket server

One session per connection, with events forwarded as JSON messages. (For a ready-made WebSocket carrier with the full session protocol, see @robota-sdk/agent-transport-ws and Deployment.)

import { WebSocketServer } from 'ws';
import { createAgentRuntime } from '@robota-sdk/agent-framework';
import { AnthropicProvider } from '@robota-sdk/agent-provider-anthropic';
 
const runtime = createAgentRuntime({
  cwd: process.cwd(),
  provider: new AnthropicProvider({ apiKey: process.env.ANTHROPIC_API_KEY! }),
});
 
const wss = new WebSocketServer({ port: 8080 });
 
wss.on('connection', (ws) => {
  const session = runtime.createSession({
    bare: true,
    deniedTools: ['Shell', 'Bash', 'Write', 'Edit'],
  });
 
  session.on('text_delta', (delta) => ws.send(JSON.stringify({ type: 'delta', delta })));
  session.on('tool_start', ({ toolName }) =>
    ws.send(JSON.stringify({ type: 'tool_start', toolName })),
  );
  session.on('complete', (result) =>
    ws.send(JSON.stringify({ type: 'complete', response: result.response })),
  );
  session.on('error', (err) => ws.send(JSON.stringify({ type: 'error', message: err.message })));
 
  ws.on('message', (data) => {
    const { prompt } = JSON.parse(data.toString()) as { prompt: string };
    session
      .submit(prompt)
      .catch((err: Error) => ws.send(JSON.stringify({ type: 'error', message: err.message })));
  });
 
  ws.on('close', () => {
    void session.shutdown();
  });
});

A prompt sent while a turn is running waits for it; if the client sends several, only the newest waiting prompt runs.

Batch processing

Run independent queries in parallel with one query function per item:

import { createQuery } from '@robota-sdk/agent-framework';
import { AnthropicProvider } from '@robota-sdk/agent-provider-anthropic';
 
const provider = new AnthropicProvider({ apiKey: process.env.ANTHROPIC_API_KEY! });
 
async function classifyAll(texts: string[]): Promise<string[]> {
  return Promise.all(
    texts.map((text) =>
      createQuery({ provider })(
        `Classify the sentiment of: "${text}". Reply with one word: positive, negative, or neutral.`,
      ),
    ),
  );
}
 
const results = await classifyAll(['TypeScript is great!', 'This API is confusing.', 'It works.']);

For rate-limited providers, split the list and limit concurrency. For large batches, prefer sessions from a runtime so you can shutdown() each one when its item is done.

Error handling

Rate limits

The run loop does not retry a failed provider call (the vendor SDK clients inside the Anthropic and OpenAI providers apply their own default retries). A rate limit that still fails surfaces as RateLimitError; other provider failures as ProviderError with the HTTP status. Retry in your code — see Retrying provider failures.

Context overflow

A session tracks token usage and compacts the conversation automatically: before each new turn, if usage has passed the threshold (about 83.5% of the model's context window by default), it summarizes the history first. See Context Management.

Submitting after shutdown

submit() on a session that is shutting down or shut down rejects. Create a new session instead.

Complete examples