Skip to content

First year 50% off with code 50OFF

Buy Now

User guide

For installation, follow the Linux or macOS quickstart. This guide covers configuration, day-to-day commands, modes, Telegram control, and troubleshooting.

my-ai-team is distributed as a release tarball. After purchase you receive a download link; unpack it anywhere and run its installer:

Terminal window
tar xzf my-ai-team-*.tar.gz
cd my-ai-team
./install.sh

The installer:

  • copies a frozen runtime into ~/.local/share/my-ai-team
  • links the mat command and its helpers into ~/.local/bin, pointing them at that frozen install root, and writes a version stamp you can print with mat --version
  • writes no content under your personal ~/.claude; each agent’s home is built by the launcher on first launch, so nothing personal is rewritten
  • on systemd hosts, installs and starts the Telegram relay service automatically (and restarts it on every later install so the running daemon picks up new code). Where no per-user service manager is reachable — some SSH and container hosts, and Windows Git Bash — it prints a note and skips the step; a failed service step never fails the install.

On the first interactive Linux or macOS install, it opens the platform quickstart. Upgrades, reinstalls, and non-interactive installs skip it.

Set AVAILABLE_AGENTS=claude,codex before running ./install.sh to provision only selected backends. When AVAILABLE_AGENTS is unset, the installer scans for existing backend config directories and provisions the ones it finds.

Agent homes are provisioned on first launch, not at install time, so an upgrade takes effect the next time you start a session. Provisioning is idempotent and self-repairing: it renders the role’s prompt, writes the settings a long-running unattended agent needs, installs mat’s safety guards for backends that support them, and marks the launched checkout trusted so the agent does not stop on a trust prompt.

One of those guards shapes the action requests an agent sends you: a notify-user --action is held until its message either lists what the agent already tried under a Tried: header or asks for a choice under Decision:, so an action notification states the attempts or the decision it needs from you. It covers Claude, Codex, Copilot, and pi agents; OpenCode and freebuff are not covered. An agent can bypass it for one command with MAT_ALLOW_BARE_ACTION=1.

To upgrade later, run mat upgrade (see Upgrading); you can delete the unpacked tarball directory once the install has completed.

mat reads optional user config from ${XDG_CONFIG_HOME:-~/.config}/mat/.

  • backends.json adds or overrides backend registry entries.
  • credentials.conf lets two same-kind backends share one login.
  • user.conf is a deprecated fallback for MAT_DEFAULT_BACKEND / MAT_USER_NAME.

Your default backend and display name are read from git config — the canonical store:

Terminal window
git config --global mat.defaultBackend claude
git config --global mat.username "your name"

git config resolves system → global → local, so a per-repo override applies inside that repo while global values apply everywhere else.

mat setup --backend <kind> --make-default writes the canonical registry entry for one supported backend family and makes it your default — the first-run path used by the agent-assisted quickstart. It is non-interactive and safe to rerun:

  • The entry is inserted, or reconciled in place if a backend with that nickname already exists. Every other entry in backends.json — including unrelated credential variables — is preserved untouched, and the generated file is the same human-readable JSON the installer writes.
  • Token/API-key families (claude, opencode) name the environment variable that will hold your token or key (CLAUDE_CODE_OAUTH_TOKEN, OPENCODE_API_KEY) and never read, print, or store a secret value. Native-login families (codex, copilot, pi, freebuff, grok, commandcode) get the native-login shape; authenticate later with the CLI’s own login flow (codex, gh auth login for Copilot, pi with /login, freebuff login, the Grok Build TUI, cmd login).
  • --make-default sets git config --global mat.defaultBackend <kind> after the registry write succeeds.
  • Fail-closed: an unsupported kind or a malformed existing backends.json exits non-zero with a diagnostic and changes nothing.

After setup, run mat doctor for the short list of steps only you can do (backend login/token, GitHub auth, license activation, Telegram credentials).

The zero-config path. The built-in registry contains a single Claude backend. Run claude setup-token, export the result as CLAUDE_CODE_OAUTH_TOKEN (mat sources ~/.bashrc.secret at launch, so exporting it there is enough), and you are ready — no backends.json required. Edit backends.json only for multi-account setups, third-party endpoints, or other backend families.

A native Claude backend is token-auth only: with no token it fails fast at launch rather than starting unauthenticated.

To reuse a multi-account setup, copy examples/mat/backends.json into ~/.config/mat/backends.json and trim the entries you do not want. examples/mat/user.conf shows the supported user.conf exports.

backends.json is a single object with a backends array. Each element is a backend object:

{
"backends": [
{
"nickname": "ccw",
"config_dir": "claudew",
"auth_var": "CLAUDE_CODE_OAUTH_TOKEN_CCW",
"prompt_file": "CLAUDE.md",
"kind": "claude"
},
{
"nickname": "ccd",
"config_dir": "clauded",
"auth_var": "DEEPSEEK_API_KEY",
"prompt_file": "CLAUDE.md",
"kind": "claude",
"base_url_var": "DEEPSEEK_CLAUDE_BASE_URL"
}
]
}
Field Meaning
nickname Required. The CLI alias and the slot name used in commands and locks.
config_dir Required. Home/config directory stem under $HOME/.<config_dir>. Kept independent from nickname on purpose.
auth_var Optional. Name of the environment variable holding the auth token or API key. Omit it (or set null) when the backend uses its own login flow. A claude or leg backend is token/API-key auth only.
prompt_file Required. Prompt filename written into that backend’s home (CLAUDE.md for Claude, AGENTS.md for Codex, and so on).
kind Required. Backend family: claude, codex, copilot, pi, opencode, freebuff, grok, commandcode, agy, or leg. The family decides which modes a backend can run — see the notes below and each family’s subsection.
base_url_var Optional. Name of the environment variable whose value becomes the backend’s API endpoint. Omit it for the default endpoint or a native login flow.
base_url Optional literal endpoint for an explicit Codex or pi provider, or a Grok BYOK backend. Use base_url or base_url_var, not both.
tier Optional. strong or weak. A weak backend is refused for explore and audit (those roles produce the tickets everyone else works from) but stays usable as a Developer under a strong reviewer. Defaults to strong.
default_model Optional. Model applied at launch, passed through verbatim. Omit it to launch with the backend’s default.
default_effort Optional. Reasoning-effort level applied at launch. Omit it or set it to an empty string and effort falls back to high. Claude changes it in-session with /effort; leg applies it through LEG_THINKING.
context_window_size Optional. Effective context window for auto-compaction: an integer (250000) or a k/m shorthand (350k, 1m). Omit it and mat sets nothing, except on leg, which then defaults to 200000 (see leg workers).
max_output_tokens Optional for kind: "pi". Positive integer or k/K shorthand (base 1000), such as 96000 or 96k. Defaults to 64000 for pi model entries; an invalid value stops pi from launching.

The auth and base-url variables name environment variables; mat reads their values from ~/.bashrc.secret when they are not already exported. mat records the variable name, never the value.

A malformed backend object (a missing required field, an unsupported kind, an invalid value) is warned about and skipped; the rest of the file still loads. Invalid JSON, or a missing backends array, falls back to the built-in Claude backend with a warning rather than aborting.

MAT_USER_NAME is rendered into prompt files anywhere {{MAT_USER_NAME}} appears; when unset it falls back to the user.

Codex has five routes. The classifier walks them in order — gateway_selector wins over every other field, then endpoint, then auth_var alone, then legacy. Every route ends up writing a real role-local Codex config (gateway, official-auth-only, direct custom) or keeps the native auth.json / config.toml credentials (legacy native).

  • Gateway-selected. A non-empty gateway_selector field routes the entry through the gateway origin: MAT_CODEX_GATEWAY when set and non-empty, with MAT_ANTHROPIC_GATEWAY as the fallback — an operator running one gateway for several protocols exports the origin once. The selected value becomes the role’s base_url with only one trailing / stripped and, when enabled, the worker namespace inserted: the gateway owns URL handling and joins Codex’s /responses request path onto its pool member’s base URL, so any origin shape the gateway accepts works, path prefixes included. Only when both variables are unset or empty does the launch fail, with a diagnostic naming both. The role is provisioned with a fixed mat-owned provider identity mat-codex-gateway and the selector value carried as MAT_CODEX_API_KEY. The declared auth_var, base_url, base_url_var, model_provider, wire_api, and model_catalog_json are deliberately ignored on this route — set them only as comments documenting the upstream subscription. MAT_GATEWAY_QUOTA_URL is scrubbed on every Codex route; the gateway route re-establishes it from the fallback origin so the statusline queries the right pool’s quota. Team/duo launches also scrub any inherited OPENAI_API_KEY, OPENAI_BASE_URL, and CODEX_ACCESS_TOKEN from the pane environment at the supervisor boundary, before the Codex TUI starts.

    {
    "nickname": "codex-gateway",
    "config_dir": "codex-gateway",
    "prompt_file": "AGENTS.md",
    "kind": "codex",
    "gateway_selector": "chn"
    }
    export MAT_CODEX_GATEWAY=http://gateway.local:8080
  • Official auth-only. A non-empty auth_var with no endpoint and no other provider metadata uses the official Responses endpoint https://chatgpt.com/backend-api/codex with the provider identity codex-team. No base_url / base_url_var is required and no model catalog is copied.

    {
    "nickname": "codex-account-a",
    "config_dir": "codex-account-a",
    "auth_var": "CODEX_TOKEN_ACCOUNT_A",
    "prompt_file": "AGENTS.md",
    "kind": "codex"
    }
  • Direct custom. Any entry with an endpoint (base_url or base_url_var) is provisioned as a role-local Responses provider with the operator’s chosen endpoint, model_provider, and wire_api. Declare the key source and endpoint rather than putting the token in backends.json:

    {
    "nickname": "deepseek",
    "config_dir": "deepseek",
    "auth_var": "DEEPSEEK_API_KEY",
    "base_url_var": "DEEPSEEK_BASE_URL",
    "prompt_file": "AGENTS.md",
    "kind": "codex",
    "model_provider": "deepseek",
    "wire_api": "responses",
    "model_catalog_json": "~/.codex/models.json"
    }
  • Personal-subscription legacy. An entry with neither auth_var nor provider metadata keeps Codex’s own login: put config.toml in the backend’s bare canonical home ~/.<config_dir>/, and every role home links to it automatically. Add one entry per account to run multiple accounts.

  • Invalid direct. An entry with provider metadata but no endpoint is refused before launch with an actionable diagnostic. Either set an endpoint or strip the metadata so the entry resolves to a valid route.

Every Codex launch starts in Standard mode; /fast on inside a running session switches that one session to Fast.

For interactive launches, mat disables Codex’s automatic TUI conversation recaps with a process-scoped setting, so recap requests are not scheduled and do not consume additional model tokens. Manual /recap remains available. This does not disable automatic context compaction; a configured context_window_size still controls the Codex compaction threshold.

pi is a multi-provider AI coding assistant. Add a kind: "pi" entry:

{ "nickname": "pi", "config_dir": "pi", "prompt_file": "SYSTEM.md", "kind": "pi" }

The prompt_file must be SYSTEM.md — pi loads its system prompt from that filename and ignores any other.

pi backends have three credential modes:

  • Shared login (default). An entry with no provider metadata links every role to the bare agent dir ~/.<config_dir>/agent: log in once there (run pi with no role and use /login) and every pi role inherits that auth, model, and settings config.
  • Explicit provider. An entry that declares any of auth_var, base_url, base_url_var, model_provider, or wire_api is provisioned per role instead. mat writes a role-local models.json when an endpoint is declared and detaches the shared auth.json link — an explicit entry gives up the shared /login auth set, while the shared settings file stays linked. The key is never written to disk or the command line: models.json stores only the $VARIABLE reference pi resolves at request time, and mat exports auth_var for it.
  • Gateway. An entry with a non-empty gateway_selector routes through the companion gateway (see Light Anthropic gateway) with no vendor key on the worker. The selector is the only credential: mat exports it as MAT_PI_API_KEY and writes a role-local models.json provider block whose baseUrl is MAT_PI_GATEWAY or, when that is unset, MAT_ANTHROPIC_GATEWAY, with the worker namespace inserted when enabled. auth_var, base_url, base_url_var, and model_provider are ignored. wire_api is optional and defaults to anthropic on this route. default_model is required. The recommended form is a bare model id (claude-sonnet-4-5): mat names the block mat and launches pi with --model mat/<model-id>. A <prefix>/<model-id> names the block <prefix> instead; only the first / splits, so mat/vendor/model routes model vendor/model. A prefix that matches a pi built-in provider also routes to the gateway, but a credential stored for that provider by an in-pane /login would take precedence, and mat doctor notes any prefix other than mat. An entry with no default_model, or with an empty model id (gw/), fails before pi starts, because pi would otherwise pick a model from a vendor provider. As with an explicit provider, the shared auth.json link is detached and the shared settings file stays linked.
{
"nickname": "pi-proxy",
"config_dir": "pi-proxy",
"auth_var": "PI_PROXY_KEY",
"base_url_var": "PI_PROXY_URL",
"wire_api": "anthropic",
"default_model": "myproxy/claude-sonnet-4-5",
"context_window_size": "256k",
"max_output_tokens": "96k",
"prompt_file": "SYSTEM.md",
"kind": "pi"
}

wire_api names the wire protocol, not the vendor: anthropic (Anthropic Messages), openai (OpenAI Chat Completions — the format most OpenAI-compatible proxies clone), responses (OpenAI Responses), or google (Google Generative AI). When an endpoint is declared without wire_api, mat defaults to openai; a gateway entry without wire_api defaults to anthropic. One provider per entry: model_provider, when set, and the provider/ prefix of default_model must agree — that agreement picks the provider id written into models.json. model_catalog_json is not supported for kind pi.

