Omnara's AI Cloud Sandbox Syncing
You kick off a codex xhigh refactor on your laptop. 15m later, you realize you need to leave, and codex is only halfway done. You want to pick it up on your phone with the same code changes and conversation. This is an obviously reasonable request. It is also something no one has built well.
There are a litany of inferior approximations. You can work entirely from a persistent VM. But you must accept the latency and environment management pain. You can resume via teleporting. But you need to have completed this checklist ahead of time. You can use remote control, but your laptop needs to stay open.
Each solution partially solves the problem. However, the solution isn’t unachievable! A combination of resumable chat sessions, smart code checkpointing, and sandboxes solve the portability problem.
Resuming chat sessions
Let’s start the easy part: a primer on how most LLM harnesses interact with a /completions (or /responses or /messages) API.
Each time you send a message to a coding agent, this results in a chat turn. Each chat turn consists of one or more steps. There is usually one step per tool call.
The above consists of 1 turn, and 3 steps (one for each tool call)
The coding agent harness saves some state of the conversation at each turn because it needs to make a subsequent API call until the agent has completed its generation. In Claude Code, this is just a JSONL session file that looks roughly like this:
{ "uuid": "<uuid>", "timestamp": "2026-03-13T22:18:39.490Z", "cwd": "<cwd>", "sessionId": "<uuid>", "gitBranch": "<branch>", "message": { "role": "user", "content": [ { "type": "text", "text": "When we create a worktree..." } ] },}
{ "uuid": "<uuid>", "timestamp": "2026-03-13T22:18:42.562Z", "cwd": "<cwd>", "sessionId": "<uuid>", "gitBranch": "<branch>", "message": { "model": "claude-opus-4-6", "id": "<id>", "type": "message", "role": "assistant", "content": [ { "type": "text", "text": "\n\nI'll start by researching the codebase..." } ], "stop_reason": null, "stop_sequence": null, "usage": { "input_tokens": 3, "cache_creation_input_tokens": 19537, "cache_read_input_tokens": 0, "cache_creation": { "ephemeral_5m_input_tokens": 19537, "ephemeral_1h_input_tokens": 0 }, "output_tokens": 2, "service_tier": "standard", "inference_geo": "global" } }}
In Omnara, we persist those messages to our database. As long as we can reconstruct that session file from the messages in our database, we can resume the chat later on any machine! The way Claude Code stores messages isn’t a public interface, but we do a best effort reconstruction of the session file.
Code checkpointing: weird git commands that you probably shouldn’t use
Now, we need to recover the code at some point in time in the conversation. Conceptually, this is simple. What if, after every write action (i.e. any Edit, Bash, or other writable tool call), we save a checkpoint of the codebase.
Most coding agents have support for hooking into tool call actions by default. The harder part is actually constructing the checkpoints, even if the user is already using source control.
We need to reconstruct:
- The user’s working directory
- The git repository itself (and the current ref the user is on)
The key primitives here are git write-tree and git commit-tree.
This requires us to capture 4 things:
- headCommit -- the current HEAD SHA (
git rev-parse HEAD) - headRef -- the current branch name, or null if detached (
git symbolic-ref --short HEAD) - indexTree -- staged changes as a tree (
git write-tree) - worktreeTree -- the working directory as a tree, including untracked files
private async captureWorktreeTree(): Promise<string> { const tempIndexPath = `${gitDir}/omnara-temp-index-${uniqueId}`; const gitEnv = { ...process.env, GIT_INDEX_FILE: tempIndexPath }; // Step 1: Copy real index -> temp index (preserves tracked files, even gitignored ones) await fs.copyFile(realIndexPath, tempIndexPath); // Step 2: Update temp index with working tree modifications to tracked files execSync('git add --update', { env: gitEnv }); // Step 3: Add NEW untracked files (respecting .gitignore) execSync('git add .', { env: gitEnv }); // Step 4: Write the temp index as a tree object const result = execSync('git write-tree', { env: gitEnv }); return result.trim();}
Then, with the worktreeTree, we can commit with commit-tree and attach important metadata to the commit to reconstruct the repository at some point in time.
Cloud storage
There’s one final piece: uploading the conversation and code to the cloud so it can be pulled down later on a different machine. For the conversation, that’s easy -- we already store that data in our database. The code part is only marginally harder, since git is designed to bundle and push changes remotely. Though we don’t want to push directly to a user’s git remote, as that causes clutter and risks pushing unintentional changes (particularly dangerous if we accidentally push a user’s .env). Our solution is to manually upload git bundles, which package git objects into a single file that can be transferred without a live remote.
For each checkpoint we create two bundles:
- headCommit
This is a bundle containing the current head commit for the checkpoint
This will contain the majority of the repository’s commit history - incremental checkpointRef bundle
This contains the incremental changes from headCommit
Using two bundles, one being incremental helps us save a little bit on storage upload times and storage costs. Technically, the optimal solution would be hosting a git remote per user workspace to dedupe git objects, however, we just end up deduping the headCommit. It's content-addressed by the HEAD SHA, so if a bundle for that commit already exists we skip the upload. We store everything in object storage using presigned URLs (we use R2) which lets us upload from the user's own machine or our managed cloud sandboxes. Even though we’re inefficient with storage, object storage is cheap, so it’s not a big deal if we’re duplicating a lot of data. This significantly reduces complexity, since we don’t need to expose full git remote functionality to the user.
Then, once uploaded, we modify a pointer for latestSyncedCheckpoint, which is a parameter for each worktree. Our abstraction for a single copy of a workspace is based on worktrees, so you may move worktrees between remote and local (and have any number of chats grouped with a worktree)
Sandboxing (and why cold start doesn't matter that much)
When a user resumes a session remotely, we start a session using a sandbox. We use Cloudflare’s Sandbox SDK, but you could use any other sandbox provider. It just needs a filesystem and coding agent harness. We initialize the repo with the bundles we created above, and then seed the .claude/ or .codex/ folder with our session file reconstructed from the messages in our database.
One aside: we don't use much of the richer functionality of these sandbox SDKs (CF Sandbox SDK, E2B, Daytona). We mostly treat it as a dumb keyed sandbox, because all of our setup is upfront. Most of these sandbox libraries want you to manage lifecycle through the SDK. We already have a daemon process that handles our application lifecycle, so we don't end up using most of those features. Here's roughly what the setup process looks like:
// workers APIapp.post('/worktrees/:worktree_id', /* auth & validation middleware */ async (c) => { const sandbox = getSandbox(env.Sandbox, worktreeId, { sleepAfter: "4h" }); await sandbox.start({ entrypoint: ["./worktree-entrypoint.ts"], envVars: buildEnvVars(), enableInternet: true }); return c.ok()})
// entrypoint inside containerasync function entrypoint() { const config = validateConfig(process.env) // initialize and create llm configs, like ~/.claude and ~/.codex folders writeLLMCreds(config) // run background daemon, this allows us to start coding agent sessions on our machine const daemonPid = spawn("omnara daemon run-service"); // setup github CLI creds and git push credentials if (config.github) { setupGithub(config.github) } if (config.checkpointId) { // clone from workspace bundles (existing checkpoint id) await exec(`omnara workspace load --checkpoint-id ${config.checkpointId}`) } else { // clone from scratch (not starting from a checkpoint) await gitClone(config) } // write file that indicates to the daemon that the workspace is loaded, so // we don't start sessions before the session has started writeFile(`/tmp/.omnara-workspace-loaded`) }
Many sandbox providers fixate on cold start time, but in our experience, it doesn't matter much. The marginal difference between 500ms and 2s is negligible if your sandbox is stateful. The cold start time is almost always going to be dominated by the time it takes to bootstrap code and configs. Currently, our TTI for our sandboxes is around 8-10s. Under 2s of that is the sandbox itself initializing; the remaining 6-8s is all of our application logic. This includes resolving the git checkpoint, loading session files, and starting the omnara background process.
Try it out
The hard part of running a sandbox is the state. We've figured out the hard parts of state sync, so you don't have to figure it out yourself. Try it out by signing up at omnara.com!