# Build a Stateful Code Interpreter with a Jupyter Kernel

> Install ipykernel and jupyter_client in a venv inside a Sandboxer sandbox, start the kernel with sbx exec --detach, and send each cell through a small client script. Variables persist between calls. In our test the same kernel and its variables survived sbx stop and sbx resume; a cold boot loses them, so check the kernel after resuming.

Source: https://sandboxer.com/guides/build-a-stateful-code-interpreter-with-jupyter. Updated 2026-10-27.

**Short answer:** Install ipykernel and jupyter_client in a venv inside a Sandboxer sandbox, start the kernel with sbx exec --detach, and send each cell through a small client script. Variables persist between calls. In our test the same kernel and its variables survived sbx stop and sbx resume; a cold boot loses them, so check the kernel after resuming.

## What you build

A code interpreter tool for an LLM is a long-lived Python process that runs one snippet at a time and keeps its namespace. A Jupyter kernel is that process, and the [Jupyter messaging protocol](https://jupyter-client.readthedocs.io/en/latest/messaging.html) gives you stdout, results and tracebacks for each cell. You run the kernel inside the sandbox, so the model's code never runs on your machine.

| Piece | Where it runs | What it does |
| - | - | - |
| `ipykernel` | Sandbox, detached | Holds the variables, imports and open files |
| `~/k.json` | Sandbox | Connection file: ports and the signing key. Created mode `0600` |
| `kexec.py` | Sandbox, one run per cell | Sends one cell, prints its output and status |
| `sbx exec` | Your app | Runs `kexec.py` with the model's code |

Jupyter isn't preinstalled. The base image has Python 3.12 and `uv`.

## 1. Install the kernel

```bash
sbx new --name interpreter --type small
id=sb_ugjwy66ac2    # your sandbox id from `sbx new`
sbx exec $id "python3 -m venv ~/jk && ~/jk/bin/pip install -q ipykernel jupyter_client && \
  ~/jk/bin/python -c 'import ipykernel, jupyter_client; print(ipykernel.__version__, jupyter_client.__version__)'"
```

```text
7.4.0 8.10.0
```

That took 13 seconds on a `small` sandbox (single run, October 27, 2026). The venv keeps the kernel's packages apart from the system Python.

## 2. The client script

`kexec.py` connects to the running kernel, executes one cell and prints what a notebook would show. Save it on your machine:

```python
import os, sys
from jupyter_client import BlockingKernelClient

kc = BlockingKernelClient(connection_file=os.path.expanduser("~/k.json"))
kc.load_connection_file()
kc.start_channels()
kc.wait_for_ready(timeout=10)

def show(msg):
    c = msg["content"]
    t = msg["msg_type"]
    if t == "stream":
        sys.stdout.write(c["text"])
    elif t in ("execute_result", "display_data"):
        print(c["data"].get("text/plain", ""))
    elif t == "error":
        print(f"{c['ename']}: {c['evalue']}")

reply = kc.execute_interactive(sys.argv[1], output_hook=show, timeout=60)
print(f"[{reply['content']['status']} #{reply['content']['execution_count']}]")
```

The last line prints `ok` or `error` and the kernel's execution counter, which tells your app whether the cell failed and whether it is still talking to the same kernel.

## 3. Start the kernel and run cells

```bash
sbx scp ./kexec.py $id:/home/user/
sbx exec $id --detach "~/jk/bin/python -m ipykernel_launcher -f ~/k.json"   # "pid":578

run() { sbx exec $id "~/jk/bin/python kexec.py \"$1\""; }
run "x = 41"
run "print(x + 1)"
run "import pandas as pd"
run "import os, time; pid = os.getpid(); started = time.time(); data = [i * i for i in range(10)]; pid"
run "sum(data)"
run "1/0"
run "x"
```

```text
[ok #1]
42
[ok #2]
ModuleNotFoundError: No module named 'pandas'
[error #3]
578
[ok #4]
285
[ok #5]
ZeroDivisionError: division by zero
[error #6]
41
[ok #7]
```

Errors don't reset anything: after the `ZeroDivisionError`, `x` is still 41. To add a package mid-session, install it into the venv and import it in the next cell. The running kernel picks it up without a restart:

```bash
sbx exec $id "~/jk/bin/pip install -q humanize && ~/jk/bin/python kexec.py \"import humanize; humanize.naturalsize(10**9)\""
```

```text
'1.0 GB'
[ok #8]
```

### Per-call latency

In our single test run (October 27, 2026), each `run` call took 0.63 to 0.99 seconds from our machine. An empty `sbx exec $id "true"` took 0.25 seconds, and `kexec.py` alone took 0.64 seconds inside the sandbox, mostly Python start-up and connecting to the kernel. If that matters, keep one client process connected in the sandbox and feed it cells, instead of starting `kexec.py` per cell.

## 4. Stop, resume and check the kernel

```bash
sbx stop $id --wait     # returns once the sandbox is archived
sbx resume $id          # 1.38 s to ready (single run, October 27, 2026)
run "x"
run "print(x + 1)"
run "sum(data), pid, round(time.time() - started)"
sbx exec $id --status 578 | jq -c '{status,running}'
```

```text
41
[ok #10]
42
[ok #11]
(285, 578, 193)
[ok #12]
{"status":"running","running":true}
```

The memory was restored in this run. It was the same kernel process (PID 578), its counter carried on from #9 to #10, and `x`, `data` and the imports were still there. We ran a second stop and `sbx resume $id --require-memory`, and `x, pid` returned `(41, 578)` again.

**What `memoryRestored` said:** `null`, on both resumes in our run on 2026-10-27, when the field wasn't yet set on a successful restore. Since a fix that shipped later that night, every resume reports `memoryRestored: true` or `false`, with a `resumeReason` (`cpu_mismatch`, `resized`, `no_memory_snapshot` or `restore_failed`) when it's `false`. Checking the kernel's process and execution counter, as above, still works either way. Memory is restored only on a machine with the same CPU type: if none is free within 2 minutes, the resume boots from disk on a different one (see [snapshots](/docs/snapshots#resume)). The files are kept, but processes are lost, so your kernel and its variables are gone. Pass `--require-memory` if you'd rather wait (up to 30 minutes) than boot from disk.

## When the kernel doesn't survive

We forced a cold boot by resuming at a new size (`sbx resume $id --type default`). In our run the `memoryRestored` field was `null` here too (it now reports `false` with `resumeReason: "resized"`), but the kernel was gone:

```text
$ sbx exec $id --status 578 | jq -c '{status,running}'
{"status":"lost","running":false}
$ sbx exec $id "~/jk/bin/python kexec.py x"
RuntimeError: Kernel died before replying to kernel_info
```

`~/k.json`, `kexec.py` and the venv were all still on disk. So don't rely on the field alone: after every resume, run a cheap cell and compare the execution counter. If it starts again at `#1`, or the call fails, the namespace is empty. Your app then has to rebuild it, for example by replaying the cells it has logged, or by having the model save important results to files.

To have a kernel come back on its own after a cold boot, run it as a systemd service. Enabled services start on boot; hand-started processes don't:

```bash
sudo tee /etc/systemd/system/kernel.service >/dev/null <<'UNIT'
[Unit]
Description=Jupyter kernel for the code interpreter
After=network-online.target

[Service]
User=user
WorkingDirectory=/home/user
ExecStart=/home/user/jk/bin/python -m ipykernel_launcher -f /home/user/k.json
Restart=always

[Install]
WantedBy=multi-user.target
UNIT
sudo systemctl daemon-reload && sudo systemctl enable --now kernel.service
```

After a `sudo reboot` inside the sandbox, `systemctl is-active kernel.service` printed `active` and `kexec.py` connected with the same `~/k.json`. As expected, the namespace was new: `y` returned `NameError` at `[error #1]`. A service brings back the kernel, not its variables. Only a memory-restoring resume keeps those.

## FAQ

### Do I need Jupyter Server or Kernel Gateway?

No. `ipykernel` and `jupyter_client` are enough when your app reaches the kernel through `sbx exec` or the API. A gateway is only useful if you want to reach the kernel over HTTP. If you do, host its port as private, not public, since anyone who reaches a kernel can run code.

### Is the kernel's unencrypted TCP warning a problem?

The kernel logs that it runs over TCP without encryption. ipykernel listens on `127.0.0.1` by default and the client runs in the sandbox too, so the traffic stays inside the VM. Messages are signed with the key in `~/k.json`, which is readable only by `user`.

### What does a paused interpreter cost?

Nothing for compute. Billing is per second while a sandbox runs; a stopped one's disk and memory image count toward your plan's [included storage](/docs/billing#storage). An idle sandbox also pauses itself with memory after your plan's idle time, and the next `sbx exec` wakes it (see [long-running tasks](/docs/long-running#idle-auto-pause-and-wake-on-use)).

### Can I give each user their own interpreter?

Yes. Create one sandbox per user or per conversation, each with its own kernel. Each sandbox is a separate microVM, so one user's code can't see another's variables or files.

## Related

- [Pause and resume a cloud sandbox with its memory](/guides/pause-and-resume-a-sandbox-with-memory)
- [Run a long job in a cloud sandbox](/guides/run-a-long-job-in-a-cloud-sandbox)
- [Snapshots, stop and resume](/docs/snapshots)
- [Long-running tasks](/docs/long-running)
