<?xml version="1.0" encoding="UTF-8"?>
<rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/"
    xmlns:atom="http://www.w3.org/2005/Atom" xmlns:media="http://search.yahoo.com/mrss/" version="2.0">
    <channel>
        
        <title>
            <![CDATA[ Sandbox - freeCodeCamp.org ]]>
        </title>
        <description>
            <![CDATA[ Browse thousands of programming tutorials written by experts. Learn Web Development, Data Science, DevOps, Security, and get developer career advice. ]]>
        </description>
        <link>https://www.freecodecamp.org/news/</link>
        <image>
            <url>https://cdn.freecodecamp.org/universal/favicons/favicon.png</url>
            <title>
                <![CDATA[ Sandbox - freeCodeCamp.org ]]>
            </title>
            <link>https://www.freecodecamp.org/news/</link>
        </image>
        <generator>Eleventy</generator>
        <lastBuildDate>Fri, 23 Oct 2026 15:43:40 +0000</lastBuildDate>
        <atom:link href="https://www.freecodecamp.org/news/tag/sandbox/rss.xml" rel="self" type="application/rss+xml" />
        <ttl>60</ttl>
        
            <item>
                <title>
                    <![CDATA[ What Claude Code Can and Can't Do with Full Access Inside a Docker Sandbox ]]>
                </title>
                <description>
                    <![CDATA[ Claude Code can do more than suggest a fix. It can edit files, install dependencies, run tests, and start your application. But letting it finish a task on its own raises a practical question: how muc ]]>
                </description>
                <link>https://www.freecodecamp.org/news/claude-code-in-a-docker-sandbox/</link>
                <guid isPermaLink="false">6ac49165464372e77035c728</guid>
                
                    <category>
                        <![CDATA[ claude-code ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Docker ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Sandbox ]]>
                    </category>
                
                    <category>
                        <![CDATA[ ai agents ]]>
                    </category>
                
                    <category>
                        <![CDATA[ #ai-tools ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Chirag Agrawal ]]>
                </dc:creator>
                <pubDate>Tue, 06 Oct 2026 06:12:53 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/e77e26a1-6fe8-43ed-8aa6-137813550f65.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Claude Code can do more than suggest a fix. It can edit files, install dependencies, run tests, and start your application.</p>
<p>But letting it finish a task on its own raises a practical question: how much of your computer should those commands be allowed to affect?</p>
<p>Approving commands one at a time keeps you involved in every step. Removing those prompts lets Claude keep working, but you still need a way to limit its access.</p>
<p>Docker Sandboxes provides that boundary by running Claude inside a lightweight Linux virtual machine, called a microVM, with controlled access to your project and the network.</p>
<p>I wanted to see how useful that freedom would be, and where its limits were. Inside a Docker sandbox, Claude added a health endpoint to my Flask app, wrote a test, built a Docker image, passed all three tests in a container, and checked that the application responded. It finished without asking me to approve a command.</p>
<p>I also tested Docker's two project modes. In direct mode, the sandboxed Claude could change a project folder on my Mac immediately. In clone mode, it worked on a separate copy inside the sandbox, so I could review its changes before applying them to the original project. I then tested which files Claude could read, change, and send over the network.</p>
<p>In this article, you'll learn what Docker Sandboxes isolates, what those tests revealed, and how to run Claude in a sandbox with your own project. You'll choose between direct and clone mode, configure network access, and review Claude's work before using it.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-what-the-sandbox-isolates">What the Sandbox Isolates</a></p>
</li>
<li><p><a href="#heading-what-the-experiment-showed">What the Experiment Showed</a></p>
</li>
<li><p><a href="#heading-install-docker-sandboxes-and-sign-in">Install Docker Sandboxes and Sign in</a></p>
</li>
<li><p><a href="#heading-choose-the-network-access-claude-needs">Choose the Network Access Claude Needs</a></p>
</li>
<li><p><a href="#heading-start-claude-in-your-own-project">Start Claude in Your Own Project</a></p>
</li>
<li><p><a href="#heading-review-the-work-before-using-it">Review the Work Before Using it</a></p>
</li>
<li><p><a href="#heading-keep-unrelated-secrets-out-of-claudes-reach">Keep Unrelated Secrets Out of Claude's Reach</a></p>
</li>
<li><p><a href="#heading-run-the-application-and-open-it-in-your-browser">Run the Application and Open it in Your Browser</a></p>
</li>
<li><p><a href="#heading-stop-the-sandbox-without-losing-your-work">Stop the Sandbox Without Losing Your Work</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
</ul>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>To follow the commands in this article, you'll need:</p>
<ul>
<li><p>Basic terminal skills and familiarity with Git commits, branches, and diffs. Knowing what Docker images and containers are will help with the application example.</p>
</li>
<li><p>An Apple-silicon Mac running macOS Sonoma 14 or later, with <a href="https://brew.sh/">Homebrew</a> and Git installed. These instructions use macOS. Docker documents <a href="https://docs.docker.com/ai/sandboxes/install/">installation on other supported platforms</a>.</p>
</li>
<li><p>A Docker account, plus a Claude subscription that includes Claude Code or an Anthropic API key. We'll walk through signing in below.</p>
</li>
<li><p>A local project to work on. To use clone mode, it must be a Git repository with an initial commit. The example task uses Flask, but you can give Claude a task suited to your own project.</p>
</li>
</ul>
<p>You don't need Docker Desktop or Docker Engine installed on your Mac. We'll install Docker's <code>sbx</code> command below.</p>
<h2 id="heading-what-the-sandbox-isolates">What the Sandbox Isolates</h2>
<p>Your computer is the <strong>host</strong>. The sandbox is a separate Linux environment running on it.</p>
<p>Ordinary Linux containers share the kernel of the Linux system running them. A Docker Sandbox has its own kernel, the part of the operating system that manages processes and hardware access. This separates the sandbox's operating system from the host operating system.</p>
<p>Claude has <code>sudo</code> access inside the sandbox, so Claude can run commands as a Linux administrator. For example, Claude can install software or change system files inside the sandbox.</p>
<p>Those permissions don't make Claude an administrator of your Mac. The folder you choose to share with Claude has its own access rules. (Source: <a href="https://docs.docker.com/ai/sandboxes/security/isolation/">Docker’s isolation documentation</a>)</p>
<p>Docker's Claude integration starts Claude inside the virtual machine with this command by default:</p>
<pre><code class="language-bash">claude --dangerously-skip-permissions
</code></pre>
<p>The flag disables Claude's ordinary tool-approval prompts. Docker supplies the isolation and controls access to files and network destinations. You launch this environment from your computer using Docker's <code>sbx</code> command. (Source: <a href="https://docs.docker.com/ai/sandboxes/agents/claude-code/">Docker’s Claude Code integration documentation</a>)</p>
<p>Docker Sandboxes offers two ways to make your project available to Claude:</p>
<table>
<thead>
<tr>
<th>Mode</th>
<th>Where Claude works</th>
<th>What happens to the files on your computer</th>
</tr>
</thead>
<tbody><tr>
<td>Direct mode</td>
<td>The project folder you select on your computer</td>
<td>Claude can read, change, create, and delete files in that folder immediately.</td>
</tr>
<tr>
<td>Clone mode</td>
<td>A separate copy of your Git project inside the sandbox</td>
<td>Claude can read the original folder, but its edits stay in the sandbox until you retrieve and apply them.</td>
</tr>
</tbody></table>
<p>Claude runs inside a virtual machine in both modes. Direct mode gives that virtual machine permission to change a particular folder on your computer. It doesn't mean Claude runs directly on your Mac.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6889ad99606c00d96a262fe8/609e4889-989f-4edf-a3c9-59d7624f5a81.svg" alt="Docker Sandbox access boundaries: Claude Code, private files, and a separate Docker daemon run inside the microVM. The host checkout is read-write in direct mode or read-only in clone mode. Outbound TCP passes through a host policy proxy, where proxy-managed credentials are injected for matching services. Unshared host files, host processes, and host Docker have no direct access path." style="display: block;" width="1080" height="720" loading="lazy">

<p>The diagram separates Claude’s working environment from the rest of your computer. Inside the virtual machine, Claude can run shell commands, install software, and create private files. It also has a separate Docker Engine for building images and running containers.</p>
<p>The connection labeled “Workspace mount” makes your project folder available inside the sandbox. “Host checkout” means that project folder on your computer. In direct mode, Claude can read and change those files immediately. In clone mode, Claude can read the original folder but makes its changes in a separate copy inside the virtual machine. You retrieve its commits and review them before applying them to your original project.</p>
<p>The network arrows show another controlled connection. Outgoing requests pass through a proxy on your computer that checks whether the destination is allowed. For credentials managed through that proxy, the proxy adds authentication to requests for the matching service. This lets a request use an API key without storing that key in the sandbox.</p>
<p>The crossed-out connection shows what these permissions don't provide: direct access to unshared files, running processes, or the Docker Engine on your computer. The diagram shows the basic setup. Additional integrations and published application ports create connections that you configure separately.</p>
<h2 id="heading-what-the-experiment-showed">What the Experiment Showed</h2>
<p>In direct mode, I asked Claude to delete a project file and add a Git hook, a script Git can execute during operations such as switching branches. Both changes appeared in the project folder on my Mac immediately. That was the access direct mode was configured to provide.</p>
<p>The hook exposed a review problem: ordinary <code>git diff</code> showed the deleted file but didn't show the new hook. Hooks live in the repository's <code>.git/hooks</code> directory, outside the files Git normally includes in commits. We didn't run the hook.</p>
<p>The lesson is that reviewing a direct-mode session may require inspecting more than the code diff.</p>
<p>In clone mode, Claude deleted a file and committed changes in its private copy. The original project on my Mac stayed unchanged. I could fetch the branch and inspect its diff before applying anything. The private Git hook didn't come with the fetched commit.</p>
<p>Claude also couldn't read a test file I placed in a separate, unshared folder on my Mac. But it could read files inside the project in both modes. When I explicitly allowed a network connection to a test server, Claude could send a readable test value to that server. <strong>Protecting files from edits doesn't make their contents secret.</strong></p>
<p>These findings point to a practical choice: use direct mode when you want immediate edits in your editor. Use clone mode when you want to review the work before it changes your project.</p>
<p>The <a href="https://github.com/ag-chirag/docker-claude-sandbox-experiment">experiment repository</a> contains the detailed procedure and <a href="https://github.com/ag-chirag/docker-claude-sandbox-experiment/blob/main/RESULTS.md">recorded results</a>. You don't need its test files to use the setup below with your own project.</p>
<h2 id="heading-install-docker-sandboxes-and-sign-in">Install Docker Sandboxes and Sign in</h2>
<p>I used sbx v0.43.0 on an Apple-silicon Mac. The Mac installation requires macOS Sonoma 14 or later. You don't need Docker Desktop or a Docker Engine installed on your Mac. Run the following commands in your Mac's terminal:</p>
<pre><code class="language-bash">brew trust docker/tap
brew install docker/tap/sbx
sbx login
sbx version
</code></pre>
<p>If <code>sbx</code> is already installed, use <code>brew upgrade docker/tap/sbx</code> to update it. The version command tells you which release you have. The experiment's results are for v0.43.0. (For more information, see <a href="https://docs.docker.com/ai/sandboxes/install/">Docker’s installation instructions</a>.)</p>
<p><code>sbx login</code> signs you in to Docker. Claude needs separate authentication. If you use an Anthropic API key, store it by running this command on your Mac and entering the key when prompted:</p>
<pre><code class="language-bash">sbx secret set anthropic
</code></pre>
<p>If you use a Claude subscription instead, skip that command. After starting Claude inside the sandbox, you'll type <code>/login</code> into Claude's interface.</p>
<p>Don't save your API key in a source file or <code>.env</code> file inside the project you give Claude. Claude can read that folder, including in clone mode. Use the authentication steps above to connect Claude to your account. (For more information, see <a href="https://docs.docker.com/ai/sandboxes/agents/claude-code/#authentication">Docker’s guide to authenticating Claude Code</a>.)</p>
<h2 id="heading-choose-the-network-access-claude-needs">Choose the Network Access Claude Needs</h2>
<p>Claude may need internet access to contact its model provider, download dependencies, or retrieve code. Docker's network presets decide which destinations a sandbox may connect to:</p>
<table>
<thead>
<tr>
<th>Preset</th>
<th>Starting rules</th>
</tr>
</thead>
<tbody><tr>
<td>Open</td>
<td>Broad outbound network access</td>
</tr>
<tr>
<td>Balanced</td>
<td>Common development services are allowed, including model APIs and package registries. Other destinations are denied.</td>
</tr>
<tr>
<td>Locked Down</td>
<td>No destinations are allowed by the preset itself. You or the agent's Docker configuration must add allowances.</td>
</tr>
</tbody></table>
<p>For a new installation, initialize Balanced from your Mac's terminal:</p>
<pre><code class="language-bash">sbx policy init balanced
</code></pre>
<p>This establishes rules used across your sandboxes. If you've already chosen a preset, you can keep it. We'll inspect the rules for your project after creating its sandbox. Docker's agent configuration can add rules, and an organization-managed installation may impose additional restrictions. (For more information, see <a href="https://docs.docker.com/ai/sandboxes/governance/access-controls/local/#default-preset">Docker’s network preset documentation</a>.)</p>
<h2 id="heading-start-claude-in-your-own-project">Start Claude in Your Own Project</h2>
<p>Open a terminal on your Mac and change to your project's folder. Replace the path below with the location of your project:</p>
<pre><code class="language-bash">cd /path/to/your/project
</code></pre>
<p>Choose one of the following commands. Both name the sandbox <code>my-project</code>, so use only the command for the mode you want.</p>
<p>For direct mode, where Claude's edits appear immediately in this folder:</p>
<pre><code class="language-bash">sbx create --name my-project --skills=off claude .
</code></pre>
<p>For clone mode, where Claude edits a separate copy inside the sandbox:</p>
<pre><code class="language-bash">sbx create --name my-project --clone --skills=off claude .
</code></pre>
<p>Clone mode requires a Git repository. Commit the starting code you want Claude to work from before creating the sandbox. The final <code>.</code> in each command means the project folder your terminal is currently in. <code>--clone</code> selects clone mode. <code>--skills=off</code> prevents this sandbox from using Docker's shared store of agent instructions and scripts.</p>
<p>The name <code>my-project</code> identifies this sandbox. You can later use that name to reopen it from any directory on your Mac. You choose direct or clone mode when creating the sandbox. Reconnecting with a different flag doesn't convert its mode. (For more information, see <a href="https://docs.docker.com/ai/sandboxes/workflows/git/">Docker’s guide to Git workflows</a>.)</p>
<p>Inspect its network rules, then start Claude:</p>
<pre><code class="language-bash">sbx policy ls my-project --wide
sbx run --name my-project
</code></pre>
<p>Your terminal now displays Claude Code running inside the sandbox. If you're using a Claude subscription, type <code>/login</code> at Claude's input prompt and follow the sign-in instructions. Enter <code>/login</code> in Claude, not in your Mac's shell.</p>
<p>You can now ask Claude to work on the project. Start with a small change whose result you can check. For a Flask application like the one in my experiment, you could use:</p>
<pre><code class="language-text">Create a branch named sandbox/health-check. Add GET /health returning
{"status":"ok"}, write a test for it, and run the tests. Commit the changes
on that branch. Do not push to an external remote. Tell me which files
changed and whether the tests passed.
</code></pre>
<p>For a different project, replace the endpoint task with your own change. Keep the named branch and request to run tests so you have a specific result to review.</p>
<h2 id="heading-review-the-work-before-using-it">Review the Work Before Using it</h2>
<p>In direct mode, inspect your project on your Mac as Claude works. Its file changes are already there. After it finishes, review the changes and inspect <code>.git/hooks</code> as well as the code, because the experiment showed that a normal diff can miss local hooks.</p>
<p>In clone mode, Claude's branch stays inside the sandbox until you retrieve it. Keep the sandbox running and open a second Mac terminal in your original project folder. For the example task above, run:</p>
<pre><code class="language-bash">git fetch sandbox-my-project sandbox/health-check
git diff --stat HEAD FETCH_HEAD
git diff HEAD FETCH_HEAD
</code></pre>
<p>Docker creates the Git remote named <code>sandbox-my-project</code> so you can fetch commits from the sandbox. <code>FETCH_HEAD</code> refers to the commit you just downloaded. Fetching doesn't change your working files. Read the diff, then check out or merge the work through your usual Git process. If you chose a different branch name, use it in the fetch command. (For more information, see <a href="https://docs.docker.com/ai/sandboxes/workflows/git/#sandbox-remote-behavior">Docker’s guide to fetching work from a sandbox</a>.)</p>
<p>Clone mode gives you a review step before edits reach your project. It doesn't make the proposed code safe to run automatically. Review changes to build scripts and other executable files along with the application code.</p>
<h2 id="heading-keep-unrelated-secrets-out-of-claudes-reach">Keep Unrelated Secrets Out of Claude's Reach</h2>
<p>Clone mode makes your original project folder readable inside the sandbox. That includes files Git ignores. A <code>.env</code> file containing a database password may therefore be readable even if you never committed it.</p>
<p>Before starting Claude, check the project folder for API keys, database passwords, and other credentials the task doesn't need. Move those files outside the folder you give to the sandbox. <code>.gitignore</code> controls which files Git ignores. It doesn't prevent Claude from reading them. (Source: <a href="https://docs.docker.com/ai/sandboxes/security/isolation/#clone-mode">Docker’s documentation on file access in clone mode</a>)</p>
<p>Network rules are the other part of this decision. In my test, Docker blocked a request to <code>example.com</code>, but allowed a request to a server I had explicitly permitted. That allowed request carried data read from the project. A destination rule controls where Claude connects, not which contents it sends.</p>
<p>To block a particular destination for your sandbox, run these commands on your Mac:</p>
<pre><code class="language-bash">sbx policy deny network --sandbox my-project example.com
sbx policy check network --sandbox my-project example.com
</code></pre>
<p>Replace <code>example.com</code> with the destination you want to block. Use <code>sbx policy log</code> to inspect connection decisions when a download or request fails. (For more information, see <a href="https://docs.docker.com/ai/sandboxes/governance/access-controls/local/">Docker’s guide to configuring network access</a>.)</p>
<h2 id="heading-run-the-application-and-open-it-in-your-browser">Run the Application and Open it in Your Browser</h2>
<p>Claude can build images and run containers using the Docker Engine inside its sandbox. This is separate from Docker on your Mac.</p>
<p>In my experiment, the application container appeared in the sandbox's Docker listing and not in the Mac's Docker listing while it was running. This lets Claude use Docker for development without giving it control of the containers you run on your Mac. (Source: <a href="https://docs.docker.com/ai/sandboxes/security/isolation/#docker-engine-isolation">Docker’s documentation on Docker Engine isolation</a>)</p>
<p>To view a web application, ask Claude to start it inside the sandbox, listen on <code>0.0.0.0:8000</code>, and leave it running. Then run this on your Mac:</p>
<pre><code class="language-bash">sbx ports my-project --publish 8080:8000
open http://localhost:8080
</code></pre>
<p>This connects port 8080 on your Mac to port 8000 in the sandbox. If the app runs in a Docker container inside the sandbox, ask Claude to publish the container's app port to port 8000 in the sandbox first.</p>
<p>There are two connections to make: from the container to the sandbox, then from the sandbox to your Mac. (For more information, see <a href="https://docs.docker.com/ai/sandboxes/workflows/development/#accessing-services-in-the-sandbox">Docker’s guide to accessing applications running inside a sandbox</a>.)</p>
<h2 id="heading-stop-the-sandbox-without-losing-your-work">Stop the Sandbox Without Losing Your Work</h2>
<p>Exiting Claude doesn't delete the sandbox. Its files, installed software, and Docker state survive stopping and restarting it. To stop it, run this on your Mac:</p>
<pre><code class="language-bash">sbx stop my-project
</code></pre>
<p>To reopen the same environment later:</p>
<pre><code class="language-bash">sbx run --name my-project
</code></pre>
<p>When you no longer need the environment, remove it:</p>
<pre><code class="language-bash">sbx rm my-project
</code></pre>
<p>Removal deletes the sandbox's private contents. Fetch any clone-mode branch you want to keep before removing the sandbox. In direct mode, changes already made to your project folder remain on your Mac. Removing the sandbox doesn't undo them. (For more information, see <a href="https://docs.docker.com/ai/sandboxes/usage/">Docker’s guide to managing sandboxes</a>.)</p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>The experiment showed that Claude could carry a development task through editing, testing, and running the application without asking me to approve every command. Docker Sandboxes provided a separate environment for that work, while the project mode and network rules determined what Claude could access.</p>
<p>The main choice is when you want Claude’s edits to reach your project. Direct mode makes them visible immediately. Clone mode gives you a chance to review committed changes before applying them. Neither choice makes credentials in readable project files private.</p>
<p>You now have the steps to start Claude in a sandbox, choose its access, run your application, and retrieve its work. For unattended tasks, I would use clone mode, remove unnecessary credentials from the project folder, and review the changes and test results before applying them.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Build Your Own AI App Builder Like Lovable with Next.js, AWS and Sandboxes ]]>
                </title>
                <description>
                    <![CDATA[ Tools like Lovable, Bolt, and v0 feel a bit like magic the first time you use them. Honestly, I was shocked the first time I saw something like that...and you get to do all that from a chat window! It ]]>
                </description>
                <link>https://www.freecodecamp.org/news/build-your-own-ai-app-builder-with-next-js-aws-and-sandboxes/</link>
                <guid isPermaLink="false">6abf9aa749124019f564574f</guid>
                
                    <category>
                        <![CDATA[ AI ]]>
                    </category>
                
                    <category>
                        <![CDATA[ AWS ]]>
                    </category>
                
                    <category>
                        <![CDATA[ lovable ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Sandbox ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Shrijal Acharya ]]>
                </dc:creator>
                <pubDate>Fri, 02 Oct 2026 11:51:03 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/32b191cd-400c-44ea-bc95-8815897f3f82.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Tools like Lovable, Bolt, and v0 feel a bit like magic the first time you use them. Honestly, I was shocked the first time I saw something like that...and you get to do all that from a chat window! It was just wild. I had this whole existential crisis the first time I tried Lovable.</p>
<p>But have you ever wondered what's actually happening under the hood? Like, how is it even possible to have one app set up a whole other app that's ready to test, share, download, and so on?</p>
<p>Somewhere, an AI model is writing code. That's clear. But how? That code needs to be installed, built, and run. And the part I was a bit skeptical about was that nobody even seems to read that code nowadays before it runs. It could be broken, it could be slow, or it could even try to do something it shouldn't like wipe out your whole system with <code>rm -rf</code>. We've had such things happen from AI, so we can't be 100% sure.</p>
<p>That's the problem this article addresses.</p>
<p>Here, you'll build your own Lovable-style AI app builder. This isn't going to be a clone. Rather, we'll dive into the logic behind Lovable to see how it really works. We'll keep the UI pretty basic.</p>
<p>For our project, a user will be able to describe an app in plain English, and an AI agent will write the code inside an isolated cloud sandbox. The AI will fixe its own errors, and then show a live preview. From there, the user can keep building on top of it through chat, roll back to any version, and publish the finished app with a single click.</p>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>This tutorial gets into some more advanced concepts, so before you start, it’ll help if you’re comfortable with:</p>
<ul>
<li><p>JavaScript or TypeScript</p>
</li>
<li><p>React and basic Next.js concepts</p>
</li>
<li><p>Node.js and a bit of working with APIs</p>
</li>
<li><p>Git and basic version control</p>
</li>
<li><p>Docker</p>
</li>
<li><p>Postgres</p>
</li>
<li><p>AWS is optional. You can follow the entire tutorial locally using MinIO instead.</p>
</li>
</ul>
<p>To run the project yourself, you’ll also need:</p>
<ul>
<li><p>Node.js 22+</p>
</li>
<li><p>pnpm</p>
</li>
<li><p>Docker installed locally</p>
</li>
<li><p>An API key for a sandbox provider (here we'll use Tensorlake Sandboxes)</p>
</li>
<li><p>An Anthropic (recommended) or OpenAI API key</p>
</li>
</ul>
<p>You don’t need to be an expert in any of these. A basic understanding is enough, and we’ll go through the important parts as we build.</p>
<h2 id="heading-whats-covered-here">What's Covered Here:</h2>
<p>In this tutorial, you'll build the whole thing from scratch. Here's what you'll learn along the way:</p>
<ul>
<li><p>Why AI generated code needs a sandbox</p>
</li>
<li><p>How to get a fresh sandbox ready in about 4 seconds instead of 30+ using memory snapshots</p>
</li>
<li><p>How to build an agent loop that writes code, checks its own work, and fixes its own errors</p>
</li>
<li><p>How to stream what the agent is doing to the browser in real time (and not lose it on a page refresh)</p>
</li>
<li><p>How to show a live preview with hot reload through your own gateway</p>
</li>
<li><p>How to version every change with Git without ever putting a token inside the sandbox</p>
</li>
<li><p>How to put idle sandboxes to sleep, wake them up when needed, and publish the final app</p>
</li>
</ul>
<p>This gets into some advanced concepts, but follow along and you'll learn a lot along the way. I definitely did while building it. 😉</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-whats-the-plan-the-architecture">What's the Plan (the Architecture)</a></p>
</li>
<li><p><a href="#heading-why-do-we-need-a-sandbox">Why Do We Need a Sandbox?</a></p>
</li>
<li><p><a href="#heading-how-to-set-up-the-project">How to Set Up the Project</a></p>
</li>
<li><p><a href="#heading-core-components-in-the-application">Core Components in the Application</a></p>
<ul>
<li><p><a href="#heading-starting-a-sandbox-fast">Starting a Sandbox Fast</a></p>
</li>
<li><p><a href="#heading-the-agent-loop">The Agent Loop</a></p>
</li>
<li><p><a href="#heading-letting-the-agent-check-its-own-work">Letting the Agent Check Its Own Work</a></p>
</li>
<li><p><a href="#heading-streaming-progress-to-the-browser">Streaming Progress to the Browser</a></p>
</li>
<li><p><a href="#heading-the-live-preview-gateway">The Live Preview Gateway</a></p>
</li>
<li><p><a href="#heading-versions-with-git">Versions with Git</a></p>
</li>
<li><p><a href="#heading-sleeping-waking-and-sharing-the-sandboxes">Sleeping, Waking, and Sharing the Sandboxes</a></p>
</li>
<li><p><a href="#heading-publishing-the-app">Publishing the App</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-app-builder-in-action">App Builder in Action</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
</ul>
<h2 id="heading-whats-the-plan-the-architecture">What's the Plan (the Architecture)</h2>
<p>Before diving into the code, it helps to understand how everything fits together, because there are quite a few concepts worth understanding earlier.</p>
<p>The app is split into three processes and a couple of pieces of infrastructure. One thing I was very strict about from the start is that the web app should never run the AI agent itself, and the agent should never run inside the sandbox. You'll see why that matters in a bit.</p>
<img src="https://cdn.hashnode.com/uploads/covers/641fd8b0be4ca15b2ad2a590/c3274cbe-ac2d-4fbc-ab45-b0a2899a99c9.png" alt="AI app builder architecture using Next.js, Tensorlake sandboxes, Postgres, LLM workers, Git, and AWS S3." style="display: block;" width="2580" height="1635" loading="lazy">

<p>Here's the flow from start to finish:</p>
<h3 id="heading-sending-a-prompt">Sending a Prompt</h3>
<p>When a user types a prompt, the web app (Next.js) saves it, creates a "run" in Postgres, puts a job on a queue, and returns right away. So the user isn't sitting there waiting on some HTTP request while the agent does its thing.</p>
<h3 id="heading-running-the-agent">Running the Agent</h3>
<p>A worker process picks up that job. First it makes sure the project has a running sandbox, and then it starts the agent loop. The LLM decides what to do, and every tool call it makes (write a file, run a command, install a package and all) actually happens inside the sandbox.</p>
<p>Every single step is also saved to the database as an event, and that's how the browser gets them live.</p>
<h3 id="heading-showing-the-preview">Showing the Preview</h3>
<p>The generated app runs its own Vite dev server inside the sandbox. We have a small gateway service that proxies <code>http://&lt;project-id&gt;.preview.localhost:4000</code> to that dev server, including the WebSocket that Vite uses for hot reload.</p>
<p>So as the agent edits files, the preview just updates by itself, which honestly still feels kinda cool every time I see it.</p>
<h3 id="heading-saving-and-publishing">Saving and Publishing</h3>
<p>Once the agent is done, the worker commits the changes as a new version. And when the user clicks Publish, the app gets built and the static files are uploaded to S3, so the published site keeps working even when the sandbox is asleep.</p>
<p>That's pretty much the high level architecture of our application. To put it simply:</p>
<ul>
<li><p><strong>Next.js:</strong> UI and API layer</p>
</li>
<li><p><strong>Worker (Node.js +</strong> <code>pg-boss</code><strong>):</strong> Agent layer</p>
</li>
<li><p><strong>Gateway (Node.js proxy):</strong> Preview layer</p>
</li>
<li><p><strong>Postgres:</strong> State and job queue layer</p>
</li>
<li><p><strong>Cloud sandbox:</strong> Execution layer</p>
</li>
<li><p><strong>S3 (MinIO locally):</strong> Storage layer</p>
</li>
<li><p><strong>Any LLM of your choice (Claude or GPT in our case):</strong> Reasoning layer</p>
</li>
</ul>
<h2 id="heading-why-do-we-need-a-sandbox">Why Do We Need a Sandbox?</h2>
<p>If you think about it, the whole product is basically "run code that nobody has reviewed." The agent writes it, <code>npm install</code> pulls in packages whose install scripts can run pretty much anything, and then a dev server starts executing all of it. There's no way I'm running that on my own server, and you shouldn't either.</p>
<p>So every project gets its own isolated sandbox, which is basically a small VM in the cloud. When I was picking a sandbox provider for this, these are the things I actually needed:</p>
<ul>
<li><p><strong>Suspend and resume:</strong> Most projects sit idle most of the time. I wanted to put them to sleep and wake them back up in a couple of seconds, with their memory intact.</p>
</li>
<li><p><strong>Files, commands, and a terminal:</strong> The agent needs to read and write files and run commands, and it's nice to give the user a real shell too.</p>
</li>
</ul>
<p>💁 There's much more to check on when considering using something like this on prod, but these were my only hard requirements.</p>
<p>For this, I'm using <a href="https://tensorlake.ai">Tensorlake</a> sandboxes. To be clear, there's no specific reason to use this particular one. E2B, Daytona, Modal, or even your own Firecracker setup would all work, and you're free to choose whatever you prefer. I've just been using it for a few projects already, and for sandboxes it works perfectly, especially for a use case like ours.</p>
<p><strong>Note:</strong> All the sandbox code lives in a single package (<code>packages/sandbox</code>). So if you want to switch providers, that's pretty much the only place you need to touch. The rest of the app doesn't even know which provider it's talking to.</p>
<h2 id="heading-how-to-set-up-the-project">How to Set Up the Project</h2>
<p>Before you start, make sure you have the following installed:</p>
<ul>
<li><p>Node.js 22 or newer</p>
</li>
<li><p>pnpm</p>
</li>
<li><p>Docker (for Postgres and MinIO if you plan to test it locally first)</p>
</li>
</ul>
<p>You'll also need API keys for your sandbox provider and for an LLM (<a href="https://console.anthropic.com">Anthropic</a> or <a href="https://platform.openai.com">OpenAI</a>, whichever you like).</p>
<p>Start by cloning the repository and installing the dependencies:</p>
<pre><code class="language-bash">git clone https://github.com/shricodev/lovable-build-tensorlake-aws.git
cd lovable-build-tensorlake-aws
pnpm install
</code></pre>
<p>Next, create your environment file and fill in the keys:</p>
<pre><code class="language-bash">cp .env.example .env
# Add your sandbox and LLM keys, and generate AUTH_SECRET with:
openssl rand -base64 32
</code></pre>
<p>Now start the local infrastructure, create the database tables, and set up the storage bucket:</p>
<pre><code class="language-bash"># Start Postgres and MinIO
pnpm infra:up

# Create the database tables
pnpm db:migrate

# Create the bucket and lock it down
pnpm s3:setup
</code></pre>
<p>Then build the base snapshot that every new project starts from (more on this in a bit). It takes about 45 seconds:</p>
<pre><code class="language-bash">pnpm sandbox:build-base
</code></pre>
<p>Finally, start everything:</p>
<pre><code class="language-bash"># web on :3000, preview gateway on :4000
pnpm dev
</code></pre>
<p>Open <code>http://localhost:3000</code>, sign in, and describe an app. That's it! 🎉</p>
<p><strong>Note:</strong> The app supports GitHub sign in, but it also has a simple dev login so you can try it out before creating a GitHub OAuth app. Don't worry, the dev login is always disabled in production.</p>
<h2 id="heading-core-components-in-the-application">Core Components in the Application</h2>
<p>The project is huge. Walking through every single line would turn this into an hours long read, so instead I'll focus on the core components that actually make the system work. Things like the dashboard, sign in, and the code editor are pretty standard stuff, so I'll skip those.</p>
<p><strong>Note:</strong> This means that the code snippets below are trimmed down to the important parts. You can find the complete code in the repository.</p>
<h3 id="heading-starting-a-sandbox-fast">Starting a Sandbox Fast</h3>
<p>Every generated app starts from the same template: Vite, React, TypeScript, Tailwind, and a few common libraries. The naÏve way of doing it looks something like this for every new project:</p>
<ol>
<li><p>Create a fresh sandbox</p>
</li>
<li><p>Upload the template</p>
</li>
<li><p>Run <code>npm install</code></p>
</li>
<li><p>Start the dev server</p>
</li>
</ol>
<p>And that works, but it took <strong>33.4 seconds</strong> before the preview was even reachable in my tests. Most of that (about 23 seconds) was just <code>npm install</code> running on a single vCPU. I don't know about you, but I'm not staring at a spinner for that long every time I start a new project.</p>
<p>The fix is to do all of that <strong>once</strong>, and save the result as a memory snapshot. A memory snapshot captures the files, the RAM, and the running processes. So when you restore it, the dev server is already running. Nothing has to boot again.</p>
<p>Here's the core of the base snapshot builder:</p>
<pre><code class="language-ts">export async function buildBaseSnapshot(log: Logger) {
  // Cold path, done once: create, upload template, npm install, git init,
  // verify it builds, start the dev server and warm up Vite's cache.
  const { ps } = await coldCreateFromTemplate({
    name: `base-${Date.now()}`,
    log,
    verify: true,
  });

  try {
    // Memory checkpoint: files + RAM + running processes.
    const snapshotId = await ps.checkpoint();
    writeBaseSnapshot({ snapshotId, createdAt: new Date().toISOString() });
  } finally {
    await ps.terminate();
  }
}
</code></pre>
<p>With that in place, creating a sandbox for a new project is just a restore, and then we lock it down:</p>
<pre><code class="language-ts">static async createFromSnapshot(opts: { snapshotId: string; name: string; log: Logger }) {
  const sb = await Sandbox.create({ snapshotId: opts.snapshotId, name: opts.name, timeoutSecs: 600 });
  const ps = new ProjectSandbox(sb, opts.log);

  await sb.update({
    exposedPorts: [5173],              // the Vite dev server, reachable through the proxy
    allowUnauthenticatedAccess: false, // the port URL is never public
    network: {
      allowInternetAccess: true,
      allowOut: ["registry.npmjs.org"], // npm and nothing else
      denyOut: [],
    },
  });
  return ps;
}
</code></pre>
<p>A few things worth noting here:</p>
<ul>
<li><p><code>exposedPorts</code> makes the dev server reachable through the provider's proxy, but only if you have our API key. We'll use that later in the gateway.</p>
</li>
<li><p>The <code>network</code> block is an allow list. Once <code>allowOut</code> has an entry in it, everything else is blocked.</p>
<p>💁 I actually tested this with <code>example.com</code>, <code>github.com</code>, and the cloud metadata IP, and all of them were blocked while npm still worked just fine.</p>
</li>
<li><p>There are no secrets in the sandbox environment at all. No LLM keys, no database URL, no AWS keys, nothing. So even if a generated app or some prompt injection tries to steal something, there's simply nothing in there to steal.</p>
</li>
</ul>
<p>Here are the numbers I got, measured all the way until the preview actually loads:</p>
<table>
<thead>
<tr>
<th>Path</th>
<th>Time</th>
</tr>
</thead>
<tbody><tr>
<td>Cold (create, install, start dev server)</td>
<td>33.4s</td>
</tr>
<tr>
<td>Restore from the memory snapshot</td>
<td>4.0s</td>
</tr>
<tr>
<td>Wake a sleeping sandbox</td>
<td>2.4s</td>
</tr>
</tbody></table>
<p>That's roughly 8 times faster. How cool is that? 😎</p>
<h3 id="heading-the-agent-loop">The Agent Loop</h3>
<p>The agent loop is the brain of the entire system. Every time a user sends a prompt, this is what runs.</p>
<p>The idea is simple even if the implementation isn't. You give the LLM a goal and some tools, let it call them, feed the results back, and repeat until it's done.</p>
<p>These are the tools the agent gets:</p>
<ul>
<li><p><code>list_files</code>, <code>read_file</code>, <code>write_file</code>, <code>edit_file</code>, and <code>delete_file</code></p>
</li>
<li><p><code>run_command</code> for quick checks like <code>npx tsc --noEmit</code></p>
</li>
<li><p><code>install_packages</code> for adding npm packages</p>
</li>
<li><p><code>get_dev_server_logs</code> and <code>get_browser_errors</code> for debugging</p>
</li>
<li><p><code>finish</code>, which the agent calls when it thinks it's done</p>
</li>
</ul>
<p>Each tool is just a small file with a Zod schema and a <code>run</code> function. Here's <code>edit_file</code> for example:</p>
<pre><code class="language-ts">export const editFile = defineTool({
  name: "edit_file",
  description:
    "Replace one exact snippet in a file. `search` must match exactly and occur exactly once.",
  schema: z.object({
    path: z.string(),
    search: z.string().min(1),
    replace: z.string(),
  }),
  async run({ path, search, replace }, { sandbox }) {
    const text = await sandbox.readFile(path);
    const count = text.split(search).length - 1;
    if (count !== 1) {
      return {
        isError: true,
        content: `search text occurs ${count} times in ${path}`,
      };
    }
    await sandbox.writeFile(
      path,
      text.replace(search, () =&gt; replace),
    );
    return { content: `Edited ${path}`, changedFiles: [path] };
  },
});
</code></pre>
<p>The Zod schema is doing two jobs here. It gets converted to JSON Schema for the LLM (with <code>z.toJSONSchema</code>), and it also validates whatever the model sends back before anything touches the sandbox. If the input is invalid, the error just goes back to the model instead of crashing the whole run.</p>
<p>Then the actual loop runs:</p>
<pre><code class="language-ts">while (true) {
  if (signal.aborted) return result("cancelled");

  const res = await llm.chat({
    system: SYSTEM_PROMPT,
    messages,
    tools,
    signal,
    onText,
  });
  messages.push(res.message);

  const results = [];
  for (const call of res.message.toolCalls) {
    const out = await executeTool(call.name, call.input, ctx); // validate + run
    results.push({
      type: "tool_result",
      toolCallId: call.id,
      content: out.content,
      isError: out.isError,
    });
    if (out.finish) finishCalled = true;
  }

  if (!finishCalled) {
    messages.push({ role: "user", content: results });
    continue;
  }

  // The agent says it's done. Now we check. (next section)
}
</code></pre>
<p>The <code>llm.chat</code> call is a small wrapper I wrote that supports both Anthropic and OpenAI with the same interface, so you can switch models from a dropdown in the UI.</p>
<p>On the Anthropic side, prompt caching is turned on, and to be honest, it does most of the heavy lifting on cost. A typical turn reads over 120K tokens, and almost all of them come straight from the cache.</p>
<h4 id="heading-keeping-file-paths-safe">Keeping File Paths Safe</h4>
<p>Now this one's a bit sneaky, and I only found it because I was poking around. The sandbox file API blocks paths with <code>..</code> in them, but it happily <strong>follows symlinks</strong>. So if the generated code creates a <code>leak.txt</code> that points to <code>/etc/passwd</code>, reading <code>leak.txt</code> gives you back the password file. Not great.</p>
<p>So every file tool resolves the real path inside the sandbox before touching anything:</p>
<pre><code class="language-ts">async safePath(relPath: string) {
  const abs = resolveProjectPath(relPath); // rejects "..", absolute paths, NUL bytes
  const r = await this.sb.run("realpath", { args: ["-m", "--", abs] });
  const real = r.stdout.trim();
  if (!isInsideApp(real)) throw new SandboxPathError(relPath, "resolves outside the project");
  return real;
}
</code></pre>
<p><code>realpath -m</code> follows every symlink and tells you where the path actually points to. If that ends up outside the project folder, the call just fails.</p>
<h4 id="heading-keeping-the-context-small">Keeping the Context Small</h4>
<p>Instead of replaying every past tool call on each new prompt, every turn starts a fresh conversation with:</p>
<ul>
<li><p>a one line summary of each earlier turn (what the user asked, and what the agent did)</p>
</li>
<li><p>the file tree and the list of installed packages</p>
</li>
<li><p>the current <code>src/App.tsx</code></p>
</li>
</ul>
<p>The agent reads anything else it needs with its tools. This keeps the cost of a turn pretty much flat, even after a project has had 20 prompts.</p>
<h3 id="heading-letting-the-agent-check-its-own-work">Letting the Agent Check Its Own Work</h3>
<p>LLMs are really weird. They'll happily tell you everything works when the build is basically on fire. So when the agent calls <code>finish</code>, we don't just take its word for it. We run three checks inside the sandbox:</p>
<pre><code class="language-ts">export async function runChecks(sandbox: ProjectSandbox) {
  const tsc = await sandbox.exec("npx tsc --noEmit -p . 2&gt;&amp;1", {
    timeoutSecs: 120,
  });
  const build = await sandbox.exec(
    "npx vite build --outDir /tmp/build --emptyOutDir --logLevel error 2&gt;&amp;1",
    { timeoutSecs: 180 },
  );
  const render = await renderCheck(sandbox); // renders the app once in a fake DOM

  return {
    ok: tsc.exitCode === 0 &amp;&amp; build.exitCode === 0 &amp;&amp; render.ok,
    typecheck: { ok: tsc.exitCode === 0, output: tsc.stdout },
    build: { ok: build.exitCode === 0, output: build.stdout },
    render,
  };
}
</code></pre>
<p>The first check runs TypeScript’s type checker to catch type errors without generating any files. The second runs a full Vite production build to make sure the app can actually compile successfully.</p>
<p>The third one is where it gets interesting. A lot of bugs only show up at runtime, stuff like <code>Cannot read properties of undefined (reading 'map')</code>. The usual answer to this is a headless browser, but that's heavy and slow in a small sandbox, and I really didn't want to go down that road.</p>
<p>So instead, the template ships a tiny script that renders the app once with <a href="https://github.com/capricorn86/happy-dom"><code>happy-dom</code></a> (a fake DOM for Node.js) and Vite's <code>ssrLoadModule</code>:</p>
<pre><code class="language-js">GlobalRegistrator.register({ url: "http://localhost:5173/" });
document.body.innerHTML = '&lt;div id="root"&gt;&lt;/div&gt;';
console.error = (...args) =&gt; errors.push(args.join(" "));

await server.ssrLoadModule("/src/main.tsx"); // runs the real app entry
await new Promise((r) =&gt; setTimeout(r, 1500)); // let React render

const rendered = document.getElementById("root").innerHTML.trim().length &gt; 0;
console.log(
  JSON.stringify({ ok: rendered &amp;&amp; errors.length === 0, rendered, errors }),
);
</code></pre>
<p>It takes about 2 seconds, and it caught the <code>undefined.map</code> crash along with the exact line in <code>App.tsx</code>. Noiceee!</p>
<p>If any of the checks fail, the errors go straight back to the agent as a new message, and it gets another round to fix them:</p>
<pre><code class="language-ts">lastCheck = await runChecks(sandbox);
if (lastCheck.ok) return result("succeeded");
if (healRounds &gt;= maxHeal)
  return result("failed", { error: describeFailures(lastCheck) });

healRounds++;
messages.push({
  role: "user",
  content: [
    ...results,
    {
      type: "text",
      text: `Verification failed. Fix these problems, then call finish again.\n\n${describeFailures(lastCheck)}`,
    },
  ],
});
</code></pre>
<p>It stops after 3 rounds by default. If it's still broken after that, the user gets an honest "I couldn't finish this one" with the actual errors.</p>
<h3 id="heading-streaming-progress-to-the-browser">Streaming Progress to the Browser</h3>
<p>The agent runs in the worker, but the user is looking at the browser. So somehow every step ("Wrote <code>src/App.tsx</code>", "Ran <code>npx tsc --noEmit</code>", "Checks passed" and all) has to get from one to the other as it happens.</p>
<p>The worker writes each step as a row in a <code>run_events</code> table and then fires a Postgres <code>NOTIFY</code>:</p>
<pre><code class="language-ts">export async function appendEvent(
  db: Db,
  e: { projectId: string; runId?: string; type: string; payload: unknown },
) {
  const [row] = await db
    .insert(runEvents)
    .values(e)
    .returning({ id: runEvents.id });
  await db.$client.notify(
    "events",
    JSON.stringify({ projectId: e.projectId, id: row.id }),
  );
  return row.id;
}
</code></pre>
<p>On the web side, a Next.js route handler streams these events to the browser with Server Sent Events. When the browser connects, it first replays everything from the active run, and then it just keeps listening for new rows:</p>
<pre><code class="language-ts">const pump = async () =&gt; {
  const rows = await db
    .select()
    .from(runEvents)
    .where(and(eq(runEvents.projectId, project.id), gt(runEvents.id, cursor)))
    .orderBy(asc(runEvents.id));

  for (const r of rows) {
    cursor = r.id;
    send(`id: ${r.id}\ndata: ${JSON.stringify(r)}\n\n`);
  }
};

bus.on(project.id, pump); // fired by a single LISTEN connection per process
pump(); // replay first
</code></pre>
<p>Since all the events live in the database, refreshing the page in the middle of a run doesn't lose anything. The browser reconnects, replays the run so far, and continues from where it left off. The Stop button works the same way, just in reverse. The web app sends a <code>NOTIFY</code> with the run ID, and whichever worker is holding that run aborts it.</p>
<p><strong>Note:</strong> Streamed text from the LLM comes in token by token, and saving every token would mean hundreds of rows per turn. So the worker buffers it and writes one row every 250ms instead. I also had a small bug here where events landed out of order because each write was its own promise. Pushing every write through a single promise chain fixed it.</p>
<h3 id="heading-the-live-preview-gateway">The Live Preview Gateway</h3>
<p>The generated app's dev server runs inside the sandbox on port 5173, and the provider exposes it at a URL like <code>https://5173-&lt;sandbox-id&gt;.sandbox.example</code>. Now, you could just put that URL in an iframe and call it a day, but there are two problems with that:</p>
<ol>
<li><p>To load it, the browser would need our sandbox API key. Yeah, that's clearly not happening.</p>
</li>
<li><p>And if we made it public instead, anyone who guessed the URL could open it, and we'd have no control over who sees what.</p>
</li>
</ol>
<p>So we put our own small gateway in front of it. It maps <code>&lt;project-id&gt;.preview.localhost:4000</code> to the right sandbox and adds the API key on the server side:</p>
<pre><code class="language-ts">const proxy = createProxyServer({ changeOrigin: true, secure: true, ws: true });

const server = http.createServer(async (req, res) =&gt; {
  const projectId = HOST_RE.exec(req.headers.host ?? "")?.[1];
  const target = await resolve(projectId); // project -&gt; sandbox, cached for a few seconds
  if (!target.sandboxId) return send(res, noPreviewPage());

  delete req.headers.cookie; // never forward the visitor's credentials
  delete req.headers.authorization;
  proxy.web(req, res, {
    target: previewUrlFor(target.sandboxId),
    headers: { authorization: `Bearer ${API_KEY}` },
  });
});

// Vite's hot reload runs over a WebSocket, so upgrades get proxied too.
server.on("upgrade", async (req, socket, head) =&gt; {
  const target = await resolve(HOST_RE.exec(req.headers.host ?? "")?.[1]);
  proxy.ws(req, socket, head, {
    target: previewUrlFor(target.sandboxId).replace(/^https/, "wss"),
    headers: { authorization: `Bearer ${API_KEY}` },
  });
});
</code></pre>
<p>That <code>upgrade</code> handler is what makes the preview feel alive. Whenever the agent edits a file, Vite pushes the change over the WebSocket, and the preview updates without a reload.</p>
<p>There's also another reason for having the gateway that's pretty easy to miss. The preview runs on a <strong>different origin</strong> (<code>*.preview.localhost</code>) than the main app (<code>localhost:3000</code>). So a generated app can never read the main app's cookies or call its API as the logged in user. And since <code>*.localhost</code> resolves to <code>127.0.0.1</code> in modern browsers, you don't even need to touch your hosts file for this.</p>
<p><strong>Note:</strong> The template also injects a tiny script into the preview that listens for <code>window.onerror</code>, and posts them to the parent window. The workspace then forwards those to the backend, and that's where the agent's <code>get_browser_errors</code> tool gets real runtime errors from.</p>
<h3 id="heading-versions-with-git">Versions with Git</h3>
<p>Every finished prompt becomes a version. So, the user can pretty much revert to a specific "prompt", more like <code>git reset</code>.</p>
<p>For this, plain old Git inside the sandbox works great. The base snapshot already has a repo with the template as the first commit, and after each successful turn, the worker commits everything:</p>
<pre><code class="language-ts">export async function commitAll(ps: ProjectSandbox, message: string) {
  const out = await git(
    ps,
    `git add -A
if git diff --cached --quiet; then
  echo NOCHANGE
else
  git commit -q -m "$MSG"
  git rev-parse HEAD
  git show --name-only --format= HEAD
fi`,
    { MSG: message },
  );
  if (out.trim() === "NOCHANGE") return null;
  const [sha, ...files] = out.trim().split("\n");
  return { sha, files };
}
</code></pre>
<p><strong>Note:</strong> You might be wondering why there's no <code>exit 0</code> in there. Commands run under <code>bash -l</code>, and an explicit <code>exit</code> in a login shell runs <code>~/.bash_logout</code>, whose last command failed on this image and turned my exit code into a failure. This one honestly took me way longer to figure out. 😭</p>
<p>Restoring never rewrites history. It makes the files match the old commit exactly, and then commits that as a brand new version on top. So "restore version 1" creates version 5, and you can still go back to version 4 whenever you want. Nothing gets lost.</p>
<h4 id="heading-keeping-the-history-durable-without-tokens-in-the-sandbox">Keeping the History Durable (Without Tokens in the Sandbox)</h4>
<p>Git inside the sandbox is great, but it only lives as long as the sandbox does. So after each version, the history also gets pushed to a hosted Git repository.</p>
<p>The straightforward way to do this would be to give the sandbox a Git token and just run <code>git push</code>. But the tokens I had access to were scoped to the <strong>whole project</strong>, not a single repo. Putting one of those inside a sandbox full of untrusted code? Yeah, no thanks.</p>
<p>So the sandbox never pushes anything. It creates a <code>git bundle</code> (basically the entire repo in a single file), the worker reads that file out, and the worker does the push itself:</p>
<pre><code class="language-ts">export async function pushToHostedGit(
  ps: ProjectSandbox,
  repo: string,
  dataDir: string,
) {
  await git(ps, "git bundle create -q /tmp/repo.bundle main");
  const bundle = await ps.sb.readFile("/tmp/repo.bundle");

  const mirror = join(dataDir, "git", `${repo}.git`); // a bare repo on the worker
  writeFileSync(join(mirror, "incoming.bundle"), bundle);
  await run("git", [
    "-C",
    mirror,
    "fetch",
    "-q",
    "--force",
    "incoming.bundle",
    "+refs/heads/main:refs/heads/main",
  ]);

  const cred = await repos.credential(repo); // short lived token, only on the worker
  await run("git", [
    "-C",
    mirror,
    "-c",
    `http.extraHeader=Authorization: Basic ${basic(cred)}`,
    "push",
    "-q",
    "--force",
    url,
    "main",
  ]);
}
</code></pre>
<p>A nice side effect of this is that it also powers <strong>remix</strong>. When someone copies a shared project, the worker loads the source project's bundle from the hosted repo into a brand new sandbox, and the original sandbox doesn't even have to wake up for it.</p>
<h3 id="heading-sleeping-waking-and-sharing-the-sandboxes">Sleeping, Waking, and Sharing the Sandboxes</h3>
<p>A running sandbox costs money even when nobody's working on it. So there's a small job that runs every minute and suspends any sandbox that hasn't had an agent run for 10 minutes. Suspending keeps the memory, so the dev server comes back exactly as it was.</p>
<p>Waking up happens in the gateway. If someone opens the preview of a sleeping project, the gateway shows a small "Waking up your app..." page that keeps refreshing itself, and resumes the sandbox in the background:</p>
<pre><code class="language-ts">if (isPage &amp;&amp; target.status === "suspended") {
  const outcome = wake(projectId, target, log); // deduplicated per project
  const done = await Promise.race([outcome, timeout(4000, "pending")]);
  if (done === "busy") return send(res, busyPage());
  if (done !== "running") return send(res, wakingPage()); // auto refreshes
}
</code></pre>
<p>In my tests, a sleeping sandbox woke up in about 3 seconds, and the app loaded right after. 🎊</p>
<h4 id="heading-sharing-a-limited-number-of-sandboxes">Sharing a Limited Number of Sandboxes</h4>
<p>Most sandbox providers limit how many sandboxes you can run at the same time, especially on a free plan. So I added a small concurrency checker, and it all happens in Postgres with an advisory lock, so two workers never end up making the same decision at the same time:</p>
<pre><code class="language-ts">async function decide(db: Db, projectId: string) {
  return db.transaction(async (tx) =&gt; {
    await tx.execute(sql`select pg_advisory_xact_lock(${LOCK_KEY})`);

    const live = await runningSandboxesOldestFirst(tx);
    if (live.some((s) =&gt; s.projectId === projectId)) return { kind: "ok" };
    if (live.length &lt; concurrencyLimit()) return { kind: "ok" };

    // Full. Free a slot by suspending the least recently used idle sandbox.
    const victim = live.find((s) =&gt; !busyProjects.has(s.projectId));
    if (victim) return { kind: "evict", sandboxId: victim.sandboxId };

    return { kind: "wait", position }; // everything is busy, just wait...
  });
}
</code></pre>
<p>To test this, I set the limit to 1 and sent a prompt to one project while another project's sandbox was just sitting idle. The idle one went to sleep, and the new one took its slot. Then I sent a prompt to the first project while the second was still working, and the UI showed "Waiting for a free sandbox, #1 in line" until the slot freed up. If you have a bigger plan, you just raise the limit in <code>.env</code> and nothing else changes.</p>
<h3 id="heading-publishing-the-app">Publishing the App</h3>
<p>The preview is great while you're building, but you don't want your published app to depend on a sandbox that goes to sleep every 10 minutes. 🫩</p>
<p>So publishing builds the app once and turns it into plain static files:</p>
<pre><code class="language-ts">export async function publish(project: Project) {
  const ps = await projectSandbox(project.id); // wakes it if needed
  const build = await ps.exec(
    "rm -rf dist &amp;&amp; npx vite build --outDir dist --emptyOutDir 2&gt;&amp;1",
    { timeoutSecs: 180 },
  );
  if (build.exitCode !== 0)
    throw new HttpError(422, `The build failed:\n${build.stdout.slice(-1500)}`);

  const files = await listFiles(ps, "dist");
  const prefix = `published/${slug}/${versionId}/`;

  for (const f of files) {
    const bytes = await ps.readBytes(`dist/${f}`);
    await storage.put(prefix + f, bytes, contentTypeFor(f), cacheControlFor(f));
  }

  await savePublishedSite({ projectId: project.id, slug, s3Prefix: prefix });
  return { url: publishedUrl(slug) };
}
</code></pre>
<p>The gateway then serves those files from S3 at <code>http://&lt;slug&gt;.app.localhost:4000</code>. Files that Vite hashes (like <code>assets/index-CScgwd68.js</code>) get cached forever, and <code>index.html</code> always gets revalidated, so updates show up right away. Any path without a file extension falls back to <code>index.html</code>, so client side routing works as well.</p>
<p>And published sites get their own subdomain on purpose. If they lived under the main app's domain, a published app's JavaScript could call your API with the cookies of whoever is looking at it, and you really don't want that. 😺</p>
<p>In my tests, publishing took about 6 seconds, and the published site loaded in 4ms with the sandbox asleep, because it never touches the sandbox at all.</p>
<h2 id="heading-app-builder-in-action">App Builder in Action</h2>
<p>Here's a quick demo of the app builder in action:</p>
<div class="embed-wrapper"><iframe width="560" height="315" src="https://www.youtube.com/embed/hE96nLJo_fc" style="aspect-ratio: 16 / 9; width: 100%; height: auto;" title="YouTube video player" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen="" loading="lazy"></iframe></div>

<p>I also ran a small eval out of curiosity. It sends the same three prompts (a habit tracker, a kanban board, and an expense dashboard with charts) to two different models, each in a fresh sandbox:</p>
<table>
<thead>
<tr>
<th>Model</th>
<th>Passed (build + render)</th>
<th>Avg time</th>
</tr>
</thead>
<tbody><tr>
<td>Claude Sonnet 5</td>
<td>3/3</td>
<td>135s</td>
</tr>
<tr>
<td>GPT 5.5</td>
<td>3/3</td>
<td>92s</td>
</tr>
</tbody></table>
<p>All six apps built and rendered on the first check, without needing a single fix round, which honestly surprised me a bit. On Claude, each app cost somewhere around $0.15 to $0.20.</p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>So, what do you think of the project? This was truly one of the most fun projects I've worked on in a while since this AI stuff has taken over raw coding. 🤦‍♂️</p>
<p>When you use tools like Lovable, it's easy to think it's all about the prompt and the model. But once you build one yourself, you realize most of the work is everything around the model, like where the code runs, how fast it starts, how you show it to the user, and how you keep it from doing something it shouldn't.</p>
<p>If there's one thing I'd want you to take away from this, it's the sandbox part. Treat AI generated code as untrusted, give it its own small machine with no secrets and almost no network access. Memory snapshots and suspend/resume then take care of making it fast and cheap.</p>
<p>There's still a lot of room to extend this. You could add a small backend (like Hono and SQLite) to the template so users can build full stack apps, let users click an element in the preview to edit exactly that component, or generate a few design variations of the same prompt side by side. I'm just too exhausted to implement that right now. I'll leave it up to you. ✌️</p>
<p>The foundation is there. The rest is just building on top of it.</p>
<p>You can find the complete source code here: <a href="https://github.com/shricodev/lovable-build-tensorlake-aws">shricodev/lovable-build-tensorlake-aws</a></p>
<p>So, that's it for this article. Thank you so much for reading! See you next time. 🫡</p>
 ]]>
                </content:encoded>
            </item>
        
    </channel>
</rss>