For gateway and endpoint-backed entries that set default_model, mat writes numeric maxTokens from max_output_tokens, defaulting to 64000, and writes numeric contextWindow from context_window_size when configured. As a session grows, pi can clamp each request’s output allowance to fit the remaining context. The selected endpoint must support the configured output cap. On a responses wire, mat also writes compat.supportsMaxOutputTokens: false unless the entry declares max_output_tokens, so pi sends no max_output_tokens and the upstream default governs; some gateway-pool upstreams reject that parameter outright. These limits are written only on a model entry: an endpoint without default_model keeps its baseUrl-only provider block, and shared-login or auth-only routes do not gain one.

Each mat-managed model entry with default_model also sets pi’s reasoning flag so the --thinking effort selected at launch reaches pi’s native request builder. The control pi sends depends on the selected API:

wire_api pi API Thinking control
unset on a gateway, or anthropic anthropic-messages Anthropic thinking (budget-based unless pi selects adaptive thinking)
unset on an explicit endpoint, or openai openai-completions pi’s detected compatibility format, commonly reasoning_effort
responses openai-responses reasoning.effort
google google-generative-ai Google thinkingConfig

mat does not override pi’s thinking format. If an endpoint rejects the controls pi selects for its API, pi reports the request error and mat does not disable thinking as a fallback.

{
"nickname": "pi-gateway",
"config_dir": "pi-gateway",
"gateway_selector": "chn",
"default_model": "claude-sonnet-4-5",
"prompt_file": "SYSTEM.md",
"kind": "pi"
}

default_model and default_effort apply at launch like on any other backend: the model forwards as --model <provider/model-id> (pass the model id exactly as pi expects it, e.g. deepseek/deepseek-v3), and the effort forwards as --thinking <effort> — always, falling back to high when default_effort is unset. mat never passes --provider or an API key.

If pi is configured with an Anthropic-compatible provider, you can instead register it as kind: "claude" with a base_url_var pointing at pi’s endpoint — no new config, reusing Claude’s auth wiring.

OpenCode workers support both OpenCode v1 and v2. mat detects the installed major version at each launch (an unrecognised version is treated as v2) and mat doctor reports it. On v2 mat starts the TUI with --auto --standalone (a private server that exits with the pane) and selects the model and effort through the role config as <provider>/<model>#<effort> from default_model and default_effort. On v1 mat starts it with --auto --agent build --model &lt;model&gt; and applies the effort through agent.build.reasoningEffort.

OpenCode workers are interactive-only. Register one with an API-key variable and the nested prompt path OpenCode reads as its role instructions:

{
"nickname": "opencode",
"config_dir": "opencode",
"auth_var": "OPENCODE_API_KEY",
"prompt_file": "config/opencode/AGENTS.md",
"kind": "opencode",
"default_model": "opencode/deepseek-v4-flash-free"
}

Each OpenCode role gets an isolated config tree, so its own config, skills, and AGENTS.md stay per-role. mat writes the provider config with an env-backed key and never puts the key on the command line. For an OpenAI-compatible BYOK endpoint, add base_url_var:

{
"nickname": "opencode-byok",
"config_dir": "opencode-byok",
"auth_var": "OPENAI_API_KEY",
"base_url_var": "OPENAI_BASE_URL",
"prompt_file": "config/opencode/AGENTS.md",
"kind": "opencode",
"default_model": "opencode/my-model"
}

OpenCode has no write guard: the restricted modes (explore, audit, review) continue after a warning that the agent has unrestricted write access, and the headless delivery modes refuse an OpenCode backend outright. Keep OpenCode on interactive modes, or leave worktree isolation on (the default) so a stray write lands in a throwaway checkout rather than your main tree.

freebuff workers are interactive-only. Register one with:

{
"nickname": "freebuff",
"config_dir": "freebuff",
"auth_var": "-",
"prompt_file": ".AGENTS.md",
"kind": "freebuff",
"tier": "strong"
}

The prompt_file must be .AGENTS.md — the leading dot is load-bearing; freebuff reads a home-level dotfile and never a plain AGENTS.md. There is no auth_var: run one interactive freebuff login from the operator’s canonical home (~/.config/manicode/), and every role links that credentials.json. The release files (freebuff, its metadata, tree-sitter.wasm, and rg) are also converged and hardlinked between the canonical home and each role, so a role that downloads an update promotes it on the next launch without making every other role download another copy. Role settings, history, projects, analytics ID, and instance-owner state remain independent real files.

Three limits are worth knowing before you register one:

  • One active session per freebuff account. Run only one freebuff instance at a time; concurrent-instance safety is not provided. Two panes on the same account otherwise evict each other — the later one takes over and the earlier stops responding.
  • No write guard. Like OpenCode, freebuff runs restricted modes full-auto after a warning; keep it on interactive modes and leave worktree isolation on.
  • Hardlink/symlink degradation. On different filesystems, or on Windows without symlink privilege, mat leaves role-local copies/files in place rather than copying stale credentials. Log in separately in that degraded case. The wrapper’s rename-based binary update briefly breaks hardlinks and is repaired on the next launch.

freebuff’s model catalog is re-tuned faster than a mat release, and a model can be metered or closed for part of the day, so pin capability to the tier field in backends.json rather than to a model name. Declare weak where the catalog is limited — explore and audit gate on it.

Command Code workers run the cmd CLI headless-first: the duo, team, and caucus delivery relays serve it, and mat doctor checks it like any other backend. Register one with:

{
"nickname": "commandcode",
"config_dir": "commandcode",
"auth_var": null,
"prompt_file": ".commandcode/AGENTS.md",
"kind": "commandcode",
"tier": "strong"
}

The equivalent compact registry entry is commandcode:commandcode:-:.commandcode/AGENTS.md:commandcode.

There is no auth_var: authenticate once with an interactive cmd login in your own home. Every role home links that auth.json, so one login serves all roles. A worker’s constitution renders at <role home>/.commandcode/AGENTS.md, its skills project to <role home>/.commandcode/skills, and its session transcripts stay under the role home — so a worker never reads or writes your interactive Command Code history.

Two limits to know before you register one:

  • No write guard. Like OpenCode and freebuff, Command Code runs restricted modes full-auto after a warning: mat has no hook surface to install a policy into. Keep worktree isolation on (the default) so a stray write lands in a throwaway checkout.
  • Interactive panes are not wired. mat drives Command Code headless. The tmux and local pane drivers, including their relay send paths, are not adapted to the cmd TUI in this release — use Command Code for the headless delivery modes, and pick a pane-backed backend for interactive drivers.

agy workers run the Google Antigravity CLI (agy) headless-first: the duo, team, and caucus baton relays serve it. Register one with:

{
"nickname": "agy",
"config_dir": "agy",
"auth_var": null,
"prompt_file": ".gemini/GEMINI.md",
"kind": "agy",
"tier": "strong"
}

The equivalent compact registry entry is agy:agy:-:.gemini/GEMINI.md:agy:-:strong.

There is no auth_var: sign in once with agy’s own login. mat stages no token for agy and does not copy or link its Google account file into role homes; each role runs with $HOME set to its own role home, so its .gemini state and conversations stay separate from yours and from other roles.

Every agy turn passes --dangerously-skip-permissions, so agy never stops to ask for tool approval, and one --add-dir for each workspace the turn may use. agy only grants access to directories passed with --add-dir: starting it in a directory or naming a --project is not enough.

Restricted modes (explore, audit, review, duo-review, live) get a PreToolUse write guard. mat keeps one hook named mat-role-guard in <role home>/.gemini/config/hooks.json. It refuses tracked-file writes and delivery commands, and read-only tools and unrestricted roles keep their normal behavior. Other hooks in that file are left as they are. If the file is not valid JSON, mat refuses to launch the role and leaves the file in place. A workspace .agents/hooks.json is not used as the role guard.

This release is headless/Baton-first. The tmux and local pane drivers are not adapted to agy’s interactive TUI, so choose a pane-backed backend for interactive drivers.

leg workers run the leg CLI headless-first: the duo, team, and caucus baton relays serve it. Register one with:

{
"nickname": "leg",
"config_dir": "leg",
"auth_var": "ANTHROPIC_API_KEY",
"prompt_file": "AGENTS.md",
"kind": "leg",
"tier": "strong"
}

The equivalent compact registry entry is leg:leg:ANTHROPIC_API_KEY:AGENTS.md:leg:-:strong.

leg is token/API-key auth: auth_var is required, and mat refuses to start a turn when it is missing or its value is empty. The credential reaches leg only through its environment, never its command line. mat doctor checks that auth_var resolves and reports how to fix a missing or empty value without showing the credential. Two credential types work:

  • An Anthropic API key: name the variable ANTHROPIC_API_KEY, and its value is passed as an API key.
  • An Anthropic-compatible provider’s key: use any other variable name together with a base_url_var naming the provider’s endpoint. The key is passed as a bearer token (ANTHROPIC_AUTH_TOKEN) and the endpoint as ANTHROPIC_BASE_URL.

A Claude Code subscription token (from claude setup-token) is not supported: leg is passed such a token as a bearer token, but every turn fails with a rate_limit_error. Other Anthropic credential variables in your shell do not reach leg.

Every leg turn carries the role’s instructions (the entry’s prompt_file in the role home) as its system prompt. A turn whose role instructions are missing or empty fails before leg starts.

default_effort reaches leg through LEG_THINKING. Leg accepts off, minimal, low, medium, high, xhigh, and max; when the field is omitted or empty, mat uses high. An already-exported LEG_THINKING value wins over the registry. If the registry contains another non-empty value, mat reports it once and leaves LEG_THINKING unset so leg uses its own default.

Each role keeps its own leg session store under its role home, so roles never share a conversation. A task’s later turns continue its session; if leg reports that session is gone, mat starts that turn in a new session once.

The backend’s context_window_size sets leg’s compaction budget (LEG_CONTEXT_WINDOW_TOKENS, required by leg 0.34.1 and later): omit the field and mat exports 200000, the standard Claude window. mat sets the budget without clamping requests to the model’s real limit — on a 1M-window model a value this small only compacts earlier — and a value you export as LEG_CONTEXT_WINDOW_TOKENS yourself wins over both the field and the default.

Restricted modes (explore, audit, review, duo-review, live) get a write guard through leg’s pre-tool hook. It refuses tracked-file writes and delivery commands from leg’s bash, write, and edit tools; reads and unrestricted roles keep their normal behavior.

This release is headless/Baton-only. The tmux and local pane drivers are not adapted to leg, so choose a pane-backed backend for interactive drivers. mat setup does not write leg entries; add one to backends.json by hand.

freebuff, Command Code, and agy use HOME as their native home variable. For these workers, mat sets $HOME to the per-session role snapshot so each role has an isolated native configuration. That snapshot is not the operator’s real home, and a ~/ path must not be assumed to reach it.

Use these pinned anchors for operator-live paths:

  • GIT_CONFIG_GLOBAL — the operator’s Git global configuration.
  • GH_CONFIG_DIR — the operator’s GitHub CLI configuration.
  • MAT_GH_AUTH_CONFIG_DIR — pins the same directory as GH_CONFIG_DIR (or your explicit value), so the gh credential gateway can find your GitHub CLI accounts from the worker. mat sets it only when that directory is readable; without one, the worker’s gh refuses with the usual gh auth login remedy.
  • MAT_HOST_SECRET_FILE — the operator’s host secret file.
  • MAT_STATE_HOME — mat’s operator state directory.
  • MAT_WORKTREE_ROOT — the operator’s worktree root.

Non-git directories copied into the snapshot may be stale. Git-tracked snapshot content refreshes with the snapshot contract; live operator content belongs in git or behind an explicit anchor. This boundary does not change snapshot copy, credential linking, or HOME mapping behavior.

Grok workers run the interactive Grok Build TUI. Register one with native per-role login:

{
"nickname": "grok",
"config_dir": "grok",
"auth_var": null,
"prompt_file": "AGENTS.md",
"kind": "grok",
"tier": "strong"
}

The equivalent compact registry entry is grok:grok:-:AGENTS.md:grok:-:strong. Native login stores one identity in the canonical ~/.grok/auth.json; role homes link that file, so authenticate once in the canonical Grok home rather than logging in separately for every role.

Grok supports three distinct authentication paths:

  • Native login: omit auth_var, base_url, and base_url_var (or set auth_var to null).
  • xAI API key: set auth_var to the key variable, such as XAI_API_KEY, and omit base_url and base_url_var.
  • BYOK: set auth_var plus either a literal base_url or a base_url_var. mat exports both values as XAI_API_KEY and GROK_MODELS_BASE_URL.

For example, an xAI-key backend can use "auth_var": "XAI_API_KEY"; a BYOK backend can use "auth_var": "MY_GROK_KEY" with either "base_url": "https://provider.example/v1" or "base_url_var": "MY_GROK_BASE_URL".

Set context_window_size to configure Grok’s auto-compaction window when default_model is set. Grok applies the value to that selected model; without default_model, mat leaves the native configuration unchanged.

The key and endpoint are read from the environment or ~/.bashrc.secret when needed and never appear on the command line. Ambient XAI_API_KEY and GROK_MODELS_BASE_URL are scrubbed unless the backend declares the matching path; a declared endpoint without a usable key, or an invalid endpoint, fails closed rather than falling back to native login.

