mate
Reference

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:latest

Studio 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 prints http://localhost:4097/?token=… to the log; a restart invalidates open tabs. Pin a value of at least 32 characters, best as studioToken: in the configuration file, to keep it stable and out of the log. Then open https://<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), add localhost:8080 to MATE_STUDIO_ALLOWED_HOSTS; add a proxy’s host the same way. Matching is exact.
  • TLS at a proxy. Set MATE_STUDIO_PUBLIC_ORIGIN to the exact https://host[:port] users open; its host is accepted automatically. Studio then issues a Secure cookie on that host and matches Origins against it. It never trusts Forwarded or X-Forwarded-*. The proxy must enforce TLS, forward WebSocket upgrades on /api/terminal, pass the Host header through, and be the only route to the container’s port.

What to mount

PathWhat it is
/companionsThe Companion Repositories this container serves. Mount one companion directly, or a directory holding several.
/etc/mate/appliance.yamlThe 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 ./companions

With 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:ro

The 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 -v deletes 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-home volume and its top-level volumes: 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 the keep-id mapping 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.

SettingMeaningDefaultRequired
MATE_COMPANIONS_DIRThe directory holding the Companion Repositories the container serves./companionsno
MATE_COMPANION_REPOSGit 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_COMPANIONWhich discovered companion agent sessions run against, by directory name or absolute path.—only when more than one is found
MATE_AUDIENCEThe 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_PORTThe port Studio is served on.4097no
MATE_STUDIO_HOSTThe interface Studio binds.0.0.0.0no
MATE_STUDIO_WRITABLEWhether Studio’s save path is reachable.trueno
MATE_STUDIO_TERMINALWhether Studio offers the agent terminal. Off whenever MATE_STUDIO_WRITABLE is off.trueno
MATE_STUDIO_DETACH_MINUTESHow long a terminal session keeps running with no browser tab attached, in minutes.30no
MATE_STUDIO_TOKENPins Studio’s access token (at least 32 characters). Without it, one is generated per start and its address printed.—no
MATE_STUDIO_ALLOWED_HOSTSAdditional exact host[:port] values Studio answers to, comma-separated.—no
MATE_STUDIO_PUBLIC_ORIGINThe exact https://host[:port] users open when a proxy terminates TLS.—no
MATE_GIT_SYNCWhether agent sessions run the companion’s own Git synchronization.falseno
MATE_GIT_USER_NAMEThe Git author name used for any commit the container makes.—no
MATE_GIT_USER_EMAILThe Git author email used for any commit the container makes.—no
MATE_AGENT_CREDENTIALSThe 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_TOKENThe read token for that registry. Startup-only; a registry without it stops startup.
MATE_GIT_CLONE_TOKENA 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_HINTText added to the error reported when no companion is found, such as the command that sets one up.—no
MATE_APPLIANCE_CONFIGWhere the configuration file is read from./etc/mate/appliance.yamlno

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_REGISTRY and MATE_PLUGIN_REGISTRY_TOKEN. After the companion is checked out and registered, and before verification, the image runs mate install --yes --frozen-plugins in 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-analyst

MATE_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 policy and config. The effective default agent is the audience’s studio.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 install writes the same files whatever MATE_AUDIENCE is 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 openspec or tokensave stay in the base list, because they change what mate install writes.
  • Validation. Audience names are lowercase letters, digits and hyphens (up to 64, starting with a letter). An audience may contain only capabilities, plugins and studio.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 install and mate doctor fail the same way; ordinary commands warn once and use the base alone. mate doctor --json reports audiences: { declared, active }.
  • Older Mate versions ignore audiences: and refuse a frozen install whose manifest holds audience packages. Set an engines.mate floor in framework.yaml for 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 … --version

It 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.9
  • version is the exact published version the image installs.
  • channel is latest for a stable release and canary for a canary; it names the moving tag the image is added to.
  • ref is the release tag, which is the bare version, without a v.

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.agent in the companion’s framework.yaml selects 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 own audiences.<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 4096 port mapping; publish 4097 alone.
  • Remove MATE_AGENT_PORT and MATE_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 serve now 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.