Agent Architecture
Understanding the agent system architecture with Cloudflare Durable Objects and VoltAgent Core
Agent Architecture
The agent system uses a multi-layered architecture combining Cloudflare Durable Objects for isolation, VoltAgent Core's PlanAgent for orchestration, Daytona for sandboxed execution, and Convex for data persistence.
System Overview
AgentWorker (AIChatAgent / Durable Object)
├── ConvexAdapter — JWT + external token auth, unified Convex HTTP client
├── PlanAgent (VoltAgent Core) — multi-step task decomposition
│ ├── Planning Toolkit — write_todos, update_todo for step tracking
│ └── Daytona Sub-Agent — file ops (list, read, write, edit, glob, grep, exec, Python)
├── Tools
│ ├── web_search (Exa neural search)
│ ├── ask_user (structured questions with options)
│ └── Background task wrapper (foreground + background execution)
├── Callable Functions
│ ├── SandboxFsService — listFiles, downloadFile, downloadFolder, deleteEntry
│ └── SandboxPtyService — openPtyTerminal, streamPtyTerminal, writePtyTerminal, resizePtyTerminal, closePtyTerminal, listPtyTerminalSessions
├── Vectorize — chat message indexing (768 dims, cosine)
└── Streaming — full event forwarding to frontend via createUIMessageStreamRequest Lifecycle
1. WebSocket Connection (onConnect)
When a frontend client connects:
override async onConnect(connection: Connection, ctx: ConnectionContext): Promise<void> {
const url = new URL(ctx.request.url);
await this._init({
model: url.searchParams.get("model") ?? undefined,
reasoningEffort: url.searchParams.get("reasoningEffort"),
inputModalities: url.searchParams.get("inputModalities")?.split(","),
tokenConfig: parseTokenFromUrl(url) ?? undefined,
});
await this._prepAgent();
return await super.onConnect(connection, ctx);
}The _init method:
- Stores init args in DO storage for reconnection
- Creates
ConvexAdapterfrom token config (JWT or external token) - Fetches the chat document from Convex (using token-type-appropriate query)
- Initializes Daytona client and retrieves the sandbox if the chat has one
- Registers callable functions (FS + PTY services) on the worker prototype
The _prepAgent method:
- Sets up VoltAgent registry and optional VoltOps observability
- Creates Daytona sub-agent from the sandbox toolkit
- Builds the PlanAgent with system prompt, tools, and sub-agents
- Adds planning toolkit (
write_todos,update_todo) - Patches the
tasktool with background execution support
2. Message Processing (onChatMessage)
override async onChatMessage(onFinish, options): Promise<Response> {
// 1. Update chat timestamp (fire-and-forget)
void this.convexAdapter.mutation(api.chats.index.update, { _id, patch: {} });
// 2. Generate title on first message (fire-and-forget)
if (this.messages.length === 1) {
void generateTitle({ convexAdapter, chatId, userMessage });
}
// 3. Process messages (filter modalities, sanitize tool parts, extract last user context)
const { messages: messagesForAgent, lastUserIdx, lastUserQueryText } =
processMessagesForAgent(this.messages, this.state.inputModalities);
// 4. Build RAG context from Vectorize
const retrievalMessage = lastUserIdx !== -1
? await buildRetrievalMessage({ env, memberId, queryText: lastUserQueryText })
: null;
// 5. Inject retrieval context before last user message
const modelMessages = retrievalMessage && lastUserIdx !== -1
? messagesForAgent.toSpliced(lastUserIdx, 0, retrievalMessage)
: messagesForAgent;
// 6. Stream via PlanAgent
const stream = await agent.streamText(modelMessages, { abortSignal });
// 7. Forward full stream to frontend
return createUIMessageStreamResponse({
stream: createUIMessageStream({
execute: ({ writer }) => parseStreamToUI(stream.fullStream, writer),
}),
});
}3. Message Persistence (persistMessages)
After messages are saved to DO SQLite (via AIChatAgent base class), they are indexed in Cloudflare Vectorize:
override async persistMessages(messages: UIMessage[]): Promise<void> {
await super.persistMessages(messages);
await indexMessagesInVectorStore({ env, agentName, chatId, messages });
}Each message gets embedded (768-dim vectors) and upserted with metadata including role, chatId, messageId, and text content.
4. Message Deletion (updateMessages)
A callable function that syncs message state -- deletes removed messages from both DO SQLite and Vectorize, then re-persists the remaining set:
@callable()
async updateMessages(messages) {
// Delete messages not in the new set
// Remove corresponding vectors from Vectorize
// Re-persist remaining messages
}5. Callable Functions (PTY Terminal Operations)
Callable functions are registered dynamically from SandboxFsService and SandboxPtyService. The worker auto-discovers methods on these service objects and exposes them as callable RPC methods:
openPtyTerminal-- opens/creates a PTY session and returns terminalIdstreamPtyTerminal-- streaming callable that streams PTY output for an existing sessionwritePtyTerminal-- send input to a PTY sessionresizePtyTerminal-- resize terminal dimensionsclosePtyTerminal-- kill and clean up a PTY sessionlistPtyTerminalSessions-- list active sessionslistFiles,downloadFile,downloadFolder,deleteEntry-- filesystem operations
6. State Updates (onStateUpdate)
When the frontend changes settings (model, reasoning effort):
override async onStateUpdate(state: AgentArgs, source: Connection | "server"): Promise<void> {
await this.ctx.storage.put("chatState", state);
await this._patchAgent(); // Updates model on PlanAgent + all sub-agents
await super.onStateUpdate(state, source);
}Authentication Flow
The ConvexAdapter handles two token types:
// JWT token -- standard Convex auth
const adapter = await createConvexAdapter(CONVEX_URL, { type: "jwt", token });
// External token -- shared secret for service calls
const adapter = await createConvexAdapter(CONVEX_URL, { type: "ext", token });Token type determines which Convex function variants are called (e.g., api.chats.index.get vs api.chats.index.getExt).
Request Routing
// packages/agent/src/index.ts
export default {
async fetch(request: Request, env: typeof worker.Env): Promise<Response> {
return (
(await routeAgentRequest(request, env, {
prefix: "agents",
cors: {
"Access-Control-Allow-Origin": env.SITE_URL,
"Access-Control-Allow-Credentials": "true",
"Access-Control-Allow-Methods": "GET, POST, HEAD, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization, Cookie, Upgrade, Connection, Sec-WebSocket-Key, Sec-WebSocket-Version, Sec-WebSocket-Extensions, Sec-WebSocket-Protocol",
"Access-Control-Max-Age": "86400",
},
})) || new Response("Not found", { status: 404 })
);
},
};The routeAgentRequest function from the agents package routes requests to the correct Durable Object by name (chat ID), using the agents prefix in the URL path.
Tools Architecture
PlanAgent Direct Tools
These run in the supervisor agent context:
| Tool | Source | Description |
|---|---|---|
web_search | Exa | Neural web search with source citations |
ask_user | Custom | Structured questions, pauses for user input via approval flow |
write_todos | VoltAgent | Create a step-by-step plan |
update_todo | VoltAgent | Update step status (pending/done) |
task | VoltAgent | Delegate work to a sub-agent (patched with background support) |
Daytona Sub-Agent Tools
These run in a delegated sub-agent context:
| Tool | Description |
|---|---|
list | List files and directories with metadata |
read | Read file contents with offset/limit pagination |
write | Write or overwrite files |
edit | Text replacement (single or replaceAll) |
glob | Find files by glob pattern |
grep | Search file contents by pattern |
exec | Execute shell commands |
stateful_code_exec | Persistent Python REPL (notebook-like contexts by ID) |
upload_attachment_to_workspace | Upload a sandbox file into Convex attachments and get a storageId |
Vector Search (RAG)
Messages are indexed in Cloudflare Vectorize for cross-chat retrieval:
// Indexing: embed message text and upsert with metadata
await vectorize.upsert([{
id: buildVectorId(agentName, messageId),
values: embedding, // 768-dim
metadata: { role, chatId, messageId, text },
}]);
// Retrieval: query by last user message, scoped to member
const results = await vectorize.query(embedding, {
topK: 6,
namespace: memberId,
returnMetadata: "all",
});Retrieved context is injected as a system message before the last user message.
File Structure
packages/agent/
├── alchemy.run.ts # IaC: DurableObject, Vectorize, secrets
└── src/
├── index.ts # Entry point, CORS, routeAgentRequest
├── client.ts # OpenRouter AI client + embedding
├── prompt.ts # System + task prompts
├── agent/
│ ├── worker.ts # AgentWorker class (AIChatAgent)
│ ├── chat-meta.ts # Title generation
│ ├── messages.ts # Message text extraction + part filtering
│ ├── types.ts # AgentArgs type
│ └── vectorize.ts # Vector indexing + retrieval
├── tools/
│ ├── sandbox/
│ │ ├── index.ts # Daytona toolkit (file ops) + FS/PTY service factories
│ │ ├── pty.ts # SandboxPtyService (PTY terminal management)
│ │ └── types.ts # Zod schemas for sandbox tool parameters
│ ├── websearch.ts # Exa web search toolkit
│ ├── ask-user.ts # User interaction tool (approval-based)
│ └── utils/
│ └── wrapper.ts # Background task + truncated output utilities
└── utils/
└── fullStreamParser.ts # VoltAgent stream → UI message stream adapterKey Dependencies
{
"agents": "Cloudflare Agents SDK",
"@cloudflare/ai-chat": "AIChatAgent base class",
"@voltagent/core": "PlanAgent, Agent, AgentRegistry, createTool, createToolkit",
"ai": "Vercel AI SDK (UIMessage, streaming)",
"@daytonaio/sdk": "Daytona sandbox SDK",
"exa-js": "Exa neural search",
"convex": "Convex HTTP client"
}Next, learn about Configuration.