Grok workers serve both interactive sessions and the headless delivery modes (the duo, team, and caucus baton relays). Headless grok turns are cold: each turn runs on its own, with no session trail carried between turns, so a task rebuilds context from the durable artifacts named in its prompt rather than resuming an earlier conversation. Restricted interactive modes use a PreToolUse write guard: tracked-file edits and delivery commands are refused, while unrestricted roles keep their normal behavior. Each Grok role keeps mat’s skills in its own native skills directory, while Claude and Cursor compatibility scans are disabled for the role so their operator-level instructions, skills, hooks, and rules are not inherited.

Set the MAT_ANTHROPIC_GATEWAY environment variable to route native Claude backends through a local companion gateway instead of talking to api.anthropic.com directly. The gateway is a separate companion service, not bundled with mat — this is only the integration point. Export it where the launcher will see it (e.g. ~/.bashrc.secret):

export MAT_ANTHROPIC_GATEWAY=http://127.0.0.1:9949

Set it to the root origin of your gateway — no /v1 or sub-path (Claude Code appends /v1/messages itself).

  • A backend with a gateway_selector field routes through the gateway (the selector is sent as the auth token, so a base_url_var on that backend is ignored). Point several workers at the same selector — most usefully the pooled auto selector — to let the gateway handle failover across them.
  • A backend with a base_url_var and no gateway_selector ignores the gateway and uses its vendor endpoint directly.
  • A backend with auth_var: null is selector-only: valid on the gateway path, and it fails fast at launch when the gateway is unset.

With the gateway unset, every backend behaves exactly as it does without this feature.

MAT_ANTHROPIC_GATEWAY serves Claude and Copilot backends directly, and is also the fallback origin for Codex and pi backends on the gateway route (see the Codex backends and pi workers sections above): a Codex gateway_selector entry uses MAT_CODEX_GATEWAY when it is set and MAT_ANTHROPIC_GATEWAY otherwise, and a pi gateway_selector entry uses MAT_PI_GATEWAY the same way. The variables carry a gateway origin, not a wire path — each client appends its own protocol path (/v1/messages for Claude Code, /responses for Codex, the path of its wire_api protocol for pi) and the gateway joins that inbound path onto the selected pool member’s base URL, so one gateway origin serves every protocol at once.

A claude setup-token credential cannot read usage directly: its OAuth token carries inference scope only, and Anthropic’s usage API requires user:profile. mat quota reports this as error: token lacks user:profile scope and points at MAT_ANTHROPIC_GATEWAY — configuring the gateway is the fix, since the gateway’s own quota endpoint doesn’t need that scope.

mat quota (from your shell) and the agent statusline both read usage from the gateway when it is active, printing per-window utilization and reset countdowns. A gateway behind HTTP Basic auth is supported for Claude backends: declare the credential in user:pass form as MAT_ANTHROPIC_GATEWAY_BASIC_AUTH next to the gateway URL, and mat carries it through both the launch and the quota fetch without ever placing it on a command line. On this Claude Code path, mat pre-seeds the active Claude home’s remembered rejection for the gateway selector, preventing the CLI’s selector confirmation prompt while keeping ANTHROPIC_API_KEY available to Agent-SDK sub-agents.

A Copilot backend cannot traverse a Basic-auth-protected gateway: the Copilot CLI’s BYOK provider has no custom-header channel to carry the edge credential, so a Copilot pane on the gateway path fails fast at launch when MAT_ANTHROPIC_GATEWAY_BASIC_AUTH is set instead of silently 401ing at the edge. Relax the gateway edge to also accept Bearer selectors, or route the Copilot backend outside that gateway. A pi gateway backend has the same limit and fails fast the same way.

The worker namespace is off by default: every gateway client URL is the plain gateway URL. Set MAT_GATEWAY_WORKER_NAMESPACE=1 to enable it. Every client URL that points at the gateway then carries the worker’s backends.json nickname in the gateway’s reserved worker namespace, inserted between the gateway origin and any path it has:

MAT_GATEWAY_WORKER_NAMESPACE=1
MAT_CODEX_GATEWAY=http://127.0.0.1:8080/v1, nickname agent-a
-> http://127.0.0.1:8080/_aqg/w/agent-a/v1

Enable it only when the gateway supports the worker namespace. It is useful only for a gateway pool configured to spread workers (concurrency > 1). A gateway without that support forwards the prefixed path upstream, and every gateway-routed agent fails. Any value other than 1 leaves the namespace off. Changing the setting re-provisions a Codex or pi role’s provider file on its next launch.

With the namespace enabled, the URL is derived at every launch — there is no extra field to set and backends.json is never rewritten. The nickname is routing identity only: the gateway uses it to keep each worker on its own pool member, while gateway_selector still selects the pool and is still the only credential. Claude, Copilot, Codex, and pi gateway launches all use the derived URL.

The statusline’s quota endpoint follows it on the backends that have one: with the namespace active, a Claude, Codex, or pi launch adds &worker=<nick> to the quota URL it exports, so the quota that statusline reports is that worker’s instead of the whole pool its gateway_selector shares. With the namespace off — and with a nickname the namespace leaves out — the quota URL carries no worker parameter. mat quota, run from your shell, always reports the pool. A Copilot gateway launch exports no quota endpoint at all, so there is nothing to qualify there.

A nickname that is not a safe single URL segment — anything outside letters, digits, ., _, ~, and -, a leading ., or longer than 64 characters — is left out: that worker uses the plain gateway URL and the gateway’s shared, non-per-worker routing.

These mat.* git-config keys show their environment aliases, purpose, and defaults. Unless noted, Git resolves them from local to global configuration.

Key Env var What it does Default
mat.defaultBackend MAT_DEFAULT_BACKEND Backend used when a command names none claude
mat.username MAT_USER_NAME Your display name in prompts and notifications —
mat.enableWorktree MAT_ENABLE_WORKTREE (or --worktree / --no-worktree) Run each mode in its own worktree on
mat.autoStart -a / -n flags Start seeking work on launch on
mat.autoRefine MAT_AUTO_REFINE Deprecated and inactive; unready backlog refinement belongs to resident mat explore --auto-refine —
mat.autoNotifyUser MAT_AUTO_NOTIFY_USER Emit the built-in PR/merge status notifications on
mat.verbose MAT_VERBOSE (or --verbose) Show full per-step launch progress off
mat.driver MAT_DRIVER Session driver: tmux, local, or baton (Windows Git Bash: local or baton only — see below) tmux (Windows Git Bash: baton)
mat.ghAuthOwnerMap — Repeatable global mapping from owner=login (or host/owner=login) to the GitHub identity used by Baton —
mat.ghAuthTokenVar — Repeatable global mapping from login=ENV_VAR_NAME to a static credential variable —
mat.ghAuthTokenFile — Repeatable global mapping from login@owner=/absolute/path or login=/absolute/path to a caller-refreshed credential file (Windows Git Bash also accepts drive-absolute paths) —
mat.auditPollMinutes MAT_AUDIT_POLL_MINUTES Audit broad-sweep interval, in minutes 300
mat.personalPromptOverride — Enable user-global prompt overrides (see Prompt overrides) off
mat.personalSkillsOverride — Let a personal skill override a product skill of the same name off
mat.pairIdleCheck MAT_PAIR_IDLE_CHECK Enable unified idle-recovery ladders for tmux and Baton; false/0 disables them on
mat.stallWatchdog MAT_STALL_WATCHDOG Enable unified idle recovery for tmux and Baton, plus tmux human-wait checks; false/0 disables them on
mat.devopsHost MAT_DEVOPS_HOST ssh host that also receives every notify-user --action message (see Forwarding action messages) —

Worktree selection has its own precedence: MAT_ENABLE_WORKTREE first (the --worktree and --no-worktree flags set this value for that launch), then mat.enableWorktree from local or global Git config, then the deprecated per-mode key for adhoc or explore, and finally the default of on. The flag therefore overrides an inherited environment value; this is the deliberate exception to the table’s usual Git configuration resolution.

Set a key locally for one repo (git config --local mat.<key> <value>) or globally for the machine (git config --global …). The two override toggles (personalPromptOverride, personalSkillsOverride) are git-config only — no env var — so a stale environment value can never flip them.

GitHub credentials on Linux, macOS and Windows Git Bash

Section titled “GitHub credentials on Linux, macOS and Windows Git Bash”

install.sh installs a small gateway in front of gh and the HTTPS Git credential helper for github.com. Agents, Baton workers and background tasks all reach GitHub through it, and it reads the selected account’s credential fresh on every command, so a rotated token applies to the next command with no restart. If the credential is missing, empty or unreadable, the command is refused rather than run unauthenticated. Which account and credential source a Baton session uses is chosen at launch; after changing that choice (for example removing a token variable or remapping an owner), run mat baton restart <session> to choose again without relaunching. On Windows, use Git Bash; setup also supports GitHub CLI’s native gh.exe and Git’s Windows credential-helper paths. Agents whose shell is Windows PowerShell or PowerShell 7 also reach gh through the gateway, with its normal output and exit status. mat doctor shows a gh gateway line. If it warns or fails (for example after upgrading gh, or when it reports the PowerShell gh forwarder incomplete), run install-gh-gateway again.

Refreshable GitHub App credentials for Baton

Section titled “Refreshable GitHub App credentials for Baton”

Baton normally uses the GitHub account available to gh. For a GitHub App installation, map each repository owner to the App identity and point mat at its caller-managed token file:

Terminal window
git config --global --add mat.ghAuthOwnerMap 'team-one=your-app[bot]'
git config --global --add mat.ghAuthOwnerMap 'team-two=your-app[bot]'
git config --global --add mat.ghAuthTokenFile 'your-app[bot]@team-one=/absolute/path/team-one-token'
git config --global --add mat.ghAuthTokenFile 'your-app[bot]@team-two=/absolute/path/team-two-token'
chmod 600 /absolute/path/team-one-token /absolute/path/team-two-token

Login and owner matching is case-insensitive. A matching login@owner entry outranks a login=/absolute/path entry; the login-only form is a fallback when that owner has no specific entry. The first valid entry for the same key wins, and later duplicates warn. Each file must contain only its current installation token. The caller refreshes it; mat does not mint tokens or handle an App private key. Baton reads the selected file when choosing the account and again before every worker turn, so replacing it takes effect on the next turn. If the chosen file is unavailable or denied access at launch, mat warns and uses its normal user-account selection without trying a less-specific file. If it becomes unavailable during a session, that turn is refused instead of reusing an expired token.

Token-file paths must be absolute for the current platform. On Windows Git Bash, drive-absolute paths such as C:/tokens/app-token and C:\tokens\app-token are accepted; drive paths are rejected on POSIX hosts.

The App appears on commits and pull requests as your-app[bot]. Its installation needs contents:write, issues:write, and pull_requests:write for the normal Baton delivery workflow.

  1. cd into the repo you want to work on.

  2. Start the relay session:

    Terminal window
    team

The command is identical on Windows Git Bash. team derives the project identity from the Git repository root; no project registration or name is required.

When a name is shown (session labels, worktree paths), mat uses lowercase letters, digits, and -. It normalizes inputs like my_project to my-project. Recommended names read like my-project, customer-portal, or ops2.

Terminal window
adhoc [-n] [--worktree|--no-worktree] [backend]
audit [--session] [backend]
team [-n] [backends...]
explore [--worktree|--no-worktree] [backend]
live [--domain <name>] [backend]
duo [-n] [dev [reviewer]]
caucus [proposer [challenger]] [--topic-file <path>]

Run adhoc, audit, team, duo, and caucus from inside the repo you want to work on. explore and live can run anywhere. backend defaults to your mat.defaultBackend. team accepts 0–3 backends mapped to dev, plan, and review in that order; duo accepts a dev and a reviewer.

Every mode launches its own tmux session, even when invoked from inside another mat session. Reattach by rerunning the same command from the same repo, or attach to the concrete session with tmux -L mat attach -t <session>. Stop one by ending its agent or with tmux -L mat kill-session -t <session>.

Auto-start is the default: a fresh session claims work and begins on launch. Pass -n / --no-auto-start to start cold and wake it later; -a / --autostart forces it on for one launch. For a persistent opt-out, set git config --local mat.autoStart false.

Preview a launch with --dry-run at any position — it prints what would be spawned and exits without creating anything:

Terminal window
mat --dry-run duo
mat team --dry-run claude codex

Use these commands to inspect running sessions and their state:

Terminal window
mat agents # every live agent session, grouped by project, with its state
mat agents --json # machine-readable: one JSON object per session
mat idle # only the standby (idle) sessions
mat audit status # audit poller, queue, and latest-wake diagnostics

Both mat agents and mat idle work outside a Git repository. Each row is labelled by state:

  • standby — idle with a poller available, waiting for ready work (shows a best-effort idle duration).
  • ghost — the supervisor is still running, but its recorded tmux pane is gone. The row names its session, backend, and missing pane, for example ghost (pane %1497 gone). It is excluded from mat idle and cannot receive pane-based messages. Listing it does not stop the supervisor or release its ownership protection. Older sessions whose tmux hosting cannot be confirmed retain their local status until restarted.
  • busy — the agent is actively running a turn.
  • interactive — an explore or live pane running a manual REPL, with no idle/busy claim.

If a supervised backend is exhausted or unusable before it can invoke its own /respawn, an operator can request the same clean-slate boundary from any shell by naming the session:

Terminal window
mat respawn <session>

This is available only for supervised explore and adhoc sessions. The operator trigger validates the session state and published child pidfile, writes the existing respawn sentinel, and then reaps the backend so the supervisor launches a fresh child; it refuses live, audit, team, duo, and caucus sessions. It also refuses the resident auto-refine worker (explore --auto-refine) on every driver: that worker runs its own supervised session with its own lifecycle, which this verb does not address. If the backend reap cannot be arranged, the sentinel is removed without signaling the backend, and the command returns non-zero.

