A self-hosted code-execution sandbox for LLM agents — built from scratch on Docker, not rented from E2B.
Upload a CSV → ask in plain English → an LLM agent writes Python, runs it in an isolated container, and answers with real charts.
React 19 · TypeScript · Express · OpenRouter · Docker · Agentic tool-calling
Most "AI data analyst" demos either (a) let the model hallucinate numbers from the CSV text, or (b) outsource execution to a managed sandbox like E2B. This project does neither: it runs untrusted, model-generated code in a Docker sandbox built and operated in this repo, and feeds the actual runtime output back into the agent loop.
The agent receives a question and a CSV, writes Python, executes it in the sandbox, and returns prose plus the figures it actually rendered. The charts below are real output produced by the project's own pipeline from the included sample dataset:
| Monthly revenue by product | Profit by region |
|---|---|
![]() |
![]() |
The sample data the agent reasoned over (click to expand)
| date | product | region | units_sold | revenue | cost |
|---|---|---|---|---|---|
| 2024-01-05 | Shoes | North | 120 | 60000 | 36000 |
| 2024-01-12 | Bags | North | 80 | 40000 | 16000 |
| 2024-01-18 | Shoes | South | 95 | 47500 | 28500 |
| 2024-01-25 | Bags | South | 60 | 30000 | 12000 |
| 2024-02-03 | Shoes | North | 140 | 70000 | 42000 |
| 2024-02-10 | Bags | North | 90 | 45000 | 18000 |
| … | … | … | … | … | … |
📸 Live UI: drop a screenshot/GIF of the chat interface here once captured —
(A 10–15s screen recording of "upload CSV → ask → chart appears" is the single highest-impact visual you can add.)
E2B and similar managed sandboxes are excellent products. Building this layer myself was a deliberate choice, and the tradeoffs are the interesting part of the project:
- Own the execution boundary. Code execution is where every security, cost, and latency decision actually lives. Treating it as a black box means you never learn where the boundary is — building it surfaces every decision (process isolation, filesystem mounts, output capture, lifecycle) explicitly.
- No per-execution vendor cost or rate limit. Each run is a local
docker run --rm; the only ceiling is the host. - Full control of the runtime image. The Python environment, libraries, and output contract (
/output/*.png) are defined in this repo and reproducible anywhere Docker runs. - It's the part of the system worth being able to defend in an interview. Anyone can call an API. The systems thinking is in the sandbox.
flowchart LR
subgraph Client["🖥️ React UI (Vite + TS)"]
UI["Chat · CSV upload · chart viewer"]
end
subgraph Server["⚙️ Express backend (TS)"]
API["POST /chat"]
LOOP["Agentic tool loop<br/>(max 10 iterations)"]
end
subgraph LLM["🤖 OpenRouter"]
MODEL["gemini-2.5-flash-lite<br/>tool calling"]
end
subgraph Box["📦 Docker sandbox"]
RUN["docker run --rm<br/>py_compile gate → python<br/>stdout · stderr · PNGs"]
end
UI -- "question + csvContent" --> API
API --> LOOP
LOOP -- "messages + tools" --> MODEL
MODEL -- "run_code(code)" --> LOOP
LOOP -- "execute" --> RUN
RUN -- "stdout / charts" --> LOOP
LOOP -- "answer + base64 charts" --> UI
sequenceDiagram
participant U as User
participant B as Backend
participant M as LLM
participant S as Docker sandbox
U->>B: question + CSV
B->>M: system prompt forces tool use + CSV
loop until answer (≤ 10 iterations)
M->>B: run_code(python)
B->>S: docker run --rm (compile → execute)
S-->>B: stdout / stderr / PNGs
B->>M: tool result
end
M-->>B: final answer
B-->>U: answer + rendered charts
The backend runs a bounded tool-calling loop (runAgent, max 10 iterations):
- The LLM is forced via system prompt to call the
run_codetool before answering — it cannot answer from memory when execution is available. - The model emits Python (e.g. a pandas aggregation + a matplotlib chart saved to
/output/). - The backend executes it in a fresh container and captures
stdout,stderr, and PNGs. - The tool result is appended to the message history and handed back to the model.
- The model iterates (fix an error, refine) or, once it has executed code and has a final answer, returns prose + the collected charts.
The core idea: the model's beliefs are grounded in real execution output, not in its reading of the CSV.
Each run_code call (runInSandbox):
- Writes the model's code to a unique scratch dir (
/tmp/sandbox_<ts>/script.py). - Launches an ephemeral container (
docker run --rm— destroyed on exit). - Bind-mounts the script and a dedicated
/outputdirectory for artifacts. - Runs a compile gate first (
python -m py_compile) so syntax errors are caught cleanly before execution. - Harvests every
.pngfrom/output, base64-encodes it, and returns it withexitCode,stdout, andstderr.
| Layer | Stack |
|---|---|
| Frontend | React 19, TypeScript, Vite |
| Backend | Node 20, Express, TypeScript (ESM) |
| Agent / LLM | OpenRouter SDK (google/gemini-2.5-flash-lite), tool calling |
| Sandbox | Docker (Python 3.11 + pandas + matplotlib) |
| Packaging | Multi-stage Dockerfiles, Docker Compose |
Prerequisites: Node 20+ & pnpm, a running Docker daemon, and an OpenRouter API key.
# 1. Build the sandbox image (backend executes code inside `my-python-app`)
docker build -t my-python-app .
# 2. Add your key to .env in the repo root
echo "open_router_key=sk-or-..." > .env
# 3. Backend
cd backend && pnpm install && pnpm dev # http://localhost:3000
# 4. Frontend (new terminal)
cd frontend && pnpm install && pnpm dev # Vite dev serverOpen the frontend, drop in a CSV (sample sales.csv included), and ask something like
"Which product has the highest profit margin, and show me the monthly revenue trend."
POST /chat
{
"question": "Which region is most profitable?",
"csvContent": "date,product,region,units_sold,revenue,cost\n...",
"messages": []
}→ { ok: true, answer: string, images: [{ fileName, base64 }] } · GET /health → { status: "ok" }
- Execution-grounded answers. The system prompt hard-requires a tool call before any answer — eliminating the most common failure mode of CSV chatbots (summarizing numbers the model never computed).
- Compile-before-run gate. Syntax errors return a clean signal so the model self-corrects instead of the container dying opaquely.
- Bounded agent loop. A hard 10-iteration cap with graceful fallback to the last good stdout — the loop can't run away.
- Artifact contract. A fixed
/outputconvention keeps the sandbox↔backend interface simple and language-agnostic. - Stateless containers.
--rmmeans no state leaks between executions; every run starts clean.
The honest hardening path from "works on my machine" to "I'd run this in production":
- Sandbox hardening —
--network none,--memory/--cpuslimits,--read-onlyroot FS, non-root user, per-execution wall-clock timeout. - Streaming — stream tool output and tokens to the UI instead of awaiting the full loop.
- Persistence — store conversations and uploaded files (
Conversation/fileIdare already modelled in the frontend types). - Multi-language sandboxes — the
/outputcontract generalizes beyond Python. - Benchmarks — measured cold-start, warm-execution latency, and cost-per-query. (To be published once measured — none are claimed here.)
Working prototype: single-image Python sandbox, single LLM provider, in-memory conversation state. Built to understand the code-execution layer of agentic systems end to end — from the browser, through the agent loop, down to the container boundary.

