The Appliance Container
Running Mate as a container: what to mount, what to configure, and what to put in front of it.
The appliance image is Mate as a single-tenant deployment: one container, one human, one or more Companion Repositories mounted into it. Inside, Studio is served on one port: the browser reads and edits the companion’s markdown there — a served appliance cannot run Obsidian — and starts Claude Code or OpenCode from Studio’s agent terminal. Both runtimes are in the image.
docker run \
--user 10001:10001 \
--volume ./companions:/companions \
--publish 127.0.0.1:4097:4097 \
ghcr.io/uniqbit-ag/mate-cli/mate-appliance:latestStudio prints its address, carrying a fresh access token, to the container log. Open that address once per browser.
A worked example with volumes, ports and the identity mapping is in
apps/mate-container/compose.example.yaml.
Put something in front of it
Studio’s port is guarded by an access token, a Host check, and an Origin check. It has no user accounts and does not terminate TLS, and the terminal behind it is shell-level access through the agent. Publish the port on a loopback address, as the example does, and put a reverse proxy with TLS in front of it — or keep the container on a private network and reach it through one.
- The token. Without
MATE_STUDIO_TOKEN, Studio generates one per start and printshttp://localhost:4097/?token=…to the log; a restart invalidates open tabs. Pin a value of at least 32 characters, best asstudioToken:in the configuration file, to keep it stable and out of the log. Then openhttps://<your host>/?token=<value>once. - Allowed hosts. Studio answers only to
localhost,127.0.0.1,[::1]and its bound address, each on port 4097. When you publish a different host port (8080:4097), addlocalhost:8080toMATE_STUDIO_ALLOWED_HOSTS; add a proxy’s host the same way. Matching is exact. - TLS at a proxy. Set
MATE_STUDIO_PUBLIC_ORIGINto the exacthttps://host[:port]users open; its host is accepted automatically. Studio then issues aSecurecookie on that host and matches Origins against it. It never trustsForwardedorX-Forwarded-*. The proxy must enforce TLS, forward WebSocket upgrades on/api/terminal, pass theHostheader through, and be the only route to the container’s port.
What to mount
| Path | What it is |
|---|---|
/companions | The Companion Repositories this container serves. Mount one companion directly, or a directory holding several. |
/etc/mate/appliance.yaml | The configuration file, for anything you would rather not put in a process listing. Optional; every setting also has an environment variable. |
The identity, and the volume
The container runs as uid 10001, gid 10001 (mate). It never runs as root.
That identity has to be able to write the companions directory — chown it on
the host, or map the container’s user to one that already can:
sudo chown -R 10001:10001 ./companionsWith rootless Podman, map your own user to that identity instead:
userns_mode: "keep-id:uid=10001,gid=10001"A companions directory the running user cannot read or write is reported at startup, naming the directory and the identity, rather than failing later inside a session.
Git remotes over SSH
The image carries an SSH client; it carries no key and no known hosts. To clone or synchronize over SSH, mount both into the running user’s home, read-only:
volumes:
- ./ssh/id_ed25519:/home/mate/.ssh/id_ed25519:ro
- ./ssh/known_hosts:/home/mate/.ssh/known_hosts:roThe key file must be readable by uid 10001 and by nobody else, or ssh refuses
it. A host missing from known_hosts fails the connection rather than being
trusted on first use, because nothing is there to answer the prompt. HTTPS
remotes need neither.
Agent sessions synchronize Git only when MATE_GIT_SYNC is on. Otherwise each
launch gets --no-git, and nothing contacts a remote after the first clone.
Agent logins survive updates
Signing in to Claude Code or OpenCode with a subscription writes state under the
container user’s home, /home/mate. Mount a named volume there and that login,
with the sessions and history beside it, survives a rebuild, a recreate and an
update to a newer image:
services:
mate:
volumes:
- mate-home:/home/mate
volumes:
mate-home:compose.example.yaml already declares it. Sign in once from Studio’s terminal;
the next container on the same volume is still signed in.
- The volume holds live subscription credentials. Treat it like the credentials themselves. It is isolated from the host: the template mounts no host agent directory and no keychain, so a login on your machine is not the container’s login.
- Resetting.
docker compose down -vdeletes the volume and signs both agents out. Removing the container alone does not. - Updates. On every start the container recreates a missing agent directory and re-applies the image’s own TokenSave integration, offline, so a volume seeded by an older image is brought current. Logins and sessions are not touched. A failure names the agent and stops startup.
- Leftovers. A start killed part-way through restoring plugins can leave a generated npm config in the home. It names the registry token rather than holding it, and the next start removes it before doing anything else.
- Adopting it. An existing compose file receives nothing until you copy the
mate-homevolume and its top-levelvolumes:entry. The first start seeds the volume from the image; you sign in once more, and from then on it persists. With rootless Podman, keep thekeep-idmapping above so uid 10001 can write it. A volume that already exists owned by root has to be chowned to 10001.
Configuration
Every setting is readable from an environment variable and from the configuration file. The environment wins where both supply one. A value that is present but unusable — a port that is not a port — stops startup naming the setting and the value; it is never quietly replaced by a default.
| Setting | Meaning | Default | Required |
|---|---|---|---|
MATE_COMPANIONS_DIR | The directory holding the Companion Repositories the container serves. | /companions | no |
MATE_COMPANION_REPOS | Git locations to check out into the companions directory when nothing is present at their destination. One per line or comma-separated, each url or url=directory. | — | no |
MATE_COMPANION | Which discovered companion agent sessions run against, by directory name or absolute path. | — | only when more than one is found |
MATE_AUDIENCE | The audience of the selected companion this container serves, as declared under audiences: in its framework.yaml. Unset: the base configuration only. One container serves one audience; an audience the companion does not declare stops startup. | — | no |
MATE_STUDIO_PORT | The port Studio is served on. | 4097 | no |
MATE_STUDIO_HOST | The interface Studio binds. | 0.0.0.0 | no |
MATE_STUDIO_WRITABLE | Whether Studio’s save path is reachable. | true | no |
MATE_STUDIO_TERMINAL | Whether Studio offers the agent terminal. Off whenever MATE_STUDIO_WRITABLE is off. | true | no |
MATE_STUDIO_DETACH_MINUTES | How long a terminal session keeps running with no browser tab attached, in minutes. | 30 | no |
MATE_STUDIO_TOKEN | Pins Studio’s access token (at least 32 characters). Without it, one is generated per start and its address printed. | — | no |
MATE_STUDIO_ALLOWED_HOSTS | Additional exact host[:port] values Studio answers to, comma-separated. | — | no |
MATE_STUDIO_PUBLIC_ORIGIN | The exact https://host[:port] users open when a proxy terminates TLS. | — | no |
MATE_GIT_SYNC | Whether agent sessions run the companion’s own Git synchronization. | false | no |
MATE_GIT_USER_NAME | The Git author name used for any commit the container makes. | — | no |
MATE_GIT_USER_EMAIL | The Git author email used for any commit the container makes. | — | no |
MATE_AGENT_CREDENTIALS | The agents’ own credentials, passed into every agent session’s environment untouched. NAME=value pairs, one per line. | — | no |
MATE_PLUGIN_REGISTRY | <scope>=<registry-url>, such as @acme=https://registry.acme.test/api/npm/. Setting it opts in to restoring the selected companion’s locked plugins at startup. Needs MATE_PLUGIN_REGISTRY_TOKEN. Startup-only: never forwarded to Studio or agent sessions. | ||
MATE_PLUGIN_REGISTRY_TOKEN | The read token for that registry. Startup-only; a registry without it stops startup. | ||
MATE_GIT_CLONE_TOKEN | A read token used only to clone a private MATE_COMPANION_REPOS location; never written into the remote URL, an argument or the checkout. Startup-only. | ||
MATE_SETUP_HINT | Text added to the error reported when no companion is found, such as the command that sets one up. | — | no |
MATE_APPLIANCE_CONFIG | Where the configuration file is read from. | /etc/mate/appliance.yaml | no |
In the file, each setting appears under its own key:
companionsDir: /companions
companion: acme
studioPort: 4097
studioWritable: true
studioTerminal: true
studioDetachMinutes: 30
studioToken: ...
studioPublicOrigin: https://studio.acme.test
studioAllowedHosts: [studio.acme.test]
credentials:
ANTHROPIC_API_KEY: ...Credentials supplied this way reach Studio’s environment, and through it each agent session’s, and nothing else. A pinned token reaches Studio the same way, never as an argument, and is removed from each agent’s environment. They are never written into a Companion Repository, and they do not survive the container.
What startup does, and what it deliberately does not
Startup finds the companions, registers them so Studio lists them, prepares the selected one’s machine-local dependencies, and starts Studio. It starts no agent; agents start from Studio’s terminal.
It registers; it does not set up. A companion that already carries its own
configuration keeps it byte for byte: no selection is presented, none is
changed, and mate companion setup is never run on a companion someone else
configured. Registration exists so Studio’s inventory lists the companion and
can validate the companion its launches are pinned to.
A configured Git location is cloned only when nothing is present at its destination. An existing checkout is used as it is: never re-cloned, never reset. A checkout that came from a different remote than the configuration names is reported, naming both, and left intact.
Dependency preparation is filesystem work
A freshly cloned Companion Repository has no machine-local dependency workspace — it is gitignored, and a launch validates it rather than building it. The image carries a complete, fully installed one, and startup copies it into place.
Unless you opt in to plugin restore, nothing is installed at run time. No package manager runs, no dependency version is resolved, no package is downloaded, no native build happens, and no installation script executes anywhere in the container’s process tree. A container with no outbound network access starts normally.
The first start of a fresh clone copies the whole workspace — several hundred megabytes in tens of thousands of files — onto the companions volume, and says nothing until it is done. Through the file sharing of Docker Desktop or a Podman machine on macOS or Windows, that can take minutes, and Studio does not answer until it finishes. Later starts reuse the workspace.
A missing, corrupt, or incompatible dependency is reported by name, and startup stops: Studio is not started, no installation fallback is attempted, and an existing workspace is left exactly as it was.
Restoring a companion’s plugins
A companion can declare npm plugins in framework.yaml. The selected companion’s
declarations and committed plugin lockfile are the only source of which
packages run: there is no appliance-side allowlist, so whoever can change the
companion repository can choose code the appliance executes. Restrict write
access to it and review changes to its plugin declarations and lockfile. This
is a trust boundary, not a sandbox. MATE_ALLOWED_PLUGINS is no longer read; a
stale value is reported at startup and ignored.
Startup verifies every declared plugin before Studio and refuses to start when one is missing, broken, or lacks a credential it names. Two ways to have them present:
- Pre-installed. Mount a companion whose plugin tree is already installed. Nothing is installed at startup.
- Restored at startup (opt-in). Set
MATE_PLUGIN_REGISTRYandMATE_PLUGIN_REGISTRY_TOKEN. After the companion is checked out and registered, and before verification, the image runsmate install --yes --frozen-pluginsin it. Plugin versions come only from the committed lockfile and the lockfile is never rewritten; the command may still install other requirements the companion declares. A missing or disagreeing lockfile, a rejected token, or an unavailable package stops startup before Studio. After the restore, startup writes the files the plugins generate, such as agent definitions, into the checkout; it commits nothing, so they show up as uncommitted changes. A restart with a matching installed tree reinstalls nothing.
The registry token and MATE_GIT_CLONE_TOKEN exist only for startup. They are
handed to the clone and restore processes alone, redacted from printed
output, and removed from the environment before Studio starts, so agent
sessions never inherit them. The scoped npm configuration is written only
into the container user’s home, owner-only, refers to the token by name, and is
deleted whether the restore succeeds or fails. A .mate/plugins/.npmrc in the
companion that sets a registry is refused. Container administrators can still
read a running container’s startup environment; use a short-lived read-only
token.
Serving one audience of a companion
One companion can serve several audiences — developers and business analysts,
say — from one framework.yaml. The base configuration is what everyone gets;
each named audience adds plugins and the capabilities they provide, and may
override Studio’s default agent:
plugins:
- package: "@acme/mate-remote-discovery"
version: 1.0.0
studio:
terminal:
agent: developer
audiences:
ba:
capabilities:
- name: mate-business-analyst
plugins:
- package: "@acme/mate-business-analyst"
version: 1.0.0
studio:
terminal:
agent: business-analystMATE_AUDIENCE selects the audience; unset means the base configuration alone,
exactly as before. Run one container per audience, from the same companion:
services:
mate-dev:
image: ghcr.io/uniqbit-ag/mate-cli/mate-appliance:latest
environment:
MATE_COMPANION: acme
mate-ba:
image: ghcr.io/uniqbit-ag/mate-cli/mate-appliance:latest
environment:
MATE_COMPANION: acme
MATE_AUDIENCE: ba- Plugins and capabilities add, the agent overrides. The effective plugins are
the base list followed by the audience’s, each with its own
policyandconfig. The effective default agent is the audience’sstudio.terminal.agent, else the base one. Hydration, strict startup verification and Studio use only the effective set, so a plugin of an inactive audience is never loaded and its missing credential never blocks startup. - One lockfile. The plugin manifest and committed lockfile cover the base and
every audience, so
mate installwrites the same files whateverMATE_AUDIENCEis and a frozen restore works for any audience. Packages of inactive audiences are installed but never loaded. - Capabilities follow the audience. Declare a capability a plugin provides
under the audience that loads that plugin, not in the base list: with no
audience (or another one) the plugin is not loaded, so a base declaration
could not be satisfied and startup would stop. The effective capabilities are
the base list plus the active audience’s. Built-in capabilities such as
openspecortokensavestay in the base list, because they change whatmate installwrites. - Validation. Audience names are lowercase letters, digits and hyphens (up
to 64, starting with a letter). An audience may contain only
capabilities,pluginsandstudio.terminal.agent. A capability may not be built in, or declared in both base and an audience, or twice in one audience. A package may not be declared in both base and an audience, or twice in one audience; two audiences may share a package only at the same version. A hub may not declare audiences. - An undeclared audience stops startup, naming the requested and the
declared ones, before setup or Studio run.
mate installandmate doctorfail the same way; ordinary commands warn once and use the base alone.mate doctor --jsonreportsaudiences: { declared, active }. - Older Mate versions ignore
audiences:and refuse a frozen install whose manifest holds audience packages. Set anengines.matefloor inframework.yamlfor a companion that uses audiences, so the engine guard names the version needed.
Startup carries the audience to the plugin restore, strict verification,
Studio and every agent session it starts, whether it was set in the
environment or the configuration file. It is never written into the companion.
Switching MATE_AUDIENCE on one checkout leaves files the previous audience’s
plugins generated, such as agent definitions; give each audience its own
checkout.
An unsupported selection is refused by name
The image carries the tools behind every package manager and capability the
release supports — bun, uv, and the commands behind OpenSpec, Graphify,
RTK, TokenSave and Context7 — so a companion is not refused for a selection the
image simply lacks. Context7’s server is installed, not fetched; its lookups
still reach Context7 over the network.
Each agent launch registers the agents’ MCP servers from the Mate the image
contains, and the image sets MATE_CONTEXT7_MODE=preinstalled, so a companion
whose committed entry fetches Context7 through npx — as workstation setup
writes it — is served the installed server without being set up again.
Where a mounted companion declares something this image does not carry, startup says which requirement that is and exits non-zero, rather than letting every launch be refused behind its installation gate with repair advice written for a developer at a terminal. A refusal by a launch’s own preflight is shown in that session’s terminal and does not stop the container.
Two versions, which are not the same thing
The image installs the exact published release it was built from, and that version is readable from inside a running container:
docker run --rm ghcr.io/uniqbit-ag/mate-cli/mate-appliance:latest \
--entrypoint mate … --versionIt is also on the image as the ai.mate.version label, alongside
ai.mate.opencode.version and ai.mate.claude.version for the two agent
runtimes.
That contained version is a different thing from the minimum compatible
version for unattended deployments: 0.17.0-canary.5, the first
release whose unattended registration, pinned update policy, prebuilt workspace
preparation, preinstalled plugin binding, and launch exit status and signal
forwarding all work in a deployment whose version is fixed by its image. Every
published image installs its own release, which is at or above that minimum.
Everything the image holds — runtimes, OS packages, tools, npm graphs, the prebuilt workspace — is fixed by release-owned inputs and recorded with it. Changing any of those versions means a new image.
Upgrades are a new image
The container’s version is a property of the image. It declares the pinned update policy before its first Mate invocation, and every process it starts inherits it — registration, preparation, inspection, Studio, and every agent session its terminal starts. Under that policy, update handling does nothing at all: no command is blocked however new a version is cached, no banner is shown, no registry is contacted, and the update-state cache is neither read nor written.
A deployment whose version is fixed by its image cannot act on advice to upgrade, so being refused for not having taken it would simply turn the appliance off. Every other gate still applies.
To change versions, run a different image. Recreate the container too: an
existing one keeps the image it was created from, even after the tag has moved
(docker compose up --force-recreate).
Where an image comes from
Every stable and canary release requests its own image. Once all of its npm
packages are published, the release script dispatches the publish-image.yml
workflow with three inputs, and the image is built on its own; a release is
complete whether or not that build succeeds.
The same dispatch, run by hand, is the retry. It republishes nothing to npm and does not rerun the release:
gh workflow run publish-image.yml --repo uniqbit-ag/mate-cli \
--field version=0.17.0-canary.9 --field channel=canary --field ref=0.17.0-canary.9versionis the exact published version the image installs.channelislatestfor a stable release andcanaryfor a canary; it names the moving tag the image is added to.refis the release tag, which is the bare version, without av.
The workflow builds from that tag’s checkout — its recipe, its locks, and its verification — not from the default branch. An image therefore contains what its release contained: a fix merged after a release reaches an image only with the next release.
Before building, the workflow checks that every package the image installs is on the registry. npm makes each package of a release visible on its own, so a package may not appear as soon as publication finishes. The workflow waits up to ten minutes, checking only packages still missing, and continues by itself when they appear. A manual re-run is only needed if a package is still missing after that wait.
Each architecture is verified offline before anything is pushed. If either one fails, no exact-version image is published and the channel tag does not move.
Saving, and the conflict you will meet
The agent and the human write the same files, in the same companion, at the same time. That is not an edge case in this deployment — it is its shape.
Studio’s vault refuses a save that would overwrite a change it did not see: a save carries the token from when the file was opened, and a save whose token is stale comes back as a conflict rather than winning. When that happens, reload the file, reapply the edit, and save again.
The agent terminal
The terminal is on by default (MATE_STUDIO_TERMINAL) and needs the save path
(MATE_STUDIO_WRITABLE); turning writability off turns it off. It offers only
agents the companion allows, both of which the image carries at pinned
versions, so nothing is installed when one starts.
- The launch companion is fixed. Every launch targets the companion startup
selected (
MATE_COMPANION), with that companion as its working directory, even while the page browses another registered companion. The terminal control names that target. - A companion can set its persona.
studio.terminal.agentin the companion’sframework.yamlselects an agent for every session when its definition exists, such as one a plugin wrote to.claude/agents/; otherwise the session starts plain and says so. The audience being served (MATE_AUDIENCE) may override it with its ownaudiences.<name>.studio.terminal.agent. - Sessions outlive their tab. Closing or reloading the tab, or losing the
network, detaches the session; the agent keeps running for the detach window
(
MATE_STUDIO_DETACH_MINUTES, 30 minutes), and reloading or opening Studio in another tab reattaches it. - At most four sessions run at once, detached ones included.
Migrating from port 4096
Earlier images ran an opencode web session on port 4096 beside Studio. That
session is gone: open Studio and start OpenCode — or Claude Code — from its
terminal instead.
- Drop the
4096port mapping; publish4097alone. - Remove
MATE_AGENT_PORTandMATE_AGENT_HOST. A container that still sets them starts normally and names them in a warning. - Open Studio with the token from the log, or pin one with
MATE_STUDIO_TOKEN.mate studio servenow requires the token for every request, reads included. - Companion selection (
MATE_COMPANION), Git synchronization (MATE_GIT_SYNC) and credentials carry over to terminal sessions unchanged.
Stopping
The container is Studio.
A termination signal stops Studio, which ends every agent session it started —
attached or detached — giving each 5 seconds after TERM before KILL; the
container exits zero once they have stopped. An agent session ending does
not stop the container. If Studio exits on its own, anything it left running is
stopped and the container exits with Studio’s status.
A stop the container itself caused is a clean stop. Any other status is
reported as it was: an ordinary non-zero exit stays non-zero, and a SIGKILL
from the out-of-memory killer is not a clean shutdown.