A team (dev/plan/review) or duo (duo-dev/duo-review) session hosts several supervised panes in one tmux session, so its respawn is per pane. Name the pane with --role:

Terminal window
mat respawn <session> --role <role>

<role> is the supervisor role token — dev, plan, review, duo-dev, or duo-review (not the developer/reviewer send-relay aliases). The trigger rebirths only that pane: it writes the role’s restart marker, reaps that pane’s backend, and confirms the supervisor has published a fresh child; the session’s other panes are untouched. The reborn pane comes back the way its role’s cold launch does — a coordinator (plan/duo-dev) self-driving, a passive pane (dev/review/duo-review) idle — and reloads the currently installed runtime. If the delayed reap cannot be arranged, the marker is removed without signaling that pane’s backend and the command returns non-zero.

--role is only for live team/duo sessions. It refuses an explore/adhoc/live/audit/caucus session, a role outside the allowlist, and a role with no live pane in that session (an already-down pane, or a role that mode never runs — for example --role dev on a duo session). Each refusal is non-zero with the reason on stderr and leaves every backend untouched. If the reaped pane’s supervisor does not republish a fresh child within the verify window, the command fails loudly and leaves the marker and the session event-log trail in place for manual recovery.

Baton sessions (duo and caucus on the baton driver) run headless, so there is no tmux window to look at. The console is a web view of the same information mat baton status prints, plus the relay timeline, a live tail of each role, and the operator controls:

Terminal window
mat baton console # serve on 127.0.0.1:7380 and open a browser
mat baton console --no-open # print the URL only
mat baton console --port 7391 # pick another port

It needs Node.js 20 or newer on your PATH. If Node is missing or too old the command prints a one-line hint and exits non-zero without starting a server.

The left rail lists every session mat baton status reports, grouped by project, with its status, per-role queue depth, and how long ago its last operator event was — no events for a session that has not had one yet. A [!!] marker flags a session that is not ready to progress (a role or its worktree is not ready, or the session is stranded); the rail’s legend spells out what [!!] and every status mean. Selecting a session shows its participants, a merged relay timeline — each message once, labelled with its lane (pending, claimed, done, or outbox), with operator messages highlighted and routine system wakes collapsed — and the controls below. When a message is a handoff file reference, the console inlines the file’s contents (up to 64 KiB) if the file is still there and reports it as gone if it is not; it will only read handoff files from the scratch directory, never an arbitrary path a message names. New messages appear within a few seconds without a reload.

Timeline messages are labelled with names rather than transport identities. Each role message is named for the role that sent or received it (dev, reviewer, proposer, and so on), including older sessions whose messages carry the generic agent-a/agent-b labels — the console works the role out from the mailbox the message is in, and says unknown role (agent-a) rather than guessing when it cannot. Your own messages are labelled with your configured name (mat.username); with no name configured they read as the operator.

Crashed and stale sessions are hidden until you turn on the dead-session toggle. The list of dead sessions is read when the page loads. A session that stops while the page is open, such as one you stop from the console, joins it after the next refresh and stays there, so you can still open it and tear it down, until a teardown from the console removes it. A teardown removes the session from every open console page, not only the one you used, and a session you create again under the same name shows up again on its own, with no reload. Reload the page to re-read the full dead-session list. Once shown, a dead session’s row is dimmed and its own view carries a distinct “dead” badge instead of a live status pill. Clicking one always answers: a session the runtime can no longer describe still opens, showing its name, the kind and status the session list knows, and a note saying its detail is unavailable — so a dead session reads as dead rather than as a click that did nothing.

The session header also shows why a session is degraded, when it is: whether its roles and worktree are ready, and the reason a probe reported trouble. Each role column’s header carries the same per-role detail — whether its serve is alive and any probe error — when the session kind publishes it.

