Skip to content

feat(web): build the container with voice and recall, and serve Ollama beside it - #108

Merged
adityak74 merged 2 commits into
mainfrom
web/docker-voice
Aug 25, 2026
Merged

feat(web): build the container with voice and recall, and serve Ollama beside it#108
adityak74 merged 2 commits into
mainfrom
web/docker-voice

Conversation

@adityak74

@adityak74 adityak74 commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

The compose stack already ran the server and the UI. It did not run a model,
the image was built with the default features so the microphone and the
conversation search were compiled out and answered 501, and the UI container
shipped nginx with no configuration, so the page could never reach the API at
all. This makes the stack work.

It extends what is already here. An earlier attempt added a second
docker-compose.yml beside the existing compose.yml, which compose loads in
preference, so none of that work ever ran unless you passed -f, which no
documentation told you to do. That branch is superseded and deleted.

The image

zorp-web/Dockerfile builds with --features voice,recall and ships
python3 with its venv and pip. The Qwen3-ASR runtime is deliberately not
baked in: several gigabytes of weights and torch install themselves into a
named volume the first time somebody clicks the microphone, which keeps the
image small and the rebuild cheap.

The container runs as uid 1000. That is not just good practice here.
zorp-voice's setup calls refuse_root_for(geteuid().is_root()), so under
root the microphone would not work at all and nothing would say why. The image
also creates /home/zorp/state and /home/zorp/voice and chowns them before
switching user, because a named volume mounted onto a path absent from the
image arrives owned by root and the server then cannot write its databases.

Four workspace members, zorp-search, zorp-skill, zorp-recall and
zorp-voice, were missing from the COPY list. Cargo cannot parse a workspace
with absent members, so this file had stopped building before this change.

The page could not reach the API

The UI image was nginx with no config. index.html resolves the API base to
the empty string when the page is served over http, meaning same origin, so
every API request landed on the static file server and 404'd. The page
reported "no zorp server here" and suggested going away and installing zorp.

The earlier verification did not catch this, and neither did mine at first,
because both curled port 7777 directly rather than loading the page. Curling
the API is not a test of the UI. This is the same shape of mistake as
verifying with docker exec from inside the container.

web/nginx.conf.template serves the built files and proxies /api/ to the
server. Buffering is off on that path and the read timeout is an hour, because
/api/sessions/:id/events is server sent events and a turn can think for a
long time before it says anything.

The proxy attaches the token itself. ZORP_API_TOKEN had only ever been a
commented-out line in index.html and nothing injected it, so even with a
working proxy the page would have collected 401s. Doing it in nginx keeps the
token out of the browser entirely.

The sidecar

compose.yml gains an ollama service serving both the chat model and the
embeddings the conversation search needs. It is in the stack rather than on
the host because zorp-recall requires its embedding endpoint to be loopback
and enforces that four ways: the written form is checked, then the resolution,
the HTTP client gets a resolver that answers for one host and port and does no
lookup, redirects are off and proxy-from-env is off. A normal compose network
is not loopback, so recall would refuse a separate service reached by name.

server joins the sidecar's network namespace with
network_mode: "service:ollama". Both processes then see the same 127.0.0.1
and not one guard has to be weakened. ZORP_EMBED_URL is fixed rather than
overridable for the same reason. Port 7777 is published on ollama because a
container sharing another's network namespace cannot declare its own ports, so
the namespace owner has to, and that is also the name nginx proxies to.

The bind, the token, and the published ports

The server binds 0.0.0.0 inside its own network namespace, which is what
makes the published port reachable at all. Because a reachable zorp-web is
agent-driven shell access to whatever the process can see,
zorp-web/src/main.rs requires --token on any non-loopback bind and refuses
to start without one. Compose uses a required variable so the stack refuses to
come up rather than coming up unprotected.

Because the proxy authorizes on the browser's behalf, both published ports are
bound to the host's loopback: 127.0.0.1:8080 and 127.0.0.1:7777. Whatever
can reach port 8080 is already talking to the agent. Anyone wanting the UI
reachable from another machine should put their own authenticating proxy in
front rather than widening these bindings.

None of this is the loopback rule that recall and voice enforce, and that rule
is untouched. Those still talk to 127.0.0.1 inside the shared namespace, and
nothing here loosens LoopbackUrl, LoopbackResolver, redirects(0),
try_proxy_from_env(false) or the canary tests.

Verification

