For the more demanding crowd, a coding agent setup only earns its keep when it’s secure (meaning your data doesn’t egress) and safe (neither does it destroy your data). Two things have to be true for that to hold: the AI model has to run locally, and the agent must be isolated so that a stray command can’t wander off across your home directory. On Ubuntu, you don’t need to worry about either case.
Here, you will run OpenCode, an open-source, terminal-based AI coding agent, inside a workshop (an ephemeral, container-isolated dev environment) wired to the gemma4 inference snap running on your host. A workshop seals its contents off from the rest of the machine: the only way anything inside it reaches the host is through an explicit interface connection, or via the project directory where the workshop runs. We declare exactly one custom connection (a tunnel to the model) and leave the rest of the wall standing.
By the end, you will have:
- The
gemma4snap serving an OpenAI-compatible API on the host’slocalhost. - A workshop running OpenCode as an SDK (that’s what we chose to call Workshop’s distribution format), sealed off from everything else on the host.
- A single tunnel bridging the two, so the boxed-in agent reaches the host model at the same
localhost:8336it would use on bare metal. - A finished coding task delivered to your host, the agent’s work still there after the workshop is gone, and no precious token budget spent to get it. FizzBuzz stands in for that work here, kept trivial so the focus stays on the setup rather than the code; the same flow scales to any real project you’d point the agent at.
We’ll take the shortest path that works first, then open up what a workshop can actually do once it’s running.
Total time: ~15 minutes (plus driver install and reboot, if needed).
Prerequisites
sudorights for the snap installs- A working GPU driver if you want speed. NVIDIA needs version 575 or newer; the snap falls back to a
cpuengine otherwise (slower, but functional). The driver how-to has the details. jqfor the smoke tests- No cloud key. That’s the entire point.
References used throughout:
Step 1: Install the gemma4 snap
This runs on the host, not in the workshop. The model wants your GPU, and the GPU lives on the host. (Workshop can handle that as well, but that’s a different story.)
sudo snap install gemma4
The snap registers two services that start automatically: an OpenAI-compatible API (gemma4.server) and a browser UI (gemma4.server-webui).
Verify:
gemma4 status
engine: nvidia-gpu
services:
server: active
server-webui: active
endpoints:
openai: http://localhost:8336/v1
webui: http://localhost:8337/
model:
name: gemma4-e4b-q4-k-m
Note the openai: URL and the model: name; you need both for later. Now confirm the API actually answers:
curl -s http://localhost:8336/v1/models | jq '.data'
[
{
"id": "gemma4-e4b-q4-k-m",
"aliases": [
"gemma4-e4b-q4-k-m"
],
"tags": [],
"object": "model",
"created": 1780694379,
"owned_by": "llamacpp",
"meta": {
"vocab_type": 2,
"n_vocab": 262144,
"n_ctx_train": 131072,
"n_embd": 2560,
"n_params": 7518069290,
"size": 5319465128
}
}
]
The default model, gemma4-e4b-q4-k-m, is E4B (“Efficient 4B”), a multimodal Gemma 4 that weighs about 5.3 GB on disk. It is a default that works for our purposes, so the rest of this walkthrough just uses it.
The first request loads the weights into memory, so the model can take a few seconds to wake up. To confirm it’s ready without watching for that, run gemma4 chat: it waits for the server, then opens a prompt where you type a line and read the reply back (CTRL-C to leave). Once it answers, you’re live.
Step 2: Install Workshop
Still on the host. Workshop is a classic snap and runs on top of LXD:
sudo snap refresh lxd --channel=6/stable || sudo snap install lxd --channel=6/stable
sudo snap install workshop --classic
Verify:
workshop list
An empty table (just the headers) is the expected answer on a clean install. While we’re here, confirm the OpenCode SDK exists in the SDK Store; this is the thing the workshop will install for you:
sdk find opencode
NAME VERSION PUBLISHER SUMMARY
opencode 1.15.10 Dmitry Lyfar The OpenCode SDK.
Step 3: Define the workshop
A workshop is described by a single workshop.yaml in your project directory. Create one with a shiny new Ubuntu 26.04 base:
mkdir -p ~/Documents/opencode-gemma
cd ~/Documents/opencode-gemma
cat > workshop.yaml <<'EOF'
name: demo
base: ubuntu@26.04
sdks:
- name: opencode
channel: latest/stable
plugs:
gemma4:
interface: tunnel
endpoint: localhost:8336
- name: system
slots:
gemma4:
interface: tunnel
endpoint: localhost:8336
EOF
The sdks: list pulls OpenCode into the container. The interesting part is the tunnel:
- The plug (
gemma4, on theopencodeSDK) is the side that consumes the connection, where the agent, inside the container, reaches for the API. - The slot (
gemma4, on thesystemSDK) is the side that provides it: the host service atlocalhost:8336. - Both name the same
endpoint, so the address inside the container is identical to the address on the host. Nothing to remember, nothing to remap.
Because the plug lives on a regular SDK rather than on system, this tunnel does not connect itself automatically. That’s deliberate: you wire it up explicitly in Step 6, so nothing reaches the host until you say so.
Step 4: Point OpenCode at the tunnel
OpenCode talks to any OpenAI-compatible endpoint through the @ai-sdk/openai-compatible provider. Drop its config into the project directory; that directory mounts into the workshop at /project, so the file rides along and OpenCode finds it inside the container without you ever editing anything in there:
cat > opencode.json <<'EOF'
{
"$schema": "https://opencode.ai/config.json",
"provider": {
"gemma4-local": {
"npm": "@ai-sdk/openai-compatible",
"name": "Gemma4 Local",
"options": {
"baseURL": "http://127.0.0.1:8336/v1",
"apiKey": "none"
},
"models": {
"gemma4-e4b-q4-k-m": {
"name": "Gemma 4 E4B"
}
}
}
},
"model": "gemma4-local/gemma4-e4b-q4-k-m"
}
EOF
Three things to keep aligned:
baseURLpoints atlocalhost:8336, the same port the tunnel forwards, the same portgemma4 statusreported.- The model key under
modelsmust match theidfrom/v1/modelsexactly (here,gemma4-e4b-q4-k-m). - The top-level
modelis<provider-id>/<model-id>, and becomes OpenCode’s default.
The API ignores authentication, so apiKey is set to none. If a client ever insists on a key, any non-empty placeholder works.
Step 5: Launch and inspect
workshop launch demo
"demo" launched
Verify:
workshop list
workshop info demo
WORKSHOP STATUS NOTES
demo Ready -
workshop info demo prints the full picture: base image, the project directory mounted at /project, and the OpenCode SDK with its persisted config and data mounts (that’s where the SDK persists its state between workshop updates). What it does not show yet is a tunnel. The workshop is up, OpenCode is installed, and the host model is still completely out of reach. Good.
Step 6: Connect the tunnel
This is the one line that opens the wall:
workshop connect demo/opencode:gemma4 demo/system:gemma4
Verify:
workshop connections
INTERFACE PLUG SLOT NOTES
mount demo/opencode:opencode-config demo/system:mount -
mount demo/opencode:opencode-data demo/system:mount -
tunnel demo/opencode:gemma4 demo/system:gemma4 manual
The tunnel row, marked manual, is the one you just made. workshop info demo now grows a tunnels: block showing gemma4 forwarding 127.0.0.1:8336 to the identical address on the host. One port through the wall; nothing else.
Step 7: Probe gemma4 through the tunnel
Run curl inside the workshop and watch it reach the host model:
workshop exec demo -- curl -s http://127.0.0.1:8336/v1/models | jq '.data[].id'
"gemma4-e4b-q4-k-m"
Same localhost:8336, same model, but the request originated from a sealed container and came back 200. The wall is bridged for exactly one port.
Step 8: Let OpenCode write a file
Now the actual job. OpenCode runs inside the workshop, talks to gemma4 over the tunnel, and writes into /project (which is your host directory):
workshop exec demo -- opencode run 'Write /project/fizzbuzz.py: a Python script that prints FizzBuzz for 1..20'
> build · gemma4-e4b-q4-k-m
← Write fizzbuzz.py
Wrote file successfully.
The very first opencode run does a one-time database migration, and the first call into a freshly-woken model reloads weights, so the debut is slow. Later runs are brisk.
Because /project is the mount, the file is already on your host:
cat fizzbuzz.py
python3 fizzbuzz.py
for i in range(1, 21):
if i % 3 == 0 and i % 5 == 0:
print("FizzBuzz")
elif i % 3 == 0:
print("Fizz")
elif i % 5 == 0:
print("Buzz")
else:
print(i)
1
2
Fizz
4
Buzz
Fizz
...
Step 9: Tear down, the work stays
Stopping a workshop keeps its state; only remove destroys it:
workshop stop demo
workshop remove demo
workshop list
WORKSHOP STATUS NOTES
demo Off -
And the payoff:
python3 fizzbuzz.py
It still runs. The agent is gone, the container is gone, the model never left your GPU, and the only thing that made it out of the box is the file you asked for. That’s the whole shape of the exercise: confine the agent, keep the model on the metal, and let exactly one file and one port cross the line.
Where to take Workshop next
The tunnel-to-a-local-model trick is one small corner of what a workshop is for. A few directions worth exploring:
- Run agents in parallel, safely. Pair a workshop per git worktree to let several agents work on different branches at once, each in its own sealed container. See How to use workshops with Git.
- Bring your own editor. Connect a local VS Code (or JetBrains, or a browser) to a running workshop and edit inside it with your usual tools. See How to connect VS Code.
- Cross the wall via other interfaces. The tunnel is one of several sanctioned crossings:
mountfor files,tunnelfor ports and sockets, plusgpu,ssh-agent,camera, anddesktop. Declaring the connection inworkshop.yamlmakes it survive aworkshop refresh. See the tunnel interface explanation section. - Package your own tooling. Beyond Store SDKs like
opencode, you can author an in-project SDK with setup and health hooks so a teammate gets the same environment from oneworkshop launch. See In-project SDKs explanation section.
Other next steps
- Track the setup under git so it’s reproducible:
echo '*.lock' > .gitignore(the workshop drops a lock file you don’t want committed), then commitworkshop.yamlandopencode.json. - Step up to a bigger model or a different quantization:
snap components gemma4andgemma4 list-enginesshow what your hardware can load, andsudo gemma4 use-engine <name>switches engines. Treat those commands as the source of truth, then re-run thegemma4 chatcheck and update the model ID inopencode.jsonto match. - Add a second inference snap from the inference-snaps family as another provider block in
opencode.json, and a second tunnel inworkshop.yaml. - Tune the nap:
gemma4 set sleep-idle-seconds=1800keeps the model warm longer between requests. - Cut the workshop off from the internet so nothing can phone home, while the
gemma4tunnel keeps working:lxc network acl create offline && lxc network set workshopbr0 security.acls=offline security.acls.default.egress.action=drop. The block is enforced on the host side, so nothing inside the container can lift it, and it survives stop, start, andworkshop refresh. (In fact, it covers every workshop on the sharedworkshopbr0bridge). See LXD network ACLs. - Keep reading: the OpenCode docs cover MCP servers, custom prompts, and team config.