Build an IDE-like UI for a coding agent backed by a sandbox environment
Coding agents need more than a chat window. They need a file browser, a code
viewer, and a diff panel, an IDE experience. This pattern connects a deep
agent to a sandbox so it can read,
write, and execute code in an isolated environment, then exposes the sandbox
filesystem through a custom API server so the frontend can display files in
real time as the agent works.This page covers the three-panel UI (file tree, code viewer, and chat) and
the custom API routes that expose the sandbox filesystem to it. For sandbox
providers, lifecycle scoping, seeding files, secrets, deployment, and production
useStream configuration, see Going to production.
Choose how long a sandbox lives and who shares it before wiring the frontend.
See Sandbox lifecycle for thread-scoped
vs assistant-scoped sandboxes, async graph factory
setup, TTL behavior, and SDK invocation examples.This guide uses thread-scoped sandboxes by default. The frontend and
custom API server both resolve the sandbox from the LangGraph
thread ID. That keeps conversations isolated and
lets page reloads reconnect to the same environment when you persist the thread ID.For multi-tenant apps,
scope sandboxes by user or assistant in your backend factory instead. For
demos without LangGraph threads, pass a client-generated session ID in the
API URL. The session ID does not persist across browser sessions.
Configure the deep agent with a sandbox backend
as described in Execution environment.
The agent gets filesystem tools and an execute tool automatically; no extra
tool configuration is needed.Building this UI adds one requirement on top of the production setup: a
custom API server which runs outside the agent graph, so both the agent
backend and your file-browsing routes must resolve the same sandbox for
each thread. Store the sandbox ID on thread metadata and share a single
lookup function between them.
from deepagents import create_deep_agentfrom deepagents.backends.langsmith import LangSmithSandboxfrom langgraph.config import get_configdef get_or_create_sandbox_for_thread(thread_id: str) -> LangSmithSandbox: if not thread_id: raise ValueError("thread_id is required") # Look up sandbox_id from thread metadata, create if missing, and seed files. raise NotImplementedError( "Implement sandbox lookup and creation for your deployment environment." )def get_thread_id_from_config() -> str: configurable = get_config().get("configurable", {}) thread_id = configurable.get("thread_id") if not thread_id: raise ValueError("No thread_id, agent must run on a thread") return thread_iddef agent(): return create_deep_agent( model="google_genai:gemini-3.5-flash", backend=lambda _runtime: get_or_create_sandbox_for_thread( get_thread_id_from_config() ), )
from deepagents import create_deep_agentfrom deepagents.backends.langsmith import LangSmithSandboxfrom langgraph.config import get_configdef get_or_create_sandbox_for_thread(thread_id: str) -> LangSmithSandbox: if not thread_id: raise ValueError("thread_id is required") # Look up sandbox_id from thread metadata, create if missing, and seed files. raise NotImplementedError( "Implement sandbox lookup and creation for your deployment environment." )def get_thread_id_from_config() -> str: configurable = get_config().get("configurable", {}) thread_id = configurable.get("thread_id") if not thread_id: raise ValueError("No thread_id, agent must run on a thread") return thread_iddef agent(): return create_deep_agent( model="openai:gpt-5.5", backend=lambda _runtime: get_or_create_sandbox_for_thread( get_thread_id_from_config() ), )
from deepagents import create_deep_agentfrom deepagents.backends.langsmith import LangSmithSandboxfrom langgraph.config import get_configdef get_or_create_sandbox_for_thread(thread_id: str) -> LangSmithSandbox: if not thread_id: raise ValueError("thread_id is required") # Look up sandbox_id from thread metadata, create if missing, and seed files. raise NotImplementedError( "Implement sandbox lookup and creation for your deployment environment." )def get_thread_id_from_config() -> str: configurable = get_config().get("configurable", {}) thread_id = configurable.get("thread_id") if not thread_id: raise ValueError("No thread_id, agent must run on a thread") return thread_iddef agent(): return create_deep_agent( model="anthropic:claude-sonnet-4-6", backend=lambda _runtime: get_or_create_sandbox_for_thread( get_thread_id_from_config() ), )
from deepagents import create_deep_agentfrom deepagents.backends.langsmith import LangSmithSandboxfrom langgraph.config import get_configdef get_or_create_sandbox_for_thread(thread_id: str) -> LangSmithSandbox: if not thread_id: raise ValueError("thread_id is required") # Look up sandbox_id from thread metadata, create if missing, and seed files. raise NotImplementedError( "Implement sandbox lookup and creation for your deployment environment." )def get_thread_id_from_config() -> str: configurable = get_config().get("configurable", {}) thread_id = configurable.get("thread_id") if not thread_id: raise ValueError("No thread_id, agent must run on a thread") return thread_iddef agent(): return create_deep_agent( model="openrouter:z-ai/glm-5.2", backend=lambda _runtime: get_or_create_sandbox_for_thread( get_thread_id_from_config() ), )
from deepagents import create_deep_agentfrom deepagents.backends.langsmith import LangSmithSandboxfrom langgraph.config import get_configdef get_or_create_sandbox_for_thread(thread_id: str) -> LangSmithSandbox: if not thread_id: raise ValueError("thread_id is required") # Look up sandbox_id from thread metadata, create if missing, and seed files. raise NotImplementedError( "Implement sandbox lookup and creation for your deployment environment." )def get_thread_id_from_config() -> str: configurable = get_config().get("configurable", {}) thread_id = configurable.get("thread_id") if not thread_id: raise ValueError("No thread_id, agent must run on a thread") return thread_iddef agent(): return create_deep_agent( model="fireworks:accounts/fireworks/models/glm-5p2", backend=lambda _runtime: get_or_create_sandbox_for_thread( get_thread_id_from_config() ), )
from deepagents import create_deep_agentfrom deepagents.backends.langsmith import LangSmithSandboxfrom langgraph.config import get_configdef get_or_create_sandbox_for_thread(thread_id: str) -> LangSmithSandbox: if not thread_id: raise ValueError("thread_id is required") # Look up sandbox_id from thread metadata, create if missing, and seed files. raise NotImplementedError( "Implement sandbox lookup and creation for your deployment environment." )def get_thread_id_from_config() -> str: configurable = get_config().get("configurable", {}) thread_id = configurable.get("thread_id") if not thread_id: raise ValueError("No thread_id, agent must run on a thread") return thread_iddef agent(): return create_deep_agent( model="baseten:zai-org/GLM-5.2", backend=lambda _runtime: get_or_create_sandbox_for_thread( get_thread_id_from_config() ), )
from deepagents import create_deep_agentfrom deepagents.backends.langsmith import LangSmithSandboxfrom langgraph.config import get_configdef get_or_create_sandbox_for_thread(thread_id: str) -> LangSmithSandbox: if not thread_id: raise ValueError("thread_id is required") # Look up sandbox_id from thread metadata, create if missing, and seed files. raise NotImplementedError( "Implement sandbox lookup and creation for your deployment environment." )def get_thread_id_from_config() -> str: configurable = get_config().get("configurable", {}) thread_id = configurable.get("thread_id") if not thread_id: raise ValueError("No thread_id, agent must run on a thread") return thread_iddef agent(): return create_deep_agent( model="ollama:north-mini-code-1.0", backend=lambda _runtime: get_or_create_sandbox_for_thread( get_thread_id_from_config() ), )
Similar to the example in Going to production, the
agent is an async graph factory invoked on each run. Store the sandbox ID on
thread metadata so custom http.app routes can call the same
getOrCreateSandboxForThread helper. Going to production uses provider label
lookup instead when the LangGraph SDK is the only entry point.
Before the agent runs, upload starter files with uploadFiles /
upload_files. See File transfers
for seeding patterns, provider examples, and syncing
memories or skills into
the sandbox. For LangSmith sandboxes, pass templateName from a
sandbox snapshot when creating the container.
Run sandbox.execute("cd /app && npm install") after uploading
package.json so dependencies are ready before the first agent turn.
The agent can read and write files, but the frontend also needs direct access to
browse the sandbox filesystem. Add a custom FastAPI API server
and expose it through the http.app field in langgraph.json.
The sandbox API endpoints use the thread ID as a URL path parameter. This
ensures the frontend always accesses the correct sandbox for the current
conversation, using the same get_or_create_sandbox_for_thread function as the
agent’s backend:
Both the agent’s backend and the API server call the same
get_or_create_sandbox_for_thread function. This ensures they always resolveto the same sandbox for a given thread. The sandbox ID in thread metadata
is the single source of truth — no in-memory caches needed.
Register both the agent graph and the API server. The http.app field tells
the LangGraph platform to serve your custom routes alongside the default ones.
See application structure and
LangSmith Deployments
for the full set of langgraph.json options.
Your custom routes are available at the same host as the LangGraph API. For
local development with langgraph dev, that’s http://localhost:2024.
Custom routes defined in http.app take priority over default LangGraph routes. This means you
can shadow built-in endpoints if needed, but be careful not to accidentally override routes like
/threads or /runs.
The frontend has three panels: a file tree sidebar, a code/diff viewer, and a
chat panel. It uses useStream for the agent conversation and the custom API
endpoints for file browsing.For production deployment, point apiUrl at your
LangSmith Deployment, and pass a stable
thread_id on each run. See
Frontend in
Going to production for those settings
and for invoking the agent
with thread_id and runtime context.
Track two snapshots of the sandbox filesystem: the original state (before the
agent runs) and the current state (updated in real time). The thread ID is
included in the API URL so requests always hit the correct sandbox:
const AGENT_URL = "http://localhost:2024";async function fetchTree(threadId: string): Promise<FileEntry[]> { const res = await fetch( `${AGENT_URL}/sandbox/${encodeURIComponent(threadId)}/tree?filePath=/app`, ); const data = await res.json(); return data.entries.filter((e: FileEntry) => !e.path.includes("node_modules"));}async function fetchFile(threadId: string, path: string): Promise<string | null> { const res = await fetch( `${AGENT_URL}/sandbox/${encodeURIComponent(threadId)}/file?filePath=${encodeURIComponent(path)}`, ); const data = await res.json(); return data.content ?? null;}
The key to the IDE experience is updating files as the agent works, not
after it finishes. Watch the stream’s messages for ToolMessage instances
from file-mutating tools. When a write_file or edit_file tool call
completes, refresh that specific file. When execute completes, refresh
everything (since a shell command could modify any file):
import { useStream } from "@langchain/react";import { ToolMessage, AIMessage } from "langchain";const FILE_MUTATING_TOOLS = new Set(["write_file", "edit_file", "execute"]);export function IDEPreview() { const stream = useStream<typeof myAgent>({ apiUrl: AGENT_URL, assistantId: "deep_agent_ide", }); const processedIds = useRef(new Set<string>()); useEffect(() => { // Build a map of file-mutating tool calls from AI messages const toolCallMap = new Map(); for (const msg of stream.messages) { if (!AIMessage.isInstance(msg)) continue; for (const tc of msg.tool_calls ?? []) { if (tc.id && FILE_MUTATING_TOOLS.has(tc.name)) { toolCallMap.set(tc.id, { name: tc.name, args: tc.args }); } } } // When a ToolMessage appears for a file-mutating tool, refresh for (const msg of stream.messages) { if (!ToolMessage.isInstance(msg)) continue; const id = msg.id ?? msg.tool_call_id; if (!id || processedIds.current.has(id)) continue; const call = toolCallMap.get(msg.tool_call_id); if (!call) continue; processedIds.current.add(id); if (call.name === "write_file" || call.name === "edit_file") { refreshSingleFile(call.args.path ?? call.args.file_path); } else if (call.name === "execute") { refreshTreeAndFiles(); } } }, [stream.messages]);}
<script setup lang="ts">import { useStream } from "@langchain/vue";import { ToolMessage, AIMessage } from "langchain";import { watch } from "vue";const FILE_MUTATING_TOOLS = new Set(["write_file", "edit_file", "execute"]);const processedIds = new Set<string>();const stream = useStream<typeof myAgent>({ apiUrl: AGENT_URL, assistantId: "deep_agent_ide",});watch( () => stream.messages.value, (messages) => { const toolCallMap = new Map(); for (const msg of messages) { if (AIMessage.isInstance(msg)) { for (const tc of msg.tool_calls ?? []) { if (tc.id && FILE_MUTATING_TOOLS.has(tc.name)) { toolCallMap.set(tc.id, { name: tc.name, args: tc.args }); } } } } for (const msg of messages) { if (!ToolMessage.isInstance(msg)) continue; const id = msg.id ?? msg.tool_call_id; if (!id || processedIds.has(id)) continue; const call = toolCallMap.get(msg.tool_call_id); if (!call) continue; processedIds.add(id); if (call.name === "write_file" || call.name === "edit_file") { refreshSingleFile(call.args.path ?? call.args.file_path); } else if (call.name === "execute") { refreshTreeAndFiles(); } } }, { deep: true },);</script>
<script lang="ts"> import { useStream } from "@langchain/svelte"; import { ToolMessage, AIMessage } from "langchain"; const FILE_MUTATING_TOOLS = new Set(["write_file", "edit_file", "execute"]); const processedIds = new Set<string>(); const stream = useStream<typeof myAgent>({ apiUrl: AGENT_URL, assistantId: "deep_agent_ide", }); $effect(() => { const msgs = stream.messages; const toolCallMap = new Map(); for (const msg of msgs) { if (AIMessage.isInstance(msg)) { for (const tc of msg.tool_calls ?? []) { if (tc.id && FILE_MUTATING_TOOLS.has(tc.name)) { toolCallMap.set(tc.id, { name: tc.name, args: tc.args }); } } } } for (const msg of msgs) { if (!ToolMessage.isInstance(msg)) continue; const id = msg.id ?? msg.tool_call_id; if (!id || processedIds.has(id)) continue; const call = toolCallMap.get(msg.tool_call_id); if (!call) continue; processedIds.add(id); if (call.name === "write_file" || call.name === "edit_file") { refreshSingleFile(call.args.path ?? call.args.file_path); } else if (call.name === "execute") { refreshTreeAndFiles(); } } });</script>
import { Component, effect } from "@angular/core";import { injectStream } from "@langchain/angular";import { ToolMessage, AIMessage } from "langchain";const FILE_MUTATING_TOOLS = new Set(["write_file", "edit_file", "execute"]);@Component({ selector: "app-ide-preview", template: `<!-- ... -->`,})export class IdePreviewComponent { stream = injectStream<typeof myAgent>({ apiUrl: AGENT_URL, assistantId: "deep_agent_ide", }); private processedIds = new Set<string>(); constructor() { effect(() => { const messages = this.stream.messages(); const toolCallMap = new Map(); for (const msg of messages) { if (AIMessage.isInstance(msg)) { for (const tc of (msg as AIMessage).tool_calls ?? []) { if (tc.id && FILE_MUTATING_TOOLS.has(tc.name)) { toolCallMap.set(tc.id, { name: tc.name, args: tc.args }); } } } } for (const msg of messages) { if (!ToolMessage.isInstance(msg)) continue; const id = (msg as ToolMessage).id ?? (msg as ToolMessage).tool_call_id; if (!id || this.processedIds.has(id)) continue; const call = toolCallMap.get((msg as ToolMessage).tool_call_id); if (!call) continue; this.processedIds.add(id); if (call.name === "write_file" || call.name === "edit_file") { this.refreshSingleFile(call.args.path ?? call.args.file_path); } else if (call.name === "execute") { this.refreshTreeAndFiles(); } } }); }}
Before each agent run, snapshot the current file contents. After files refresh,
compare against the snapshot to identify which files changed:
function detectChanges(current: FileSnapshot, original: FileSnapshot): Set<string> { const changed = new Set<string>(); for (const [path, content] of Object.entries(current)) { if (original[path] !== content) changed.add(path); } for (const path of Object.keys(original)) { if (!(path in current)) changed.add(path); } return changed;}
When a user selects a changed file, default to the diff view so they
immediately see what the agent modified.
Show a summary of all modified files with line-level addition/deletion counts.
This gives users a quick overview of the agent’s impact — similar to a git status:
Persist threadId in sessionStorage so page reloads reconnect to the
same thread and sandbox instead of creating new ones.
Sync files on every relevant tool call, not just when the run finishes. Watch for write_file, edit_file, and execute
tool messages and refresh immediately.
Default to diff view for changed files. When a user clicks a file that
was modified by the agent, show the diff first — that’s what they care about.
Show compact tool results for read-only operations. Instead of dumping
the full output of read_file in the chat, show a one-liner like
Read router.js L1-42. Reserve the full output display for mutating tools.
Filter node_modules from the file tree. Nobody wants to browse
thousands of dependency files. Filter them out when fetching the tree.
For backends and sandboxes:
Use thread-scoped sandboxes for production apps. See
Sandbox lifecycle.
Share sandbox resolution between the agent backend and the API server via
thread metadata so both resolve the same environment with no in-memory caches.
Seed the sandbox with a real project. See
File transfers.
Keep secrets out of the sandbox. Use the
sandbox auth proxy
instead of environment variables or file uploads for API keys.