Run from the host with the page actually loaded, not with docker exec and
not by curling 7777 directly.

  • docker compose config validates. One stack, no shadowing warning.
  • All three containers start. docker compose ps shows 127.0.0.1:8080->80
    on ui and 127.0.0.1:7777->7777 on ollama, with server holding no
    ports of its own as designed.
  • The page loads in a browser and no longer shows "no zorp server here". A
    turn runs against a model through it.
  • GET / through the UI is 200.
  • GET /api/capabilities on the UI's own origin, with the client sending no
    token, returns real JSON reporting voice available with setup available.
  • POST /api/sessions on that origin returns a session id.
  • GET /api/voice/status answers.
  • GET /api/capabilities straight at 7777 with no token is 401, with the
    token 200.
  • GET /api/recall/search answers with the embedder's own 404 for the
    unpulled model, which is the route being live rather than 501. Pulling the
    model is a documented step.
  • docker compose exec server id is uid=1000(zorp) gid=1000(zorp).
  • With ZORP_WEB_TOKEN unset, compose refuses: required variable ZORP_WEB_TOKEN is missing a value.

No Rust or TypeScript source changed. The root Dockerfile, which builds the
released agent image, is untouched.

https://claude.ai/code/session_01KGPVQ8wUG7h36zashWYCp4

…a beside it

The compose stack already ran the server and the UI. It did not run a
model, and the image was built with the default features, so the
microphone and the conversation search were compiled out and their
endpoints answered 501. That looks like a broken image rather than a
deliberate build.

zorp-web/Dockerfile now builds with --features voice,recall and ships
python3, its venv and pip for the Qwen3-ASR runtime. The runtime is not
baked in: several gigabytes of weights and torch install themselves into
a named volume the first time someone clicks the microphone. The image
also runs as uid 1000 and creates the volume mount points before
switching user, because zorp-voice refuses to set itself up as root on
purpose and a named volume on a path absent from the image arrives owned
by root. Four workspace members were missing from the COPY list, so
cargo could not parse the workspace and the image had stopped building.

compose.yml gains an ollama sidecar. It is there rather than on the host
because zorp-recall requires its embedding endpoint to be loopback and
enforces that four ways, and a normal compose network is not loopback.
server joins the sidecar's network namespace, which gives both processes
the same 127.0.0.1 without weakening a single guard. Port 7777 is
published on ollama because a container sharing another's namespace
cannot declare its own ports. ZORP_EMBED_URL is deliberately not
overridable.

Verified from the host, not with docker exec:

- docker compose config validates, one stack, no shadowing
- all three containers start, ui on 8080, ollama publishing 7777
- GET / is 200 at 29430 bytes, /dist/main.js 200 at 139899, /styles.css
  200 at 62016
- GET /api/capabilities with no token is 401, with the token 200 and
  reports voice available with setup available
- GET /api/recall/search answers with the embedder's own 404 for the
  unpulled model, which is the route being live rather than 501
- docker compose exec server id is uid=1000(zorp)
- compose refuses to start with ZORP_WEB_TOKEN unset

Claude-Session: https://claude.ai/code/session_01KGPVQ8wUG7h36zashWYCp4
… the API

The stack had never worked from a browser. The UI image shipped nginx with
no configuration at all, and index.html resolves the API base to the empty
string when the page is served over http, meaning same origin. So every
API request landed on the static file server and 404'd, and the page
reported "no zorp server here" with a suggestion to go install zorp.

Neither earlier check caught it because both curled port 7777 directly
rather than loading the page. Curling the API is not a test of the UI.

web/nginx.conf.template serves the built files and proxies /api/ through
to the server, which lives in the sidecar's network namespace and so is
reached by the namespace owner's name. Buffering is off on that path and
the read timeout is an hour, because /api/sessions/:id/events is server
sent events and a turn can think for a long time before it says anything.

The proxy attaches the token itself. ZORP_API_TOKEN had only ever been a
commented-out line in index.html and nothing injected it, so even with a
working proxy the page would have collected 401s. Doing it in nginx keeps
the token out of the browser entirely.

Because the proxy authorizes on the browser's behalf, both published ports
are now bound to the host's loopback. Whatever can reach port 8080 is
already talking to the agent, and the agent runs commands on this machine.

Verified from the host with the page loaded, not by curling 7777:

- GET / through the UI is 200
- GET /api/capabilities on the UI's own origin, with no token sent by the
  client, returns real JSON reporting voice available
- POST /api/sessions on that origin returns a session id
- GET /api/voice/status answers
- docker compose ps shows 127.0.0.1:8080->80 and 127.0.0.1:7777->7777

Claude-Session: https://claude.ai/code/session_01KGPVQ8wUG7h36zashWYCp4
@adityak74
adityak74 merged commit 833a668 into main Aug 25, 2026
7 checks passed
@adityak74
adityak74 deleted the web/docker-voice branch August 25, 2026 02:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant