Server SDK
One page for the current server-side surface area: the @21st-sdk/agent sandbox config helper, the runtime sandbox/thread API from @21st-sdk/node, and the token exchange helpers from @21st-sdk/nextjs/server.
agent, sandboxId, and threadId. Use @21st-sdk/node to create and drive those resources from your server code.How the layers fit together
Sandbox()in@21st-sdk/agentdefines the default environment for future sandboxes of a deployed agent.AnClientin@21st-sdk/nodecreates and manages runtime sandboxes, threads, files, commands, git clones, and tokens.@21st-sdk/nextjs/serveris only a token exchange helper for Next.js route handlers.- For raw HTTP endpoints and SSE behavior, see the API Reference.
1. Sandbox interface
The current sandbox interface is the Sandbox() helper from @21st-sdk/agent. You use it inside agent() to define how every new sandbox for that deployed agent should be prepared.
/home/user is the sandbox root directory. Claude settings live in /home/user/.claude. For example, skills go in /home/user/.claude/skills.import { agent, Sandbox } from "@21st-sdk/agent"
export default agent({
runtime: "claude-code",
model: "claude-sonnet-4-6",
systemPrompt: "You are a helpful support agent.",
sandbox: Sandbox({
apt: ["ffmpeg", "poppler-utils"],
setup: [
"corepack enable",
"pnpm install",
],
files: {
"/home/user/.claude/skills/refunds/SKILL.md": "Skill content goes here...",
"/home/user/README.local.md": "# Sandbox notes",
},
cwd: "/home/user",
}),
})| Field | What it does |
|---|---|
apt | Installs system packages with apt-get when the sandbox is created. |
setup | Runs shell commands after apt install. Good for dependency install or repo bootstrapping. |
files | Writes files into the sandbox before the agent starts using it. |
cwd | Creates and uses this directory as the working directory for setup commands and runtime execution. |
Sandbox(). Use dashboard env vars or pass envs to an.sandboxes.create().2. @21st-sdk/node
@21st-sdk/node is the package that actually manages sandboxes, threads, and tokens from server code.
import { AnClient } from "@21st-sdk/node"
const an = new AnClient({
apiKey: process.env.AN_API_KEY!,
})
const sandbox = await an.sandboxes.create({
agent: "support-agent"
})
const thread = await an.threads.create({
sandboxId: sandbox.id,
name: "Refund request",
})AnClient
const an = new AnClient({
apiKey: process.env.AN_API_KEY!,
})Sandboxes
| Method | Current behavior |
|---|---|
sandboxes.create({ agent, files?, envs?, setup? }) | Creates a runtime sandbox for a deployed agent. Uses the agent's deployed sandbox config, then applies runtime overrides. |
sandboxes.get(id) | Returns sandbox status, error state, agent info, and thread summaries. |
sandboxes.delete(id) | Deletes the runtime sandbox and cascades deletion to its threads. |
sandboxes.exec({ sandboxId, command, cwd?, envs?, timeoutMs? }) | Runs a shell command inside the sandbox and returns stdout, stderr, and exit code. |
sandboxes.files.write({ sandboxId, files }) | Writes one or more files into the sandbox. |
sandboxes.files.read({ sandboxId, path }) | Reads one file from the sandbox and returns its content. |
sandboxes.git.clone({ sandboxId, url, path?, token?, depth? }) | Clones a git repo into the sandbox. |
Threads
| Method | Current behavior |
|---|---|
threads.list({ sandboxId }) | Lists thread summaries for one runtime sandbox. |
threads.create({ sandboxId, name? }) | Creates a new thread inside an existing runtime sandbox. |
threads.get({ sandboxId, threadId }) | Returns one thread, including persisted messages when available. |
threads.delete({ sandboxId, threadId }) | Deletes a single thread from the sandbox. |
threads.run({ agent, messages, sandboxId?, threadId?, name? }) | Convenience method that can auto-create a sandbox and thread, then POST to the chat stream endpoint. |
Run threads from server
If sandboxId or threadId are omitted, the missing values are created automatically.
The messages array uses AI SDK UIMessage-style objects with role and parts. If you already use useChat(), this is the same general message shape you are already working with. References: UIMessage and useChat().
messages array every time. The relay uses the last user message as the next chat turn.const result = await an.threads.run({
agent: "support-agent",
sandboxId: sandbox.id,
threadId: thread.id,
messages: [
{
role: "user",
parts: [{ type: "text", text: "Check the refund policy for order #1234" }],
},
],
})
// Raw SSE Response from the relay
const stream = result.response.body
// Reconnect/cancel URL for this active stream
console.log(result.resumeUrl)Tokens
an.tokens.create({ agent?, userId?, expiresIn? }) returns a short-lived JWT for client-side chat access. Default expiresIn is "1h".
3. @21st-sdk/nextjs/server
@21st-sdk/nextjs/server only handles token exchange for browser chat. For now, that is the entire server-side surface of this package.
@21st-sdk/node for those.Create a token route
import { createAnTokenHandler } from "@21st-sdk/nextjs/server"
export const POST = createAnTokenHandler({
apiKey: process.env.AN_API_KEY!,
})Low-level token exchange
Use exchangeToken() only if you want to wrap the token exchange yourself.
import { exchangeToken } from "@21st-sdk/nextjs/server"
export async function POST(req: Request) {
const { agent, userId } = await req.json() as {
agent?: string
userId?: string
}
const data = await exchangeToken({
apiKey: process.env.AN_API_KEY!,
agent,
userId,
expiresIn: "1h",
})
return Response.json(data)
}4. Session patterns
The server SDK gives you sandbox and thread primitives. If you want persistent sessions, you store those IDs yourself and reuse them on later requests.
Create a new session
import { AnClient } from "@21st-sdk/node"
import { db } from "@/db"
const an = new AnClient({
apiKey: process.env.AN_API_KEY!,
})
export async function createSession(userId: string) {
const sandbox = await an.sandboxes.create({ agent: "support-agent" })
const thread = await an.threads.create({
sandboxId: sandbox.id,
name: "New chat",
})
return db.agentSession.create({
data: {
userId,
agent: "support-agent",
sandboxId: sandbox.id,
threadId: thread.id,
},
})
}Continue an existing session
Load the stored IDs, call threads.run(), and stream the relay response back to the client. The request body should include the full messages array your UI is currently rendering.
import { AnClient } from "@21st-sdk/node"
import type { UIMessage } from "ai"
import { db } from "@/db"
const an = new AnClient({
apiKey: process.env.AN_API_KEY!,
})
export async function POST(
req: Request,
{ params }: { params: Promise<{ sessionId: string }> },
) {
const { sessionId } = await params
const { messages } = await req.json() as { messages: UIMessage[] }
const session = await db.agentSession.findUniqueOrThrow({
where: { id: sessionId },
})
const result = await an.threads.run({
agent: session.agent,
sandboxId: session.sandboxId,
threadId: session.threadId,
messages,
})
return new Response(result.response.body, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
},
})
}Create a new thread in the same sandbox
Use this when you want a clean conversation but want to keep the same filesystem, cloned repos, and sandbox state.
import { AnClient } from "@21st-sdk/node"
import { db } from "@/db"
const an = new AnClient({
apiKey: process.env.AN_API_KEY!,
})
export async function createFollowUpSession(sessionId: string) {
const existing = await db.agentSession.findUniqueOrThrow({
where: { id: sessionId },
})
const thread = await an.threads.create({
sandboxId: existing.sandboxId,
name: "Follow-up chat",
})
return db.agentSession.create({
data: {
userId: existing.userId,
agent: existing.agent,
sandboxId: existing.sandboxId,
threadId: thread.id,
},
})
}Hydrate the UI and resume active streams
This is one restore flow. On the server, call threads.get() and load both the persisted messages array and the thread status. Pass the messages to the client as initialMessages and derive resumeStreamOnMount from thread.status === "streaming".
import { AnClient } from "@21st-sdk/node"
import type { UIMessage } from "ai"
import { db } from "@/db"
import { SessionChat } from "@/components/session-chat"
const an = new AnClient({
apiKey: process.env.AN_API_KEY!,
})
async function getSessionState(sessionId: string) {
const session = await db.agentSession.findUniqueOrThrow({
where: { id: sessionId },
})
const thread = await an.threads.get({
sandboxId: session.sandboxId,
threadId: session.threadId,
})
return {
agent: session.agent,
sandboxId: session.sandboxId,
threadId: session.threadId,
initialMessages: (thread.messages as UIMessage[] | undefined) ?? [],
resumeStreamOnMount: thread.status === "streaming",
}
}
export default async function SessionPage(
props: { params: Promise<{ sessionId: string }> },
) {
const { sessionId } = await props.params
const session = await getSessionState(sessionId)
return <SessionChat {...session} />
}"use client"
import { useChat } from "@ai-sdk/react"
import { AnAgentChat, createAnChat } from "@21st-sdk/react"
import type { UIMessage } from "ai"
import "@21st-sdk/react/styles.css"
import { useEffect, useMemo, useRef } from "react"
export function SessionChat(props: {
agent: string
sandboxId: string
threadId: string
initialMessages: UIMessage[]
resumeStreamOnMount: boolean
}) {
const {
agent,
sandboxId,
threadId,
initialMessages,
resumeStreamOnMount,
} = props
const chat = useMemo(
() =>
createAnChat({
agent,
tokenUrl: "/api/an/token",
sandboxId,
threadId,
}),
[agent, sandboxId, threadId],
)
const { messages, sendMessage, status, stop, error, setMessages } = useChat({
chat,
resume: resumeStreamOnMount,
})
const hydratedRef = useRef(false)
useEffect(() => {
if (hydratedRef.current) return
hydratedRef.current = true
setMessages(initialMessages)
}, [initialMessages, setMessages])
return (
<AnAgentChat
messages={messages}
onSend={(message) =>
sendMessage({
role: "user",
parts: [{ type: "text", text: message.content }],
})
}
status={status}
onStop={stop}
error={error}
/>
)
}After hydration or resume, the client should keep sending the full updated messages array back to your server route on each turn.