Isolated exec environments

AiChat uses E2B sandboxes as its isolated execution layer for two different paths:

  • python_exec and related data tools in chat sessions
  • Forge jobs launched from the chat control surface

The shared boundary lives in apps/aichat/server/utils/sandbox.ts, which is the only server module that imports the E2B SDK. Everything else asks for a sandbox through the SandboxDriver interface.

What the isolated environment is

The sandbox is a remote filesystem + command runtime created from an E2B template. The codebase currently uses two templates:

  • base — Debian + Python 3 + Node.js
  • code-interpreter-v1base plus Jupyter and a Python data stack

Template choice is part of the sandbox acquisition call, not a separate subsystem. The driver also documents a desktop template, but the chat and Forge paths in this repository use base and code-interpreter-v1.

The driver caches a handle by scope id and reuses it only while that remote sandbox is still running. In chat, this can preserve one interpreter across multiple cells during the sandbox's lifetime. A Forge job uses one sandbox during a single run and releases it afterward; a retry or follow-up run gets a fresh sandbox. E2B pause/resume is not implemented.

Code runs in chat

Python execution in chat flows through apps/aichat/server/utils/pythonTool.ts.

  1. The agent tool python_exec is built by buildPythonExecTool(sessionId).
  2. The tool calls acquireChatSandbox(sessionId) from apps/aichat/server/utils/sandbox.ts.
  3. acquireChatSandbox() always requests an interpreter sandbox with:
    • interpreter: true
    • template: 'code-interpreter-v1'
  4. The tool then calls sandbox.runCode(handle, params.code, ...).

That runCode path gives python_exec a Jupyter kernel. While the cached chat sandbox remains alive, imports, variables, and loaded data frames can be reused across calls in the same conversation.

Output shape for code runs

runCode() returns:

  • stdout
  • stderr
  • exitCode
  • charts

The chart list is produced by mapExecution() in apps/aichat/server/utils/sandbox.ts. It prefers Plotly outputs first, then falls back to image outputs such as matplotlib PNGs.

pythonTool.ts keeps the LLM-visible summary small and stores the full execution details separately in details. The chat runtime then uses toolDetailsFailed() to mark non-zero Python exits as tool errors.

Data attachment flow

When a user attaches files in chat, chatRuntime.ts enables the datasets and python_exec tool ids together. That allows the dataset tools in apps/aichat/server/utils/datasetTools.ts to fetch the original uploaded file into the same chat sandbox with dataset_fetch_file, after which python_exec can analyze it directly.

Agent runs in Forge

Forge jobs use the same sandbox driver, but they acquire a job-scoped sandbox in apps/aichat/server/utils/forgeJobs.ts.

The job orchestration path is:

  1. forgeTools.ts exposes the job-facing chat tools such as forge_create_job, forge_plan_job, forge_run_job, and forge_get_job.
  2. forgeJobs.ts creates the job record and later acquires the forge-job:${job.id} scope with useSandbox().acquire(...), using job.sandboxTemplate || 'base'.
  3. The chosen Forge adapter in apps/aichat/server/utils/forgeAdapters.ts runs against that handle.

There are two adapter modes visible in the codebase:

  • pi — the default adapter, which drives the sandbox with bash, read_file, and write_file tools through the pi-agent-core loop
  • claude-code — a headless CLI-based adapter that also runs inside the same sandbox driver

Unlike chat Python runs, Forge job execution is repository-oriented. forgeJobs.ts mints the scoped GitHub token, clones the repository, and creates the branch before handing the checked-out repoPath to the adapter. The adapter edits and tests inside that path. Afterward, forgeJobs.ts commits, pushes, and collects the diff. Keeping those Git operations in the orchestrator is part of the execution boundary.

Sandbox templates for jobs

forgeTools.ts exposes the available templates to the chat UI, and job creation stores the selected template in the forge_jobs.sandbox_template column (mapped to forgeJobs.sandboxTemplate by Drizzle). The current code paths use:

  • base for most repos
  • code-interpreter-v1 for data-heavy Python work

Isolation boundaries

The codebase enforces isolation in a few concrete ways:

  • All sandbox access goes through SandboxDriver in sandbox.ts.
  • Only sandbox.ts imports e2b or @e2b/code-interpreter directly.
  • Chat code execution can reuse an interpreter sandbox within one session id while that sandbox remains alive.
  • Each Forge run uses a separate sandbox handle keyed by forge-job:${job.id} and releases it when the run finishes.
  • Sandboxes are created with a 10-minute E2B timeout. Individual code and command calls fall back to 60 seconds at the driver level; python_exec defaults to 30 seconds, and Forge supplies per-step overrides.
  • runCommand() returns stdout, stderr, and exit code; it does not throw the raw SDK error back into the app path.

The sandbox driver also notes that resources are fixed by template. In other words, a larger environment means a different template, not a resized runtime.

Where results appear

For chat Python runs, the results surface in two places:

  • the streamed tool result seen by the LLM
  • the details payload returned to the chat runtime and UI, including charts

For Forge jobs, the adapter records a structured trace and summary, and forgeJobs.ts persists job state, cost, diff, and error information in the job record.

In both cases, errors are normalized before they reach the caller. Python execution folds kernel errors into stderr and exitCode: 1; command execution returns a similar { stdout, stderr, exitCode } shape.

Operational notes and limits

A few implementation details are worth keeping in mind:

  • acquire() caches the first sandbox created for an id and reuses it while it is still running.
  • Forge releases and kills its sandbox in a finally block after every run; it does not pause or resume that environment.
  • The interpreter sandbox flips Plotly to the plotly_mimetype renderer once per kernel so structured chart output can be captured.
  • mapExecution() caps chart count and size to keep outputs bounded.
  • The chat sandbox and Forge sandbox are both E2B-backed, but they are not the same handle and do not share filesystem state.
  • The repository’s docs already describe this at a high level in apps/web/content/docs/aichat/concepts.md; this page is the implementation-oriented reference.