This repo is WIP. Do not use directly in production.
Persistent bash sandboxes backed by Convex, using Vercel's just-bash for in-memory command execution and Convex file storage for filesystem persistence. No VMs or containers involved.
Screen.Recording.2026-04-07.at.21.07.00.mp4
just-bash provides a bash interpreter that runs in-process on Node.js with a virtual in-memory filesystem. This project wraps it in a Convex action that loads the filesystem from storage before execution and diffs changes back afterward. The result is stateful, isolated bash environments that persist across invocations using only Convex primitives (database, file storage, and actions).
An optional AI agent (@convex-dev/agent) provides a chat interface that can execute commands in the sandbox via tool calling.
The core logic lives in convex/exec.ts. Here's what happens on each command:
const initialFiles: InitialFiles = {};
for (const file of files) {
const { storageId } = file;
initialFiles[file.path] = async () => {
const blob = await ctx.storage.get(storageId);
if (!blob) return "";
return await blob.text();
};
}Files are registered as async callbacks rather than loaded eagerly. just-bash only calls the callback when a command actually reads the file, so commands that touch a small number of files don't pay the cost of loading the entire filesystem.
We intercept the virtual filesystem's mutating methods (writeFile, appendFile, rm, mv, cp) to build a diff of what changed during execution:
const writtenPaths = new Set<string>();
const deletedPaths = new Set<string>();
const origWriteFile = fs.writeFile.bind(fs);
fs.writeFile = async (...fsArgs) => {
const path = fs.resolvePath(bash.getCwd(), fsArgs[0]);
writtenPaths.add(path);
deletedPaths.delete(path);
return origWriteFile(...fsArgs);
};The two sets interact correctly for compound operations: a file that is written then deleted in the same command won't be persisted; a file that is deleted then re-created will be.
just-bash resets its internal cwd after each exec() call, so cd would not persist between commands. We work around this by appending a hidden pwd to every command and extracting the result from stdout:
const CWD_MARKER = "\x00__CWD__\x00";
const result = await bash.exec(
`${command}\n__exit_code=$?\necho "${CWD_MARKER}$(pwd)"\nexit $__exit_code`
);The marker is stripped from the output, and the extracted path is saved to the session for the next invocation.
After execution, changes are written back to Convex:
- Written paths: content is read from the virtual FS, stored as a blob via
ctx.storage.store(), and the file record is created or updated. - Deleted paths: the corresponding file record is removed from the database.
- CWD changes: the session record is patched with the new working directory.
Each file version produces a new storage blob. Previous blobs are not deleted during execution (the sandbox delete mutation handles cleanup).
| Table | Fields | Purpose |
|---|---|---|
sandboxes |
name |
Top-level isolation boundary |
sessions |
sandboxId, cwd |
Persistent working directory per terminal |
files |
sandboxId, path, storageId |
Virtual filesystem entries backed by Convex storage |
agentThreads |
threadId, sandboxId, sessionId |
Maps AI agent threads to sandboxes |
Indexes enforce path uniqueness per sandbox (bySandboxIdAndPath) and support efficient lookups by parent (bySandboxId).
┌─────────────────────────────────────────────────────┐
│ Next.js Frontend │
│ ┌──────────┬──────────────────────┬──────────────┐ │
│ │ File │ File Viewer │ Agent Panel │ │
│ │ Tree │ │ │ │
│ │ ├──────────────────────┤ │ │
│ │ │ Terminal │ │ │
│ └──────────┴──────────────────────┴──────────────┘ │
└───────────────────────┬─────────────────────────────┘
│
┌───────────────────────┴─────────────────────────────┐
│ Convex Backend │
│ │
│ run.ts ──► exec.ts │
│ ├─ Hydrate virtual FS from storage │
│ ├─ Intercept FS mutations │
│ ├─ Execute via just-bash │
│ ├─ Extract cwd from output │
│ └─ Persist diff back to storage │
│ │
│ agent.ts ──► exec tool ──► run.ts │
└─────────────────────────────────────────────────────┘
The agent (convex/agent.ts) uses @convex-dev/agent with the Vercel AI Gateway (anthropic/claude-sonnet-4-20250514). It exposes a single exec tool that calls run.ts. The agent maintains a persistent session per thread so that directory changes and file modifications carry across tool calls within a conversation.
pnpm install
# Start Convex (deploys functions and generates types)
npx convex dev
# Set the AI Gateway API key (required for the agent)
npx convex env set AI_GATEWAY_API_KEY <your-key>
# Start the frontend
pnpm dev| Command | Description |
|---|---|
pnpm dev |
Next.js dev server (Turbopack) |
pnpm dev:convex |
Convex dev server |
pnpm build |
Production build |
pnpm test:once |
Run test suite (72 tests) |
pnpm lint |
ESLint |
pnpm typecheck |
TypeScript (frontend + Convex) |
convex/
exec.ts Core execution engine
run.ts Orchestration action (creates sandbox/session if needed)
sandbox.ts Sandbox CRUD (cascading deletes)
session.ts Session CRUD
file.ts File CRUD with path uniqueness + content URL query
agent.ts AI agent definition and exec tool
agentQueries.ts Thread/message queries for the chat UI
schema.ts Database schema
convex.config.ts Component registration (@convex-dev/agent)
*.test.ts Tests
app/
page.tsx Sandbox list (create, delete)
[sandboxId]/page.tsx IDE layout (file tree, viewer, terminal, agent)
components/
file-tree.tsx Nested file tree with selection state
file-viewer.tsx Text file display with line numbers
terminal.tsx Terminal with session management
agent-panel.tsx Chat panel with streaming and tool call rendering
| Package | Role |
|---|---|
just-bash |
In-memory bash interpreter with virtual filesystem |
convex |
Database, file storage, serverless functions |
@convex-dev/agent |
AI agent with tool calling |
@ai-sdk/gateway |
Vercel AI Gateway provider |
react-resizable-panels |
Resizable panel layout |
convex-test |
Convex function testing |
Apache 2.0. See LICENSE.