Under the session header there is one column per configured role — two for a duo or caucus session, three for a team. Each column tails what that role is doing right now: its recent user and assistant turns, with every tool call shown as a single line such as Editing src/app.js, Reading README.md, Running bash test/run.sh, or Searching TODO. A tool the console does not recognise is named outright (Calling <tool>), and a call it cannot describe at all says Unknown tool call running. Tool inputs and outputs are never shown — the one-line form is all a column carries. Columns update within a few seconds of the role writing a new turn. Each column’s header also names the issue that role is working on (issue #<number>), or says no task claimed while the role is on a turn that has not claimed one.

Not every backend records a transcript. A role that has none — a caucus role, or a role that has not started one yet — says so and shows its latest sent message instead of an empty panel.

A role’s conversation belongs to one issue at a time, and the console holds each column to that: when a role moves on to a new task, the previous task’s turns are cleared from its column and the column says which conversation it is waiting for. That is what makes a column readable at a task boundary — an empty one means the new task’s turn has not recorded anything yet, not that the old conversation is still running.

Under whichever of those a column shows, every column carries a serve stderr section: the tail of that role’s own serve log, which is where a backend that is failing to start says why. Nothing has to be clicked to see it, and it refreshes along with the turns above it, so a failing serve can be read against the turns it did or did not produce. It scrolls in its own bounded area, and it always says which case it is in: still loading, empty, unavailable with the reason, or a long log shown as its tail only.

When a role does have a transcript, its column carries a Rewind link that opens that conversation in a local Rewind transcript viewer, assumed to be at http://localhost:7373. Set MAT_REWIND_URL if yours listens elsewhere. Clicking it checks whether Rewind is already running and starts it for you if not, then opens the link once it answers; if Rewind never comes up, you get a message telling you to start it yourself instead of a dead tab. On a host with no Rewind installed at all, that same tab goes to the Rewind product page (https://shukelabs.com/products/rewind/) rather than asking you to start something that is not there.

The columns fit the window rather than scrolling sideways: they share the width evenly, wrap onto another row when the window is too narrow for all of them, and wrap long lines inside their own panel, so nothing is hidden off the right edge. The relay timeline sits below them in its own bordered, independently scrolling panel — scrolling it moves the timeline, not the page, and its position survives the periodic refresh, so reading back through a long conversation is not interrupted. A timeline you have scrolled to the bottom stays at the bottom as new messages arrive.

Some of this takes a moment, and the page says so rather than sitting still. Picking a session shows its name and Loading session… until its detail arrives — never the session you were looking at before — and a fetch that fails says so in its place. A fleet refresh and a running action both show in the status line at the top right, next to the live-connection state.

A slow host — native Windows especially, where mat baton status can take over a minute — does not make the console lose a session. Once the page has shown a session list or a session’s detail, a refresh that is slow or never answers keeps what was last shown and labels it instead of replacing it with an error:

  • refreshing — a refresh is still running behind the data on screen (refresh still running; showing last known as of …). The page updates on its own when the refresh finishes.
  • stale — the last refresh failed or timed out, so the data on screen is the last good answer, with the time it was read and why the refresh failed (stale as of … UTC: mat baton status –json timed out after …). The next successful refresh clears it without a reload.
  • error — there was nothing to fall back on (the very first load failed), so the page says the read failed rather than showing an empty list.

The fleet’s state is shown in the status line; a session’s is shown as a note under the session header. A session disappears from the list only when a successful mat baton status stops reporting it, never because a refresh timed out. Each read is bounded — about 30 seconds for a status scan and 15 seconds for one session’s detail, raised to 3 minutes and 2 minutes on Windows — and a read that hits its bound is stopped together with everything it started.

You can also intervene from the page. Each role column has its own message box at the bottom, so you type under the role you are talking to and there is no dropdown to aim wrong. Enter sends that column’s message; Shift+Enter and Ctrl+J insert a newline instead, and Enter while an input method is composing commits the candidate rather than sending. What you have typed survives the page’s periodic refresh, including your cursor position, and it is cleared only once the send actually succeeds — a refused or failed send leaves the text there to retry. Inject to inbox is a separate control below the columns that bypasses topology routing; it keeps its own role selector, and stays disabled until you acknowledge the same unscoped-injection warning the CLI prints. The action bar offers wake, pause or resume, stop, restart, and teardown: stop asks for a confirmation dialog, and teardown — which is destructive, stopping every service and removing the session’s state — is directly clickable and asks for two confirmations in a row, naming the session it will tear down before the final one. Cancelling either dialog sends nothing. Every control runs the same mat baton verb you would type in a terminal, and the verb’s own output — including its refusals, such as a caucus session refusing wake — is shown verbatim afterwards, labelled with the role it was routed to.

While a verb is running the controls are disabled and say which action it is — the column that is sending reads sending…, and the others say what is holding them. One action runs at a time, and the console refuses a second one until the first answers, so a repeated Enter or a double click cannot send twice — including after you switch to another session, where the busy note tells you which session is still working. Some verbs take minutes, so nothing is lost by waiting; if the request never answers at all, that is reported in the same place the verb’s own output would have been.

The console is deliberately local: it binds the loopback interface only and refuses requests that do not come from it. It never edits a session’s state, mailbox, or transcript itself — every session mutation is the existing verb, which keeps writing the operator event trail. The one exception is the Rewind link’s own start-it-for-me step, which is not a session mutation at all. Because it is unauthenticated, do not expose the port beyond your own machine.

mat backlog prints a read-only snapshot of open issues by lifecycle, followed by open PRs. It never mutates anything; use mat next-work for work selection.

Terminal window
mat backlog # human-readable listing
mat backlog --json # JSON object (repo, counts, issues[], prs[])
mat backlog ready # show only the ready section
mat backlog --repo owner/name # override repo slug

Each issue lands in one bucket: umbrella (a tracker), delivery (claimed), refining, blocked, ready (claimable now), backlog, or other. The ready section is byte-identical to what mat next-work picks from.

Use one read-only command to name every unmet prerequisite:

Terminal window
mat doctor

It checks, one report line each:

  • bash ≥ 4.3, and tmux / git / jq / curl on PATH.
  • On Windows Git Bash, jq may be a native jq.exe (preferred) or an npm/pnpm global-bin script shim. mat runs a pnpm shim’s JavaScript target through node with the arguments untouched; any other script shim runs with Git Bash path conversion on and a warning that path-like jq arguments may be rewritten. If jq cannot be launched at all (for example a Cannot find module error), the backend check reports that with jq’s own error and tells you to reinstall jq, rather than blaming backends.json.
  • python3 and PyYAML (used by the GitHub issue helper), with a platform install hint when python3 is present but the yaml module is missing.
  • gh present and authenticated — an unauthenticated gh otherwise shows up later as an agent dying on its first issue or PR call.
  • The installed gh version against the floor the Reviewer’s merge PR read needs (>= 2.72.0) — below it, a warn names the required version rather than letting the read fail as an unrelated-looking error.
  • ~/.local/bin on PATH, and every installed command resolving back to this install root — so a half-installed or stale link set is named.
  • The installed version and activation state (no network; the license key is never printed).
  • The resolved default backend: which registry entry is in effect and whether its auth actually resolves — a token variable, or gateway routing.
  • Telegram credentials and whether the relay service is loaded and active.
  • The super-global ~/.claude/CLAUDE.md: benign (absent or empty) or populated. See the managed Claude file.
  • On Windows Git Bash: the bash.exe mat exports so Claude Code picks the Bash tool, not PowerShell — see Windows Git Bash: agents get the Bash tool, not PowerShell; whether the active git can honour the configured SSL backend — see Windows: one Git for Git Bash and the MSYS2 shell; and whether /tmp resolves to one directory across bash, native tools, and the agent’s Read/Write tools.

Three verdicts, one exit rule:

Verdict Meaning Exit status
ok the prerequisite is met unaffected
warn worth reading, not an unmet prerequisite (e.g. an unactivated install) unaffected
FAIL an unmet prerequisite, printed with a fix: line non-zero

mat doctor exits non-zero if any check FAILs and never stops at the first failure, so it is safe as a script gate. It is strictly diagnostic: it creates no session, writes no config, prints no secret value, and never remediates — each FAIL line tells you the command to run yourself. No launch path gates on it.

Prompt guidance follows the host operating system. On Linux and macOS, a direct /tmp/<name>.md handoff is permitted only when MAT_SCRATCH_DIR is unset and the effective root is the default /tmp; the file must exist, be readable, and use the .md handoff contract before it is sent. If MAT_SCRATCH_DIR is set, use its printed root or mat scratch-file instead. Use mat scratch-file when you need collision-safe reservation or atomic stdin-backed writing.

Windows Git Bash/MSYS2 and unknown hosts use the conservative rule: use the printed scratch root and mat scratch-file for relay files. This keeps Bash, native tools, and agent file tools on one path. The runtime scratch commands and relay transport are the same on every platform.

A relayed handoff must be a readable, non-empty file: send-relay checks the path before delivery and refuses — with a non-zero exit, nothing delivered — when the file is missing, unreadable, or empty. Write the file first, then send its path. The atomic writer form mat scratch-file <name> -- prints the path only after the bytes are written, so it can never hand off a path without content behind it; the reservation form mat scratch-file <name> prints a path and creates nothing, so sending a reserved-but-unwritten path is refused.

To send a handoff directly to a pane, send-relay %<pane-id> <handoff-file> also works outside a tmux client, such as from an SSH shell. Role-word targets such as reviewer still require a tmux client.

By default every worktree-creating mode — adhoc, team, duo, audit, explore, caucus — runs in its own dedicated worktree or read-only snapshot, isolating it from your main checkout. On a disk-constrained machine you can turn this off for all of them at once:

Terminal window
git config --local mat.enableWorktree false # this repo
git config --global mat.enableWorktree false # every repo on this machine

Live still starts in the checkout you launched it from, and direct work stays there. Every patch task is placed automatically. An issue-backed patch task gets its own managed worktree from the latest verified default branch; uncommitted changes in the launch checkout move into it, so the launch checkout is left clean. Issue-free patch tasks are placed the same way, and a task already running in a linked worktree stays there. If the default branch or a safe task worktree cannot be resolved, the task stops instead of using the shared checkout. This live patch boundary remains in effect when the general worktree setting above is disabled.

With worktrees disabled, the write-capable modes (adhoc/team/duo) share the one main checkout, so mat refuses a second write-capable session against a repo that already has a live one. Remove an inactive, clean worktree explicitly with mat remove-worktree <path>; this also accepts a preserved live issue-patch worktree. It refuses dirty worktrees and any path still used by a live pane.

Choose a mode that matches the work:

  • Team and Duo — multi-agent issue-backed delivery with an independent reviewer gate.
  • Adhoc — single-agent issue-backed delivery.
  • Explore and Audit — investigation that produces tickets, not code.
  • Caucus — a two-agent deliberation that converges a decision into tickets.
  • Live — a hands-on single-agent session with no issue queue.

team is the 3-agent relay for issue-backed delivery in the current repo: a planner, a dev, and an independent reviewer, each in its own pane. Only the planner starts working on launch; the dev and reviewer panes start idle and wake as work reaches them.

Each backend combination gets its own stable worktree, preserved when you close the session and reused when you rerun the same team … command.

duo is the 2-agent relay: a dev and an independent reviewer. It is the leaner delivery loop when you do not need a separate planner — the dev plans and implements, and the reviewer gates the plan and the implementation. Start it with a dev and an optional reviewer backend:

Terminal window
duo # both roles on your default backend
duo claude codex # dev on claude, reviewer on codex

Like team, duo runs issue-backed delivery in the current repo and keeps a stable worktree per backend combination. See the baton & duo quickstart for a headless, tmux-free way to run it.

adhoc is the single-agent delivery path: one agent claims a ticket, plans, implements, opens the PR, and merges it. Name an existing #N on a direct turn and adhoc handles only that issue; with auto-start it selects the next ready ticket itself.

Each adhoc slot keeps a stable worktree, preserved for recovery when you close the session and reused when you rerun the same repo and slot.

explore is the open-ended investigation mode: it runs with or without a repo, in its own dedicated home, and hands its findings off as tickets rather than code. Each explore pass ends a ticket in one of three states: ready once problem, approach, and acceptance criteria are concrete; blocked, with the open question written into the ticket body and one notification to you; or closed as a duplicate. A ticket never rests on refining after the pass, and opening a ticket ends the explore session instead of launching delivery.

Explore and Audit gather concrete source and runtime evidence, test the leading conclusion, and report confirmed, not confirmed, or insufficient evidence with a next action. They report findings; they never open a ticket themselves.

For a resident, repository-scoped explorer that walks every open unready issue on its own, run mat explore --auto-refine [backend] (or -a). It selects issues with refining or no workflow label, then launches a fresh child per ticket, parks on relay wakes when idle, and never implements, opens PRs, or merges. While parked, the pane shows an explicit explore auto-refine [<backend>] parked status line — the finished child’s last screen is cleared — and the pane border counts down ↻ mm:ss to the next poll check, so a waiting worker is never mistaken for a dead one.

It skips an issue already associated with an open PR, and also leaves the issue alone when that association cannot be verified; a later pass can reconsider it. If stale-claim recovery does reclaim a lock, it leaves an issue comment naming the reclaimed lock and latest heartbeat. A comment delivery failure does not undo the recovery. A live session also receives a diagnostic after repeated heartbeat failures, while its claim remains in place for retry.

For a one-shot explore with a faster startup, use --lean; see The --lean startup profile for the contract and the prerequisite run.

Auto-refine runs under the baton driver too, and it is the one single-agent mode that does: instead of a resident child in a pane, each ticket is refined over a series of headless turns, every turn a whole process that starts and exits. Continuity across those turns comes from the ticket’s own conversation trail — each turn resumes the previous one — rather than from a living child, so a turn boundary is already a context boundary. Manual renewal (/handover, /respawn) is therefore unavailable there: it refuses rather than restarting the ticket. A ticket that never reaches an outcome is bounded by a per-ticket turn budget; when the budget runs out the ticket is marked blocked with an ## Open question explaining why, you get one notification, and the hold is released, so it is not retried automatically. Set the budget with MAT_EXPLORE_AUTO_REFINE_MAX_TURNS, and the per-turn time limit with MAT_EXPLORE_AUTO_REFINE_TURN_TIMEOUT. Pick a backend whose CLI supports headless turns; one that does not is refused at launch.

A headless refine pass can also stop and ask you something. When it does, you get the usual notification, the worker stops taking turns, and it waits — it will not carry on without your answer. Reply with:

Terminal window
mat baton send <session> explore "<your answer>"

Your text becomes the next turn, on the same ticket and with the same conversation, so the pass picks up where it paused. The reply is only accepted while that worker is running; if it has already exited, the command says so and sends nothing.

audit starts or reattaches a per-repo session in a dedicated worktree. Each pass refreshes to the latest merged code and reviews it for quality problems, filing tickets but never editing code, opening branches, or merging.

Audit runs on a cadence poller: after each pass it calls start-audit-poll and waits, waking on a newly merged PR or on a periodic broad sweep (about every 5h by default; tune it per-repo with git config --local mat.auditPollMinutes <minutes>). After five consecutive cycles without a new merge, the poller becomes dormant: it stops broad-sweep wakes but keeps watching the remote tip. A wake can file up to three quality-finding tickets and up to two CI-health tickets (five total), then the session idles again. The auditor also watches the CI surface and tickets red runs. Closing the session stops the pollers and preserves the worktree for reuse.

The supervisor recognizes both direct child completion and non-ancestral Command Code shell/tool completion. The latter carries the current child PID and capability; the supervisor validates the generation and process identity, claims it once, boundedly reaps that child, and clears the handoff record. Copied, stale, malformed, or duplicate completion signals only re-arm the poller and cannot end another child.

mat audit status reports the poller state, queued-wake depth, current child elapsed time, and the latest wake kind.

When a foreground (mat.driver local) audit’s supervised child keeps running while a wake is queued behind it, the framework escalates with notify-user --action, naming the session, the child’s elapsed time, and the number of queued wakes. It never terminates the child on its own; mat audit status shows the same stall. To recover, run mat audit revive <session> — it re-checks the stall before reaping the child, so it is a safe no-op when no stall is recorded.

Remote-tip/self-liveness heartbeat runs about every five minutes by default and is tunable with MAT_AUDIT_POLL_HEARTBEAT_MINUTES. It independently checks that the session is still alive and fetches the repository’s remote tip without waking an agent when nothing changed. When a merge is found, the session refreshes its worktree and performs checkpoint reconciliation before the next audit pass.

caucus starts a repo-scoped, 2-agent deliberation: a proposer and a challenger converge one topic to a concrete conclusion, rendered as GitHub ticket(s) — no delivery code. Each launch cuts a fresh read-only snapshot from the default branch’s tip and is deleted on session exit.

caucus always launches idle. Seed the opening topic by typing it into either pane, or non-interactively with caucus --topic-file <path>. Alternatively, caucus --backfill gives both agents a standing backlog-grooming duty and opens immediately with no typed topic.

live starts a hands-on single-agent session and reattaches on later launches: rerunning the command reattaches to the existing tmux live session when one is usable. A second supervisor is still rejected while a live state owner exists and no usable tmux session is available. Unlike the delivery modes it has no issue queue, no reviewer, and no relay — it is you and one agent working directly.

Live picks its own working posture and escalates it without asking:

  • direct — hands-on in the current directory and branch: operational commands, testing, tracked-document updates, and other requested work, including edits on the default branch when the repository enforces no CI. It has no automatic branch, commit, PR, CI, or merge requirement.
  • patch — for product-code delivery: it creates a task branch before tracked product-code edits (or any change to a repository that enforces CI), makes the change, validates, self-reviews, then makes one coherent commit with a clear message, pushes, and opens a PR. It merges only when the PR is mergeable and required CI is green. Missing or failing checks remain blockers: never merge around CI.

Live escalates to patch at the moment a tracked edit becomes necessary and either the edit changes product code or the repository enforces CI, announcing the switch rather than asking for it. Before substantial edits it records the problem, investigation, and proposed solution; this is not an approval gate.

Live’s one queue-like carve-out is issue capture: when it spots an incidental, out-of-scope concern, it may open a gh issue to record it rather than dropping it or scope-creeping. Capture only — live never implements the ticket it files.

Use live --domain <name> claude, live --domain <name> codex, or live --domain <name> commandcode to attach a configured domain toolbox home (its own instructions, skills, and runbooks) while keeping the current repository as the primary workspace. The attachment is writable and does not require an extra consent gate. Claude discovers the domain home instructions natively. Codex and commandcode receive --add-dir for access, and mat folds the domain root’s non-empty AGENTS.override.md, otherwise AGENTS.md (Codex), or .commandcode/AGENTS.md (commandcode) into one managed block in the role instructions, because neither auto-loads the domain file from --add-dir.

explore and live accept a --lean flag. Ordinary single-agent launches configured for baton collapse to the effective local path before lean admission; lean trades the per-launch snapshot isolation and provisioning work for faster startup, on the precondition that you have already prepared the agent home with one normal launch (so the home, prompt, settings, and project trust are already in place).

The prerequisite is per mode, and every refusal prints the exact command:

lean launch prerequisite (run once in this directory)
mat explore --lean [backend] mat explore --no-worktree [backend]
mat live --lean [backend] mat live [backend]
mat live --domain <name> --lean [backend] mat live --domain <name> [backend]

A plain mat explore is not sufficient: it runs inside its own detached snapshot and grants trust to that snapshot, not to the checkout a lean launch shares. mat explore --no-worktree prepares the checkout you actually run in, and works for every backend.

Lean always runs Explore in the shared checkout, so an explicit mat explore --worktree --lean combination is rejected before launch. Remove --worktree or use a normal, non-lean Explore launch; --no-worktree is accepted with lean but is redundant.

Terminal window
mat explore --no-worktree claude # once, to prepare the home
mat explore --lean claude # thereafter
mat live claude # once, to prepare the home
mat live --lean claude # thereafter
mat live --domain platform codex # once, to prepare the domain-aware home
mat live --domain platform --lean codex # thereafter

Lean shares the worktree across runs and reuses the existing home verbatim without rewriting it, with one exception: if the base render context (directory, driver, host, or another render input) differs from the identity preserved in the render-cache sidecar, lean re-renders just the constitution so the home is admitted against an artifact that matches this launch. Live domain path, source, or content drift is a proof mismatch, not a render context change, so lean refuses before rewriting the constitution, sidecar, or provisioning records. A damaged prompt or render-cache sidecar also refuses before any re-render.

After the first lean launch validates the home, later launches with nothing changed are admitted by a fast warm-path check instead of repeating the full validation; any change to the home’s managed files, its provisioning records, or the launch context falls back to the full validation first, so the fail-closed behavior is the same on both paths. The warm check compares file metadata rather than file contents, so an edit that deliberately forges a file’s size and modification time — or an in-place edit to the install tree without a version bump — goes unnoticed until the next full validation runs. Anyone with write access to the home could simply edit the cached admission record instead, so this adds no new exposure.

A lean session runs without a separate supervisor process: the launcher itself owns the session listing (mat agents still shows it), the backend child, and the cleanup on exit. Role routing, credential handling, restricted-role guards, and event logging are identical to the normal profile.

Lean refuses up front with a one-line reason when:

  • the mode is not explore or live (e.g. mat team --lean), saying --lean is supported only for \explore` and `live` (got `team`)`;
  • the effective driver is not local after mode resolution (ordinary baton-configured explore/live launches have already collapsed to local), saying --lean requires the local driver (resolved driver is \tmux`); run `mat explore –no-worktree claude` without –lean`;
  • the backend kind does not deliver an initial prompt to its native CLI, because lean’s contract reaches the agent only through that path, saying backend \X` (kind `Y`) does not deliver an initial prompt to its native CLI, and the lean contract reaches the agent only that way; run … without –lean`.

A lean launch then validates the home (the warm-path check, or the full validation whenever anything changed) and refuses with the same one-line shape whenever the home is missing, stale, or held by another live generation, so a lean launch is fail-closed: a refusal leaves the home and the operator’s session untouched. A correctly provisioned home is admitted regardless of which driver prepared it or how the install path is spelled; a launch from a different directory re-renders the constitution for that directory rather than refusing, while genuinely damaged managed files (deleted or edited credentials, skills, rendered prompt, settings, or onboarding state) always refuse with a message naming the stage.

Three more refusals protect concurrent sessions and your shared config:

  • A normal launch refuses while a lean session is running on the same agent home, saying agent home &lt;home&gt; is held by a live lean session (pid &lt;pid&gt;); provisioning it now would rewrite state that session is already running on. Exit that session, then relaunch — a normal launch rewrites the prompt, settings, and trust a running lean session depends on. Two normal launches keep their existing last-writer-wins behavior.
  • A lean launch refuses any home another live session already holds, saying agent home &lt;home&gt; is busy — held by a live &lt;profile&gt;-profile session (pid &lt;pid&gt;). Lean reuses a home rather than provisioning its own, so it cannot share one; exit that session, then relaunch. Once the other session has exited, the stale lease is pruned on contact and the launch proceeds.
  • Lean never rewrites the shared super-global ~/.claude/CLAUDE.md. The normal profile resets a symlinked or non-empty copy of that file on every launch (keeping a timestamped backup); lean’s only write into the home is the constitution re-render above, and it touches nothing outside its own lease and that artifact. If the file would be injected into the agent’s constitution, the lean launch refuses and tells you to run the prerequisite once — which resets and backs the file up — then relaunch with --lean. /handover and /wrapup archive the session as usual; /wrapup --continue and a bare context_renewal are refused under lean, because a respawn would re-enter the launcher with the same reuse-only contract and no fresh proof of the operator’s intent. The explicit context-renewal --archive-only <doc> form stays available: it validates the archived document and stops there — no respawn, on any session shape.

A lean run does not expose credentials or other secret environment values to the agent; the initial prompt is generated locally and contains only the checkout path the operator themselves named on the command line.

To run explore, live, or audit as a foreground process in the current terminal without creating a tmux session, set git config [--local|--global] mat.driver local. mat.driver baton runs those same single-agent modes the same way, with one exception: the resident auto-refine worker, which under baton has an engine of its own (see Explore mode) and runs headless in the launching terminal. Only one foreground audit runs per host at a time, and wakes are delivered into that terminal rather than into a pane. If a later launch reports the singleton as already running and the recorded supervisor’s liveness is unprovable, mat local-state clear <state-file> clears the state file named in that message; a proven-live supervisor is never bypassed.

On Windows Git Bash the driver enum is local and baton only — MSYS2 tmux is an unsupported transport, so the tmux driver is retired there. Leave mat.driver unset and you get baton, the Windows default: team, duo and seeded caucus run as baton serves, while explore, live, audit and adhoc run through the same local headless path mat.driver local gives you.

Pinning tmux explicitly — MAT_DRIVER=tmux or git config mat.driver tmux — is refused with a migration message before any session is created, so there is never a half-started session to clean up. tmux is therefore not a Windows prerequisite: the installer does not ask for it and mat doctor reports it as n/a. Linux and macOS are unchanged and still default to tmux.

The Telegram relay lets you direct and answer your agents from your phone: every agent notification arrives as a Telegram message, and your replies route straight back to the exact agent that spoke.

Follow your platform quickstart’s Telegram step — it takes you from nothing to an active message under a <host> · tg-relay header arriving in Telegram. On Linux, the relay user service is installed and started for you at install time: systemd is preferred, with OpenRC user services used when no systemd user manager is reachable. The reference below applies once the relay is running.

mat doctor reports whether your Telegram credentials are present and whether the relay service is loaded and active.

mat relay status | start | stop | restart works the same way across every platform (systemd, OpenRC, launchd, Windows) without needing to know which service manager is in play:

Terminal window
mat relay status # detected manager, verified process, singleton-lock state
mat relay start # start the registered relay (fails closed if unregistered)
mat relay stop # stop it; the registration itself stays in place
mat relay restart # stop then start under one lock

start/stop/restart only ever act on a process it has freshly verified is tg-relay — never a stale or recycled PID — and stop refuses to report success while a relay you started by hand is still running outside the manager’s view, rather than silently leaving two pollers on the same bot token. start refuses to launch an unregistered or out-of-date relay; re-run ./install.sh first if it does.

One relay per bot token. Telegram allows only ONE active long-poller per token; a second relay (another host, a stray checkout, or a configured webhook) makes both callers get HTTP 409 and your replies arrive late, out of order, dropped, or duplicated. A same-host duplicate cannot start — it exits cleanly if another relay already holds the lock — while a cross-host or webhook competitor is surfaced as a throttled log line plus a one-shot ping. The fix is operator action: ensure exactly one relay owns the token.

Every message starts with one header line saying where it came from, and the message itself follows on the next line:

<host> · <project> · <role>/<backend> · <address>[ (<reachability>)]
<message>

For example:

xps · myproj · audit/cch · #5
hello

Fields are always in this order, and a field that is unknown is left out together with its · separator. The address says how to reach the sender:

Sender Address Reachability
An agent in a tmux pane #<pane-number> — (reply to it)
A background task #<session> @<role> (or #<session>) (no reply)
A Baton or local worker you can answer #<session> @<role> —
Any other session with no pane <driver>:<session>:<role> (view only)
A session start notice #<session> —
The relay itself none (<host> · tg-relay) —
A framework action <driver>:<session>:<component> (or <component>) (view only)

Framework --action pages bind their session and component explicitly, so an operator can identify the daemon, supervisor, or poller that raised the page even when it was launched from another pane. If no session, role, or pane can be resolved, the page remains deliverable but labels its role unknown-origin instead of silently appearing as a host-only action.

(no reply) and (view only) messages cannot be answered by reply. A reachable Baton or local worker prints a typed #<session> @<role> address, so you can send that address at the start of a new message as well as reply to the message.

To talk back to an agent in a pane, reply to its message in Telegram — the relay routes your reply straight to that pane, regardless of which project was last active.

After a successful reply, plain unprefixed follow-ups stay on that same pane. If that pane has exited, the relay refuses the follow-up with recovery guidance rather than sending it somewhere else. Re-target with #<session> or #<pane-number>, or reply to a live agent message.

You can target a session directly with #<session> @<role> message. This works for tmux panes, Baton workers, and replyable local workers. @dev, @planner, and @reviewer address their matching roles; @adhoc, @audit, and @explore address the corresponding single-role local workers, while @proposer and @challenger address caucus roles. A duo/team Baton target without @<role> defaults to its developer (dev) role; a single-role Baton or local target may omit it when exactly one role resolves. Caucus targets and ambiguous local targets must name the role explicitly. An incompatible role or dead headless session is refused by session and role rather than falling back to tmux.

For a session running under tmux, a refusal tells you what tmux actually confirmed. (Baton and local driver sessions are checked without tmux and keep their own refusal wording.) “No session named …”, “has exited”, or “not running” means tmux answered and that exact session is gone. “Couldn’t confirm session … is running” means tmux could not be asked: the server is unreachable (the reason is named, including a server whose socket file went missing), tmux did not answer in time, or tmux returned an error. The session may still be alive, so try again shortly. An Available: list is added only when the session listing itself succeeded; if it failed, the reply names the cause and has no list, rather than saying none are running. send-relay on tmux makes the same distinction: no session named versus cannot confirm session with the cause.

A team session announces its panes when it starts:

xps · myproj · session · #myproj_team_ccw
started — dev #5, planner #6, reviewer #7

Reply to that start message to hand the team its first task (it lands on the developer), or address a specific agent with its pane number, e.g. #6 please review the API design to reach the planner.

Send /agents or /idle as a plain message to get the running-agents listing back in Telegram without touching a pane.

When you are away and a directive arrives over Telegram, a routable mode (team, adhoc, audit, explore, live) answers over Telegram — the substantive reply comes back as a message, per follow-up, not into a pane you cannot see — until you type into the pane directly or say you are back at the console.

Some messages come from a background task rather than a pane — a long merge or test run an agent submitted to run on its own. Those carry the session instead of a pane number, e.g.

xps · myproj · reviewer/cch · #myproj_duo_ccw_cch @reviewer (no reply)
merge completed

The identity is the submitting agent’s own, and the project is the checkout the task ran in — never whichever pane happened to be focused at the time. A background task has no pane to answer on, so replying to one is refused. To reach the agent itself, send a new message starting with the address as printed (#myproj_duo_ccw_cch @reviewer ...), or with #<pane-number>.

If you reply to a message the relay can’t place — a control message, a background task’s message, or one that has scrolled out of the index — it says so and asks you to re-send with an explicit #<session> or #<pane-number> prefix rather than guessing.

A session started with mat.driver = local has no tmux pane. A replyable local message carries a typed address instead of a pane number, e.g.

xps · myproj · adhoc/cch · #myproj_cch_adhoc @adhoc
needs review

Whether you can reply depends on whether that mode has anywhere to receive an answer. A mode without a reply inbox keeps its diagnostic local:<session>:<role> (view only) address.

  • adhoc and audit accept replies. Your reply becomes the input to that session’s next agent turn, on that exact session and role — never on whichever pane happened to be active. Plain unprefixed follow-ups then stay with that worker, the way they do after a reply to a pane.
  • mat explore --auto-refine accepts replies too. The resident refinement worker asks for a decision and keeps working, so your answer is handed to its next agent turn: the next turn on the same ticket while that ticket is still open, otherwise the first turn on the ticket it picks up next. Your reply is delivered once — and if the worker is restarted before it reads the reply, the reply is re-delivered rather than lost.
  • Interactive explore and live are display-only. Those modes run the agent directly in the terminal you launched them from, with no queue to hand an answer to, so a reply is refused rather than delivered somewhere else. Answer in the terminal instead.

If the worker has exited — or cannot be confirmed to be still running — the reply is refused by session and role rather than routed elsewhere, and you re-target with #<session> or reply to a live agent message. Nothing is delivered on a guess.

Agents notify you with notify-user:

Terminal window
notify-user [--action|-a] [--message <text>] [--] "message"

The message body is normally positional (notify-user "msg"). --action / -a marks a message that needs you to act. Every other --* token is rejected with a usage error rather than being sent as text.

Most notify-user traffic is FYI — a PR opened, a cycle finished, a queue idling. A few messages need you: a decision to make, a blocker to unblock, a review only you can perform. Those carry a leading ❗ACTION: marker at the start of the header line, so you can scan a stream of notifications and see at a glance which ones are waiting on you. Reply to a marked message the same way you reply to any other — the marker is a signal to you, not a change in routing.

You can have a second agent, such as an oncall agent on another machine, see every action message too. Set an ssh host alias that works without a password prompt:

Terminal window
git config --global mat.devopsHost <ssh-alias> # or export MAT_DEVOPS_HOST

MAT_DEVOPS_HOST overrides the git setting. After every notify-user --action send, mat runs ssh <ssh-alias> oncall-inbox and passes the message on standard input, exactly as it went to Telegram plus a final newline: the ❗ACTION: marker and header line, then the message on the following lines. Plain notify-user messages are not forwarded. When neither setting is present, nothing changes.

Format change. Earlier releases sent a single line with a pane prefix and a trailing (role - backend - project - host) group. An oncall-inbox script that parsed that shape must now read the first line as the header and the rest as the message.

  • oncall-inbox is yours to provide. mat does not ship it. It must be on the remote host’s PATH for non-interactive ssh commands, which read neither ~/.profile nor an interactive ~/.bashrc. It decides how the message reaches the oncall agent.
  • The forward does not change the notification result. Telegram delivery comes first and does not depend on the forward. Once accepted, the forward continues after the agent’s tool call ends, with a 15-second SSH limit. If a local session has no live owner to accept it, the notification log records a carrier-unavailable failure. Forward failures do not change the Telegram message or notify-user’s exit status.
  • Set MAT_DEVOPS_SELF=1 on the receiving host. Agents there then never forward to themselves, even when a copied configuration also sets the host.

You can replace the shipped prompt for a given mode with your own markdown file.

Resolution order:

  1. Project override: <project-root>/.my-ai-team/<mode>.md
  2. User-global override: ${XDG_CONFIG_HOME:-~/.config}/my-ai-team/<mode>.md — skipped unless mat.personalPromptOverride=true (opt-in)
  3. Shipped default: the <mode>.md prompt installed with mat

The project .my-ai-team directory may itself be a symlink, for example to a bundle kept in a shared dotfiles repository. mat reads it through the link and still reuses the rendered prompt across launches.

Supported mode filenames: adhoc.md, dev.md, plan.md, review.md, explore.md, audit.md, live.md, duo-dev.md, duo-review.md, caucus-proposer.md, caucus-challenger.md.

Enabling user-global overrides. The user-global (XDG) tier is off by default. To activate it — so your hand-maintained copies under ${XDG_CONFIG_HOME:-~/.config}/my-ai-team/ are used — set:

Terminal window
git config --global mat.personalPromptOverride true

Drift warning: a hand-maintained personal copy is not kept in sync automatically — it drifts as my-ai-team ships prompt changes on upgrade, producing hard-to-diagnose mismatches. Opt in only while you are actively maintaining the copies. Project-local overrides (.my-ai-team/<mode>.md) are unaffected by the toggle and always win.

The per-role personality hook (shared/personality-<role>.md, or shared/personality.md for explore) is the exception: it stays overridable from the user-global tier even when the toggle is off, because the built-in personality files are empty hooks with nothing to drift from.

To add shared house rules without replacing a role prompt, create a fragment in <project-root>/.my-ai-team/shared/ or ${XDG_CONFIG_HOME:-~/.config}/my-ai-team/shared/. The project tier always applies; the user-global tier requires mat.personalPromptOverride=true.

  • local-issue-rules.md reaches the issue-acting roles.
  • local-devops-rules.md reaches every standalone role. Its shipped fragment is empty; add only the rules your deployment needs.

For a project override, .my-ai-team may be a symlink, but its shared/ directory and fragment must be real directories and files.

When an override is active, mat names it on the resolved launch line (the (personal override) marker) so you can always tell which prompt is live.

Product skills are runtime-owned: the launcher builds each backend’s skills directory from the install on launch. To add personal, environment-specific skills — internal DB-query or ticketing helpers that reference your own infrastructure and do not belong in the product repo — drop them into an operator skills overlay:

  • Source: ${XDG_CONFIG_HOME:-~/.config}/mat/skills/, one directory per skill. There is no configurable path.

  • Non-colliding skills are always provisioned into every role home, regardless of any toggle — they carry no product-drift risk.

  • mat.personalSkillsOverride — an opt-in git boolean, off by default — governs name collisions with a product skill. Off: the overlay skill is skipped and the product skill wins (the safe default). On: the overlay skill overrides the product one for that role.

    Terminal window
    git config --global mat.personalSkillsOverride true

Adding, editing, or removing an overlay skill re-provisions role homes on the next launch, and a collision prints one line naming the outcome so the resolution is visible.

bg-run launches a long-running shell command, returns immediately, and delivers milestone and completion notifications through the current turn’s wake channel — so an agent never blocks waiting for a slow build, test run, or deploy:

Terminal window
bg-run --cmd "make integration-tests" --milestones 15m,30m,45m --max 60m

Defaults are --milestones 15m,30m,45m and --max 60m; durations accept s, m, or h. After launching, do useful work or end the turn and wait for the wake — do not sleep, poll, or re-inspect status. The wake carries the result-file path, and the persisted result JSON (task id, status, exit code, elapsed time, and stdout/stderr tails) can be read when the completion wake arrives.

After the explicit milestones, bg-run adds an informational heartbeat every 15 minutes until the task ends or reaches --max (up to a bounded number of heartbeats). Running milestone wakes identify themselves as heartbeats and say that no action is required; act only on the completion wake or a failure diagnostic. If an unusually long task exceeds the heartbeat bound, its normal completion and timeout behavior still apply, but fresh heartbeat evidence is no longer guaranteed.

Failure diagnostics. Wakes and results stay compact — they carry only a short tail of each stream. When a task ends in any non-success terminal state (failed, timeout, cancelled, or aborted), bg-run additionally preserves the task’s full stdout and stderr in local 0600 diagnostic files, so you can read the actionable failure without rerunning the command. The result and wake report seven extra fields for this:

  • stdout_log / stderr_log — the diagnostic file path for each stream (empty unless the bytes were preserved).
  • stdout_log_state / stderr_log_state — one of closed (full bytes preserved at the path), absent (the stream produced no output), unreadable (the source vanished or was only partially readable — a final state: the result still commits once, with an empty log path, and the raw provider output is kept until the retention lease), or skipped (a successful task — no diagnostic is written; success output is not preserved).
  • stdout_log_truncated / stderr_log_truncated — true when a stream exceeded the per-stream byte cap and was head+tail truncated around an explicit marker, false otherwise.
  • log_max_bytes — the per-stream cap in effect (default 10485760, i.e. 10 MiB; override with BG_RUN_LOG_MAX_BYTES, applied inclusively per stream).

Diagnostic files live under a private, per-user directory and share the result’s lifetime: they are removed only once the result ages past BG_RUN_DEDUPE_LEASE, and BG_RUN_DEDUPE_LEASE=0 keeps everything indefinitely. If a task is reclaimed before it managed to publish a result, the diagnostics are materialized before any raw source is deleted and re-anchored to the new result when one is finally written; if the result write keeps failing, the raw source, diagnostic, and wake are retained together while the publish is still retryable and removed together only at the lease boundary. The full bytes always stay on the local host — only the paths and the state fields above ever travel in a wake, result, or relay payload.

Expiry cleanup. Removing expired records and files is best-effort maintenance that runs in the background. Each submission starts at most one cleanup worker and goes straight on to admit its own task; the submission never waits for cleanup to finish. The worker skips any record another process is using and leaves it for a later submission’s worker. A worker runs for at most about two minutes before it is stopped.

Session teardown. When a session ends while a Baton task is still running, bg-run finalizes it from teardown rather than leaving it orphaned: it stops the task and commits the durable result (and diagnostics) to the promised result path. Because the originating turn is already gone, a teardown-finalized Baton task records only the result — no completion wake, envelope, or relay is sent. A local adhoc supervisor also stops its accepted tasks at teardown and commits their results with status: aborted; it retains their terminal receipts in the session inbox after clearing the rest of its temporary state. A local task is not left running after its supervisor exits. Two Baton outcomes are distinct here:

  • The result commits, diagnostics may be unreadable. If the diagnostic source cannot be read back at teardown, the result still commits once, with stdout_log_state/stderr_log_state of unreadable and an empty log path (the raw source is retained until the lease so the failure can still be investigated). The result file itself is there to read.
  • The result write itself fails. If the result cannot be written at all (for example its path is briefly unwritable), there is no committed result. The task’s retry state, raw source, and diagnostics — and any compact wake — are retained together for a later pass to finish, bounded by the same retention lease. Do not expect a result file for this task until that retry succeeds.

bg-run admits at most one in-flight task per (caller, command) pair: a second submission of the same command returns the existing task rather than launching a duplicate. A milestone or completion wake is evidence of an existing task — do not resubmit the same command on a wake. The duplicate status line keeps the machine-readable task_id=... result=... deduplicated=true prefix and adds age=<seconds>s state=<state>. age is the age of the matched admission record; for a cleared tombstone it starts when that tombstone was written, not when the original submission began. state=active means the classifier still has a live owner (including a live Baton task); uncommitted_fresh identifies the short reservation grace window before provider liveness is probed. Use --retry for a deliberate rerun.

Retries, attempt identity, and stale wakes. --retry replaces the running task behind the same admission slot. Every admission record carries three identity fields — a lineage (derived from the caller and command, so a changed command or a different caller is always an independent lineage whose wakes can never supersede or be superseded by another lineage’s), a generation epoch (fresh on first admission, lease reclaim, and post-cancel re-admission; kept by --retry), and a monotonic attempt (bumped by --retry). Every wake and result carries these fields plus the event kind, frozen at launch — so each attempt’s notifications name their own attempt.

When a replaced attempt’s wake arrives after the replacement took over, bg-run drops it by identity, not by age: a wake is rejected only when the admission record is readable, its lineage and epoch match the sender’s, and the sender’s attempt is behind the record’s. A dropped wake creates nothing — no result event, no turn, no notification — and prints one superseded diagnostic naming the rejected task and attempts; the replaced attempt’s wake and result files stay on disk until their normal retention lease expires. Wall-clock age never decides a drop: a delayed completion from the current attempt is delivered after any delay. What age can do is quiet the notification when nothing else is known: if the cycle a task was launched under has provably finished, or a very old completion arrives with no evidence that its workflow is still running, the result and wake are still written and kept — only the turn that would have announced them is skipped. Redelivering the same event stays idempotent — one turn per task/event, however many times it arrives. Records from before this scheme (no identity fields) always deliver, and a task whose record is missing or unreadable delivers too — rejection requires a live record that positively names a replacement. When available, a background task keeps the invoking Duo role’s issue association even when it runs from a worktree whose branch has no issue number. Duo Baton dev and reviewer tasks resolve that association from the first available source: an explicitly assigned issue key, the launch branch, the validated active cycle, then the submitting role’s uniquely verified live issue claim. If the claim cannot be verified as one issue, the task stays keyless. A late terminal still wakes the role after ten minutes when the issue is confirmed current; without positive current workflow evidence, an old notification may be suppressed while its result remains available.

A milestone that reaches Baton after its task has already ended does not create another wake; the completion event carries the task’s final outcome. A wake for an issue cycle that has closed is also withheld, while any queued handoff for a current cycle remains eligible for delivery.

Cycle close and delivery journal. Publishing a closed issue-cycle record also stops committed tmux and Baton tasks whose recorded workflow session and issue key both match that cycle. The close record is written first, so a late callback cannot wake the closed turn. The task publishing the boundary is spared so it can finish; a nested task has its own identity and is stopped with the other matching tasks. Tasks without an issue key and headless fallback tasks keep their existing lifetime rules.

Before a tmux wake is sent, bg-run checks that the pane still belongs to the session that launched the task. It leaves the wake file in place and skips the send if the pane has moved, disappeared, or cannot be resolved. Cycle reaps, stale and superseded suppression, and wake-delivery failures are recorded in ${BG_RUN_DEDUPE_DIR}/bg-run.log (default /tmp/bg-run.log). The private log contains no command output, is limited to 1 MiB, and keeps one rotated file at bg-run.log.1.

bg-run is available on tmux and Baton sessions, and from a child running in a live supervised local adhoc session. Local adhoc tasks are owned by the supervisor; milestone and terminal receipts go to the session inbox, and the submission returns the durable result-file path. The local provider rejects direct and non-adhoc local invocations. For a standalone headless local command, use nohup <command> >validation.log 2>&1 & disown from a foreground call and confirm from the log instead.

To deliberately stop a task before it finishes, run bg-run --cancel <task-id> (the task id from the original launch or a milestone wake, while the task is still running). This delivers a status: cancelled completion wake (or a local inbox receipt) instead of waiting for the command to finish or --max to elapse; cancelling an unknown or already-completed task id fails closed with a diagnostic and changes nothing.

win-kill-tree — stop a Windows process tree

Section titled “win-kill-tree — stop a Windows process tree”

On Windows Git Bash and MSYS2, taskkill /T follows native parent links, which an MSYS fork/exec can break, so a successful taskkill /T does not prove a Bash-launched tree is gone. win-kill-tree stops a tree and then rescans until no process it owned is left:

Terminal window
win-kill-tree --msys-pid 4242 # MSYS PID, as shown by ps
win-kill-tree --winpid 13376 # native Windows PID
win-kill-tree --tag run-abc12345 # every process whose command line contains the string
win-kill-tree --winpid 13376 --term-grace 3 --deadline 20 --passes 4

A bare number is treated as --pid, which is refused when it names both an MSYS PID and a different native PID. Options:

  • --expect-generation <id:ticks> kills only if the target still has that creation identity, so a reused PID is never touched.
  • --term-grace <seconds> sends a polite TERM to MSYS processes before the forced kill.
  • --passes <n> (default 3) and --deadline <seconds> (default 30) bound the work; the command fails rather than loop forever.
  • --detach-if-contained lets a caller that is itself inside the target tree start an independent executor instead of killing itself; --log <file> receives that executor’s output.
  • --detach always hands the kill to an independent executor, without first checking whether the caller is inside the target tree.

Limits: the tree is captured from the target while it is alive, so a process that has already detached from a dead root cannot be recovered by PID — use --tag for that. --tag matches a literal, case-sensitive substring of the process command line (at least eight non-whitespace characters) and also stops the matches’ descendants. The command never kills itself or its own ancestors.

Exit status: 0 the tree is gone (or was already absent); 1 the stop was refused, the process table was unavailable or incomplete, a live process’s identity could not be read, or owned processes survived; 2 usage error or ambiguous PID; 3 the work was handed to an independent executor and has not been confirmed. On Linux and macOS the command is not needed — ordinary process groups and signals apply.

On backends with a shell-command hook, agent sessions refuse the cleanup forms that this section warns about, before the command runs, and point at win-kill-tree:

On Linux, literal kill, pkill, killall, and fuser -k commands are first checked against session ownership. A target in the session’s cgroup subtree is allowed, including a reparented process that remains in that cgroup. If the session has no dedicated cgroup, only descendants of the session process tree are allowed. A foreign target is refused with its pid, command line, and owning systemd unit; a pattern that matches both owned and foreign processes is refused, while a pattern with no matches is allowed. This ownership check does not evaluate shell variables or command substitutions to obtain operands, and non-Linux hosts keep their existing cleanup behavior.

  • taskkill with /T (or //T), in any option order.
  • Outside the Linux ownership check (and for unresolvable Linux operands), image-name kills: taskkill /IM or /FI "IMAGENAME eq <name>", Stop-Process -Name <name>, pkill <name>, and a Where-Object Name -eq selection piped to Stop-Process. These kill every process with that name, including a CI watchdog’s sleep on a shared host.
  • Linux fuser -k forms whose namespace, variable, or other operands cannot be resolved by the ownership check remain refused as broad process cleanup.

Matching ignores case and accepts a path-qualified executable name. PID-targeted commands such as taskkill //PID 1234 //F and Stop-Process -Id 1234 stay allowed, as do win-kill-tree itself and text that only mentions these commands (quoted prose, comments, heredoc bodies). Use win-kill-tree <pid> for a known process, or start your own processes with a unique string in their command line and stop them with win-kill-tree --tag <your-unique-tag>. There is no override variable. The opencode and freebuff backends expose no hook surface, so this guard does not cover them; a command typed directly in your own shell is never filtered.

For always-on, hands-off delivery, a dispatch daemon can cold-launch or wake a delivery session whenever a repo has claimable ready issues — no manual duo / team required.

If you start a duo yourself with mat duo, the dispatcher recognizes it when the repository and backend pair match its configuration, including when the duo’s managed worktree is outside the checkout. Manually started sessions are never reaped by the dispatcher.

Watch one repo in the foreground:

Terminal window
dispatch-repo ~/projects/my-repo owner/my-repo --mode duo --interval 5

Run this command from your operator shell, outside an active delivery session.

Watch many repos at once by listing them in ${XDG_CONFIG_HOME:-~/.config}/mat/dispatch.json and running dispatch:

{
"max_active_repos": 3,
"repos": [
{ "repo": "owner/repo-a", "repo_root": "/home/user/projects/repo-a", "mode": "duo" },
{ "repo": "org/repo-b", "repo_root": "/home/user/projects/repo-b", "mode": "team",
"backends": ["opus", "sonnet"] }
]
}

max_active_repos caps how many repos run at once (idle sessions are cheap and do not count); repos over the cap are deferred and retried next tick. dispatch validates the config before starting and exits non-zero on malformed JSON, a missing repo_root, an unknown mode, or duplicate repo+mode pairs.

For a resident service instead of a foreground loop, register it with mat dispatch enable (opt-in, never auto-enabled by the installer). Its verbs are watch, enable, disable, start, stop, status, and logs.

Sessions the dispatcher owns that stay fully idle past a timeout (12 hours by default) are archived and torn down automatically; a fresh session cold-launches on the next tick that finds a ready issue. Sessions you started by hand are never reaped.

Start with the local diagnostic log, ~/.local/state/my-ai-team/mat.log ($MAT_STATE_HOME/my-ai-team/mat.log when MAT_STATE_HOME is set). Set MAT_LOG_FILE to write it somewhere else. A launcher failure, a session temp directory that cannot be created, or a mode launch that exits non-zero leaves one line there, and the line outlives the pane:

[2026-09-24T09:15:02+0000] ERROR mode: mat explore exited with status 1 (cwd /home/me/repo)

Read the newest lines with tail -n 50 ~/.local/state/my-ai-team/mat.log. The log holds diagnostics only, never credentials. It is capped at 50 MiB, and when it reaches the cap it keeps the newest half and records a truncation line.

Next, run mat doctor; it names tools, gh auth, PATH/links, backend token, Telegram credentials, and relay-unit failures in one read-only pass.

mat exits at once reporting that its launcher runtime is missing.

  • Check readlink -f ~/.local/bin/mat — it must resolve inside ~/.local/share/my-ai-team.
  • Re-run ./install.sh from an unpacked release tarball to rebuild the install.

mat starts on Windows Git Bash but a mode session fails immediately.

  • Check that tmux itself is installed and on PATH.
  • Re-run ./install.sh so ~/.local/bin/mat points back at the launcher.

mat reports that it cannot create a session temp directory.

  • Symptom: the launch stops with a mat: cannot create session temp dir ... diagnostic naming the directory and the filesystem reason.
  • Cause: the per-user session-temp root is missing and cannot be created, or the launching user cannot write there. By default mat uses a private base under ${TMPDIR:-/tmp}/mat-sessions-<uid> for the current user.
  • Fix: make that root writable, or set MAT_SESSION_TMP_DIR to an absolute directory the launching user can create and write before launching again.

gh --body-file /tmp/x.md fails with file-not-found even though /tmp/x.md was just written.

  • Symptom: a Write tool creates /tmp/x.md, but gh cannot read it. On Windows Git Bash the MSYS /tmp mount maps to %TEMP%, while the Claude Write tool (native Node) resolves /tmp to C:\tmp — two different on-disk dirs for the same path. mat doctor reports this as a tmp mount FAIL on Windows.

  • Fix: repoint the MSYS /tmp mount to a real NTFS dir so all three resolvers agree. Add one line to your per-user fstab (<msys-prefix>/etc/fstab or %USERPROFILE%\.fstab):

    C:/tmp /tmp ntfs binary,noacl,posix=0 0 0

    Create C:/tmp first if it doesn’t exist. After this, cygpath -w /tmp returns C:\tmp and the Write/Read tools, bash, and gh all land on the same file.

Windows Git Bash: agents get the Bash tool, not PowerShell

Section titled “Windows Git Bash: agents get the Bash tool, not PowerShell”
  • Symptom: a launched agent runs its shell commands through PowerShell instead of bash. Prompts and skills that assume POSIX quoting, $(...), and pipes behave differently, and the guards mat installs never fire.
  • Cause: Claude Code enables its PowerShell tool by default on Windows Git Bash, alongside the Bash tool. Its startup probe can also miss a Git installed outside the standard locations — most commonly a scoop install under ~/scoop/apps/git/<ver>/ — so an agent may serve PowerShell even though Git Bash is available.
  • What mat does: when a real bash.exe path is confirmed, it exports both CLAUDE_CODE_GIT_BASH_PATH and CLAUDE_CODE_USE_POWERSHELL_TOOL=0 into every launched Claude agent. This selects the Bash tool deterministically, so the managed matcher: "Bash" hooks fire. If no Git Bash path is available, mat leaves the PowerShell setting untouched because disabling it would leave Claude without a shell tool. mat doctor reports the decision and path.
  • Overrides: set CLAUDE_CODE_USE_POWERSHELL_TOOL=1 to opt into PowerShell deliberately; mat then leaves the shell-tool choice alone. To keep Bash with a custom install, export CLAUDE_CODE_GIT_BASH_PATH yourself to a bash.exe that exists in Windows form (with a trailing .exe).

Windows: Copilot-backed panes run shell commands through PowerShell

Section titled “Windows: Copilot-backed panes run shell commands through PowerShell”
  • Symptom: in a Copilot-backed session on Windows, the agent’s shell commands run through PowerShell — there is no bash option in the Copilot CLI.
  • What mat does: it routes Copilot’s powershell tool calls through the same command guards as bash, so the delivery-command boundaries and destructive-command guards still apply on Windows. The guards scan command text with bash-syntax rules, so a delivery command written in PowerShell-only syntax (for example $env:VAR = ... rather than export VAR=...) may not be recognized — write shell commands in bash style even though PowerShell executes them.

Windows: one Git for Git Bash and the MSYS2 shell

Section titled “Windows: one Git for Git Bash and the MSYS2 shell”
  • Symptom, in either shell: fatal: Unsupported SSL backend 'schannel' on every HTTPS fetch, push, or clone — or, from a stale credential, GitHub’s remote: Repository not found for a private repository the account can demonstrably read.
  • Cause: Git for Windows and an MSYS2-native git package can both be installed, and Git Bash and the MSYS2 shell then resolve different git.exe while sharing one global ~/.gitconfig. Git for Windows supports http.sslbackend=schannel; the MSYS2-native build links OpenSSL only and rejects that value. mat doctor reports the unsupported combination as a git ssl FAIL.
  • The supported setup is both shells resolving Git for Windows, with the shared config selecting schannel plus exactly one credential helper.

Run in the MSYS2 shell only (Git Bash already resolves Git for Windows):

Terminal window
pacman -R git # drop the MSYS2-native build
echo 'export PATH="/c/Program Files/Git/cmd:$PATH"' >> ~/.bashrc
exec bash -l # reload PATH

Verify in each shell — both must print the same executable and a version stamped .windows.:

Terminal window
type -a git
git --version # expect: git version <x.y.z>.windows.<n>

Run once from either shell (~/.gitconfig is shared, so this configures both):

Terminal window
git config --global http.sslbackend schannel
git config --global --unset-all credential.helper || true # exit 5 = nothing configured
git config --global credential.helper manager

Certificate verification stays on throughout. http.sslVerify=false is never the fix for this, and nothing in mat suggests it.

Windows MSYS2: Telegram relay fails with curl exit 60

Section titled “Windows MSYS2: Telegram relay fails with curl exit 60”
  • Symptom: no Telegram messages arrive, and the relay log (~/.local/state/my-ai-team/tg-relay.log) repeats getUpdates failed rc=60 (untrusted TLS certificate ...), retrying with a growing delay of up to 60 seconds.
  • Cause: a corporate proxy intercepts TLS and re-signs api.telegram.org with its own root CA. Windows trusts that root, so Git for Windows’ curl (Schannel) works, but when the relay runs under the MSYS2 shell, MSYS2’s curl uses its own OpenSSL trust store, which does not contain it.
  • Fix: add the proxy’s root CA to MSYS2’s trust anchors. Anchors survive ca-certificates package upgrades, but edits made directly to the bundle file do not. In PowerShell, export the root (replace <Proxy Root CA> with the issuer name shown by openssl s_client -connect api.telegram.org:443):
Terminal window
$c = Get-ChildItem Cert:\LocalMachine\Root | Where-Object Subject -match '<Proxy Root CA>'
$c | ForEach-Object { "-----BEGIN CERTIFICATE-----`n" + [Convert]::ToBase64String($_.RawData, 'InsertLineBreaks') + "`n-----END CERTIFICATE-----" } |
Set-Content C:\msys64\etc\pki\ca-trust\source\anchors\proxy-root.crt

Then, in the MSYS2 shell (adjust the path above if MSYS2 is not installed in C:\msys64):

Terminal window
update-ca-trust
curl -sS -o /dev/null -w '%{http_code}\n' https://api.telegram.org/ # expect 302

The running relay picks up the new trust store on its next poll; you do not need to restart it. Certificate verification stays on, so never disable it as a workaround.

Windows Git Bash: cmp installer prerequisite

Section titled “Windows Git Bash: cmp installer prerequisite”

install.sh needs cmp from the active shell. A Git for Windows cmp.exe does not satisfy an MSYS2 shell if its directory is not on PATH; install the MSYS2 diffutils package there:

Terminal window
pacman -S diffutils

If tmux’s socket file goes missing while its server keeps running — a crash that skipped cleanup, or an rm -rf /tmp — the next tmux new-session can silently fork a second, unreachable server at the same address, stranding the panes on the original. On Linux, mat checks the resolved default socket before every launch and, when it finds this data-loss state, tries to repair the original server before refusing the launch. A refusal names the stale pid(s) and the recovery: salvage anything needed from those panes, kill them, then relaunch. A manually rebuilt session must keep its original name — relay routing keys off the session name, not the pid. mat doctor surfaces the same classification.

If a named mat server should survive a drop to zero sessions, opt in manually:

Terminal window
tmux -L mat set-option -g exit-empty off

This keeps the server alive without a resident session; run tmux -L mat kill-server to reset it.

Terminal window
mat upgrade [--list|--to <version>|--check|--force|--when-idle|--when-idle-unbounded|--override-locked|--no-service-restart]
mat upgrade --check # report installed vs. offered version; change nothing
mat upgrade # fetch and install the latest release for your license
mat upgrade --force # reinstall even when already current
mat upgrade --list # list the versions still available to your license
mat upgrade --to <ver> # install that exact version instead of the latest

mat upgrade needs an activated machine (mat activate <LICENSE_KEY>, once per host). It validates the license, fetches a signed release tarball, and installs it in place over ~/.local/share/my-ai-team/; your configuration and agent homes are untouched. Plain mat and every mode invocation never touch the network.

Running agents keep the runtime they launched with, so upgrade between cycles. After a successful upgrade, stale managed Baton host services are refreshed and restarted when the host is quiet. The turn check is strict: only an empty mat agents --json array or rows whose status is exactly standby are idle; an unavailable or malformed status is busy. Windows verifies that every running duo’s role sessions and pollers come back after the node restart, waiting up to two minutes (MAT_INSTALL_POLLER_VERIFY_WAIT_SECONDS) for them; other Baton sessions are listed in the output but do not fail the upgrade. Windows never rewrites a running service’s launcher in place: the refreshed launcher is staged beside it while the service runs and replaces it only after the node reports itself stopped, immediately before the restart. A service left stale because the host is busy is not abandoned on Windows: the installer arms a deferred converge job for it. The job re-checks the host periodically for up to two hours (MAT_INSTALL_DEFERRED_RESTART_WAIT_SECONDS), restarts the service at the first quiet moment, verifies its duo sessions and pollers, and sends you one notification per failed attempt. If the wait bound passes with the host still busy, the job restarts the Windows node service anyway, keeping session IDs intact. Outside Windows a busy service is left running and the output gives its exact manual restart command. (The same restart is also available by hand on Windows: MAT_BATON_CONTROL_DIR='<control-dir>' mat baton service restart stops the service, publishes the staged launcher and starts it again.) If the service does not report itself stopped, it is left running on its current launcher and its sessions are untouched. This gate is host-global: an open explore, live, or caucus session is also busy even when the stale service itself has no role turn. Use --when-idle to wait for quiet before that decision. --no-service-restart skips only the Baton service restarts — the Telegram relay is still recovered by the same deferred job. On systemd and OpenRC hosts also restart the Telegram relay service so the daemon runs the code you just deployed.

To roll back, pick a version from mat upgrade --list and install it with mat upgrade --to <version>; it uses the same verified in-place path, and how far back the list reaches is bounded by the release store’s retention. Plain mat upgrade always follows the latest release and never rolls back on its own. If the installed version is the offered tag, or that tag plus a commit suffix (v3.63.0-1-gabc1234), it already covers the release; --force replaces it with the tarball. Beyond what the list holds, unpack an earlier release tarball and run its ./install.sh again. Before migrating a license to another machine, release this host’s slot with mat license deactivate.

On Windows, an in-place upgrade can hit files a running session still holds open. mat upgrade --when-idle waits for running sessions to go quiet first (up to five minutes; --when-idle-unbounded removes the ceiling). Before deciding, the installer probes the agent status with a bounded timeout (one minute by default, MAT_INSTALL_IDLE_PROBE_TIMEOUT_SECONDS); a probe that does not answer in time counts as busy, so a loaded host is never interrupted. When sessions are busy, the installer leaves the resident relay running and upgrades files in place. If the running relay could not be replaced with the installed files before the install finishes, the same deferred converge job retries the replacement (three attempts) and notifies you if it still could not. When sessions are idle, the installer briefly stops the relay and attempts an atomic directory swap. If the relay starts again during that attempt, the installer waits briefly and retries; it falls back to the in-place upgrade only if the swap still cannot complete. The relay is briefly unavailable during this idle upgrade, which normally completes with the atomic swap. If a critical file is still held, the install leaves a resumable journal and reports what it could not replace, together with the holder process names and PIDs when Windows can identify them (reported for the held path set as a whole); when it cannot, the report says attribution is unavailable. The report never stops any process. Retry when the host is idle, or use --override-locked when the failure message suggests it and you deliberately accept a mixed runtime. In an interactive terminal, the installer asks the equivalent yes/no question; --override-locked accepts that decision without prompting. The upgrade continues with files it can replace, while held files remain from the previous release. The new version is recorded even though some files are still old.

After accepting a mixed runtime, a plain mat upgrade reports that the host is already on the latest version and does nothing. Once every mat session on the host has exited, run mat upgrade --force to restore a single-version install root.

See Versioning policy for what a version number means and the upgrade/rollback contract.