Docs navigation
Use case

Ship a live app with a preview URL

A coding agent builds a small web app inside a sandbox, serves it, and hands you a live URL to open in a browser. A second message edits the running app on the same sandbox, and the same URL keeps serving the updated version. There is no deploy step and no hosting to set up.

You'll need an API key
Create one at app.brainbaselabs.com and export it as BRAINBASE_API_KEY. The examples are Python; install the one dependency with pip install requests. Every call is plain HTTP, so any language works. See Authentication for the header format.

What you'll build

  • A sandbox running a single-page TODO app served on port 3000.
  • A preview URL that opens the running app in your browser.
  • A second turn that restyles the app and adds a header, served from the same URL.

Set up the shared bits once. Every snippet below reuses BASE, HEADERS, and PORT.

python
import os, time
import requests

BASE = "https://api.brainbaselabs.com"
HEADERS = {"Authorization": f"Bearer {os.environ['BRAINBASE_API_KEY']}"}
TERMINAL = {"success", "fail", "need_more_info", "idle"}
PORT = 3000

1. Build and serve the app

One POST /v2/threads describes the agent and gives it the build prompt. The prompt asks for a self-contained index.html, then tells the agent to serve it in the background and leave the server running so the port stays open after the turn ends.

python
resp = requests.post(
    f"{BASE}/v2/threads",
    headers=HEADERS,
    json={
        "agent": {
            "harness": "claude_code",
            "model": "claude-sonnet-5",
            "instructions": "You build tiny web apps and keep their dev server running.",
        },
        "input": (
            "Build a single-page TODO app (add, toggle, delete) as one index.html "
            "with plain HTML/CSS/JS, no build step. Then serve the current "
            f"directory on port {PORT} bound to 0.0.0.0 in the background:\n"
            f"  nohup python3 -m http.server {PORT} --bind 0.0.0.0 >/tmp/srv.log 2>&1 &\n"
            f"Confirm it is listening with: curl -s localhost:{PORT} | head. "
            "Leave the server running."
        ),
    },
)
resp.raise_for_status()
thread_id = resp.json()["thread_id"]

def wait(thread_id, poll_s=5, timeout_s=600):
    """Poll until the current turn settles into a terminal status."""
    deadline = time.time() + timeout_s
    while time.time() < deadline:
        thread = requests.get(f"{BASE}/v2/threads/{thread_id}", headers=HEADERS).json()
        if thread["status"] in TERMINAL:
            return thread
        time.sleep(poll_s)
    raise TimeoutError("thread did not settle")

wait(thread_id)  # let the agent finish building and start the server

Want to watch it build live instead of polling? Stream GET /v2/threads/{thread_id}/events/stream and read until the idle event, as shown in Fix a failing test suite.

2. Get the preview URL

A preview maps a port inside the sandbox to a URL you can open. Fetch the machine_id from the thread, then resolve the preview for the port your app is serving on.

python
thread = requests.get(f"{BASE}/v2/threads/{thread_id}", headers=HEADERS).json()
machine_id = thread["machine_id"]
print("machine:", machine_id)  # keep this to tear the sandbox down later

pv = requests.get(
    f"{BASE}/v2/machines/{machine_id}/preview",
    headers=HEADERS, params={"port": PORT},
).json()
print("open in a browser:", pv["url"])

# A raw provider URL is token-gated; send the token header to reach it.
preview_headers = {}
if pv.get("token") and pv.get("token_header"):
    preview_headers = {pv["token_header"]: pv["token"]}

page = requests.get(pv["url"], headers=preview_headers, timeout=30)
print("preview responded:", page.status_code)
Token-gated vs public preview URLs
The preview URL may be public or token-gated, depending on the deployment's preview-domain setup. On api.brainbaselabs.com a branded *.brainbaselabs.space URL comes back that anyone can open, with token and token_header returned as null. On a deployment without a branded domain the URL is token-gated instead: send the token_header to open it, or a browser request without it redirects to auth. The code above checks for a token and handles both.

3. Edit it on a second turn

Send a follow-up message with run: true and the agent edits the app on the same sandbox. Because the server keeps running, the same preview URL now serves the updated page.

python
def get_messages(thread_id):
    r = requests.get(f"{BASE}/v2/threads/{thread_id}/messages",
                     headers=HEADERS, params={"limit": 200})
    r.raise_for_status()
    return r.json()["items"]

def run_and_wait(thread_id, content, poll_s=5, timeout_s=600):
    """Append a message, start its turn, and wait for THAT turn to finish."""
    before = len(get_messages(thread_id))
    deadline = time.time() + timeout_s
    # The turn may still be settling when it goes idle, so the POST can
    # briefly return 409. Retry it until the message is accepted.
    while True:
        resp = requests.post(
            f"{BASE}/v2/threads/{thread_id}/messages", headers=HEADERS,
            json={"messages": [{"role": "user", "content": content}], "run": True},
        )
        if resp.status_code != 409:
            resp.raise_for_status()
            break
        if time.time() > deadline:
            raise TimeoutError("follow-up POST kept returning 409")
        time.sleep(1.5)
    while time.time() < deadline:
        thread = requests.get(f"{BASE}/v2/threads/{thread_id}", headers=HEADERS).json()
        if len(get_messages(thread_id)) > before + 1 and thread["status"] in TERMINAL:
            return thread
        time.sleep(poll_s)
    raise TimeoutError("follow-up turn did not settle")

run_and_wait(thread_id, (
    "Give the app a dark theme and add a header that reads 'Brainbase TODO'. "
    "Keep the same server running on the same port."
))

after = requests.get(pv["url"], headers=preview_headers, timeout=30)
print("same URL still live:", after.status_code)
print("edit landed:", "Brainbase TODO" in after.text)
Follow-ups need run true, then wait for the new turn
A follow-up message only starts a turn when the body includes run: true. Two timing wrinkles: the POST can briefly return 409 while the previous turn is still settling (so run_and_wait retries it), and once it is accepted the thread can still report the previous turn's terminal status for a moment (so waiting on status alone returns too early). run_and_wait retries the POST, then waits until the transcript grows and the status is terminal, so the edit has really landed before you re-fetch the page.

4. Clean up

The sandbox stops on its own when idle. To free it immediately, delete its machine.

python
requests.delete(f"{BASE}/v2/machines/{machine_id}", headers=HEADERS).raise_for_status()
Use machine_id, not sandbox_id
DELETE /v2/machines/{id} expects the Brainbase machine_id, the same one you used to resolve the preview. The thread also carries a provider-side sandbox_id; the two can look identical depending on the provider, so use the machine_id field, not sandbox_id.