# MindRoom

> AI agents that live in Matrix and work everywhere via bridges

# MindRoom Docs

# MindRoom

**AI agents that know you and your work, in a chat app anyone can use.**

MindRoom gives you a personal AI agent for everything from family trips to your homelab, and gives teams shared agents connected to their email, chat, calendar, documents, tickets, and code.
They find what you need, write what you ask for, run recurring jobs on their own, and learn from every conversation, so they get better the more they are used.
It is open source, and you choose each agent's model: a local one that keeps your private life at home, or a frontier one for hard problems.
You can run all of it yourself: the chat app, the server, and the AI backend.

<video controls playsinline preload="metadata" aria-label="Tour of MindRoom: approvals, a team thread, code, documents, and the browser" style="width: 100%">
  <source src="https://github.com/user-attachments/assets/d24be091-deb0-4be0-8346-3faa6b1e40c4#t=0.1" type="video/mp4" media="(prefers-color-scheme: dark)">
  <source src="https://github.com/user-attachments/assets/7c51fefe-a5d5-460b-ad97-d7886839af3b#t=0.1" type="video/mp4">
</video>

**[Get started](https://docs.mindroom.chat/getting-started/)** · **[See it in action](https://docs.mindroom.chat/showcase/)** · **[Coming from OpenClaw?](https://docs.mindroom.chat/openclaw/)**

## What People Use It For

=== "Personal"

    - Plan a family trip: flights, places to stay, a day-by-day itinerary, and a packing list.
    - Keep your calendar, reminders, and to-do lists in order, by voice from your phone.
    - Sort your personal email and draft replies, approving each one before it goes out.
    - Keep notes, a journal, and memories you can still find months later.
    - Track your budget, workouts, or anything else in files the agent keeps up to date.
    - Follow the topics you care about, with a digest that only arrives when there is something new.
    - Look after your homelab and smart home: check services, read logs, change configs, and control devices through Home Assistant, with approval rules for anything risky.
    - Build quick tools and scripts in the agent's own workspace.

=== "Work"

    - Find anything across email, chat, documents, tickets, and code, and see where each answer came from.
    - Get a morning briefing, or a summary of your week before every one-on-one.
    - Write status updates from what actually happened in chat and the tracker, not from what people remembered to report.
    - Turn a question into a report or an interactive presentation built from your own documents.
    - Triage the inbox, prepare for meetings, and draft emails, then approve each one before it is sent.
    - Investigate a technical question across specifications, code, and records, with every claim traced to its source.
    - Join a new team and ask its agent how the work fits together and whom to ask.

## Why MindRoom

### Agents that know you and your work

Agents connect to 100+ tools, including Gmail, Google Drive, Slack, Jira, Confluence, GitHub, and any MCP server ([Tools](https://docs.mindroom.chat/tools/), [MCP Servers](https://docs.mindroom.chat/mcp/)), and search knowledge bases built from your folders and Git repositories ([Knowledge Bases](https://docs.mindroom.chat/knowledge/)).
Each person can get a private room with their own agent ([Personal Rooms](https://docs.mindroom.chat/personal-rooms/)), each team can share agents that answer only its members ([Access Control](https://docs.mindroom.chat/authorization/)), and agents can work as a team or hand tasks to each other ([Teams](https://docs.mindroom.chat/configuration/teams/), [Agent Orchestration](https://docs.mindroom.chat/tools/agent-orchestration/)).

### Private where it matters

You choose the model for each agent.
A private agent can run on a local model through Ollama or llama.cpp, so you can share your most personal data with it, while an agent that writes code uses a frontier model from Anthropic, OpenAI, or Google ([Models](https://docs.mindroom.chat/configuration/models/)).
Pair a local model with local memory and your own server, and nothing that agent sees leaves your home ([Memory](https://docs.mindroom.chat/memory/), [Deployment](https://docs.mindroom.chat/deployment/)).
Each agent keeps its own memory, tools, and access, so what you tell a private agent stays out of shared ones, and rooms can be end-to-end encrypted ([End-to-End Encryption](https://docs.mindroom.chat/matrix/#end-to-end-encryption)).

### They remember, and keep improving

Agents keep what is worth remembering from every conversation without being asked, and look it up again when it becomes relevant ([Memory](https://docs.mindroom.chat/memory/)).
A shared agent gets better as more people use it: what it learns while helping one person helps the next.
With skill learning on, an agent also turns work it repeats into reusable skills, following the self-improvement loop of Hermes Agent ([Automatic Skill Learning](https://docs.mindroom.chat/skills/#automatic-skill-learning)).
You can teach it directly too: tell it a preference or a correction, and it is remembered.

### They work while you don't

Schedule recurring jobs in plain language, such as a daily briefing or a weekly report, including silent checks that post only when something changed ([Scheduling](https://docs.mindroom.chat/scheduling/)).
Send a voice message from your phone ([Voice Messages](https://docs.mindroom.chat/voice/)), or call an agent and talk while it hands longer tasks to background agents ([Voice Calls](https://docs.mindroom.chat/voice-calls/)).

### Safe to give real access

- Sending or changing something can wait for your one-tap approval, which shows exactly what will be sent and to whom ([Tool Approval](https://docs.mindroom.chat/tool-approval/)).
- Code runs in isolated workers ([Workers & Sandboxing](https://docs.mindroom.chat/deployment/sandbox-proxy/)), and agents can be kept off the open internet except for sites a person approves ([Approved Egress](https://docs.mindroom.chat/deployment/approved-egress/)).
- Each agent answers only the people and rooms you allow ([Access Control](https://docs.mindroom.chat/authorization/)).
- It all runs on [Matrix](https://docs.mindroom.chat/matrix/), an open, end-to-end encrypted messaging standard that governments chose for their own secure messaging: France's Tchap, the German armed forces' BwMessenger, Germany's national healthcare messenger TI-Messenger, and a messenger NATO is testing all run on it.

The [Security Model](https://docs.mindroom.chat/architecture/security-posture/) documents exactly which boundary protects what.

### A chat app built for agents

MindRoom builds its own client, MindRoom Chat, on the [web](https://chat.mindroom.chat), on [iPhone and iPad](https://docs.mindroom.chat/ios/), and on the [Mac](https://docs.mindroom.chat/installation/macos-app/), along with the server and the AI backend.
So agents are not limited to what Slack or WhatsApp can display:

- Replies stream in with every tool call shown, and you can stop them at any time ([Streaming Responses](https://docs.mindroom.chat/streaming/)).
- Approval cards and clickable questions sit right in the conversation ([Interactive Questions](https://docs.mindroom.chat/interactive/)).
- An agent can work in its own browser that you watch live and take over, for example to log in ([Worker Computer](https://docs.mindroom.chat/tools/worker-computer/#watch-take-control-and-resume)).
- An agent can use the apps and folders you select on your own computer, and runs shell commands only after you approve them ([Desktop Bridge](https://docs.mindroom.chat/tools/desktop/)).
- An agent can open an interactive page next to the conversation, such as a dashboard, a presentation, or a form you answer in place ([Interactive Canvases](https://docs.mindroom.chat/canvases/)).
- You can switch the model for a thread or a room from the chat ([Model Overrides in Chat](https://docs.mindroom.chat/configuration/models/#model-overrides-in-chat)).

### Yours to own

- **Open source:** MindRoom is Apache 2.0 licensed, so you can run the whole stack on your own hardware, or use the [hosted Matrix server](https://docs.mindroom.chat/deployment/hosted-matrix/) and run only the backend yourself.
- **Any model:** pick a model per agent, room, or thread, and switch the day a better one comes out ([Models](https://docs.mindroom.chat/configuration/models/)).
- **Federated like email:** separate MindRoom deployments, such as groups with different data rules, can still share rooms where that is allowed.
- **Extensible:** add your own tools, skills, and event hooks as plugins, which reload while MindRoom keeps running ([Plugins](https://docs.mindroom.chat/plugins/), [Hooks](https://docs.mindroom.chat/hooks/)).

### Works where you already are

Matrix bridges bring the same agents, with the same memory, to Slack, Telegram, WhatsApp, Discord, and email ([Bridges](https://docs.mindroom.chat/deployment/bridges/)).
Agents also answer any OpenAI-compatible client ([OpenAI-Compatible API](https://docs.mindroom.chat/openai-api/)), and tools like Claude Code and Codex can use them through the [MCP Gateway](https://docs.mindroom.chat/deployment/mcp-gateway/).

## How MindRoom Compares

Agents such as OpenClaw and Hermes Agent plug into existing messaging apps through one adapter per app.
That lets them reach many apps, but each app decides what the agent can show.
MindRoom takes the opposite approach.

| | Agents inside other messaging apps | MindRoom |
|---|---|---|
| Who they serve | Usually one person's assistant | Personal agents for everyone and shared agents for teams, in rooms where people and agents work together, with access control |
| What agents can show | Whatever each app allows, mostly text and simple buttons | Its own client: approval cards, live tool traces, voice calls, a browser you can take over, and interactive pages |
| How they connect | One adapter per messaging app | One open, encrypted protocol, plus bridges for reach |
| Where messages go | Through each messaging platform | End-to-end encrypted, on servers you can run yourself |

Coming from OpenClaw? MindRoom reads OpenClaw workspace files and skills; see [Import from OpenClaw](https://docs.mindroom.chat/openclaw/).

## Get Started

1. [Install & First Run](https://docs.mindroom.chat/getting-started/): run `uvx mindroom run` and pair it with your MindRoom Chat account.
2. [Configuration](https://docs.mindroom.chat/configuration/): define agents, models, and teams in `config.yaml` or the [Dashboard](https://docs.mindroom.chat/dashboard/); most changes apply without a restart.
3. [Deployment](https://docs.mindroom.chat/deployment/): run MindRoom with Docker or Kubernetes when you are ready to share it.

# Showcase

Every video on this page is a recording of MindRoom Chat talking to a MindRoom backend.
The model responses and external services are scripted so each recording is repeatable; the rooms, threads, approvals, streaming, and tool traces are MindRoom's own.
Each video comes in a light and a dark recording to match your color scheme.

## Films

=== "Film · 50 s"

    Approvals, a team thread, code, documents, and the browser at work, then lights, trips, and dinners at home.

    <video class="only-light" controls playsinline preload="none" poster="https://github.com/user-attachments/assets/e3ce6dae-b730-41cc-a1e9-a7292e8e5766" aria-label="MindRoom at work and at home" style="width: 100%">
      <source src="https://github.com/user-attachments/assets/383e9556-af82-4b4c-bdd4-8ba894481eec" type="video/mp4">
    </video>
    <video class="only-dark" controls playsinline preload="none" poster="https://github.com/user-attachments/assets/70e6d774-5f5c-4d2c-862d-a992136dfd73" aria-label="MindRoom at work and at home" style="width: 100%">
      <source src="https://github.com/user-attachments/assets/2c3227cf-0cea-475f-8c27-959a21504348" type="video/mp4">
    </video>

=== "Short · 29 s"

    The same story with two moments at work and two at home, as in the README.

    <video class="only-light" controls playsinline preload="none" poster="https://github.com/user-attachments/assets/47832724-c39c-4a5f-86e6-0eba3aefde19" aria-label="The short version" style="width: 100%">
      <source src="https://github.com/user-attachments/assets/41418624-06e8-43f4-8c70-a5c7558de84a" type="video/mp4">
    </video>
    <video class="only-dark" controls playsinline preload="none" poster="https://github.com/user-attachments/assets/13bf22c5-5ca4-4677-b31e-595080773845" aria-label="The short version" style="width: 100%">
      <source src="https://github.com/user-attachments/assets/665d730b-83b3-42b6-aa98-7dde35f16244" type="video/mp4">
    </video>

=== "Work tour · 82 s"

    Thirteen moments at work, one after another, in an earlier full-screen style.

    <video class="only-light" controls playsinline preload="none" poster="https://github.com/user-attachments/assets/db242573-e9f9-4040-96c2-f8888172cbc1" aria-label="Narrated tour" style="width: 100%">
      <source src="https://github.com/user-attachments/assets/7c51fefe-a5d5-460b-ad97-d7886839af3b" type="video/mp4">
    </video>
    <video class="only-dark" controls playsinline preload="none" poster="https://github.com/user-attachments/assets/a7a759d9-f371-4a16-ba36-43bd4cf010a5" aria-label="Narrated tour" style="width: 100%">
      <source src="https://github.com/user-attachments/assets/d24be091-deb0-4be0-8346-3faa6b1e40c4" type="video/mp4">
    </video>

## Canvases

Agents can show a page they made beside the conversation and act on what you pick in it.
See [Interactive Canvases](https://docs.mindroom.chat/canvases/).

=== "Overview · 27 s"

    A narrated film of all four: a scheduling grid, a data fit, a slide deck, and a trip planner.

    <video class="only-light" controls playsinline preload="none" poster="https://github.com/user-attachments/assets/77e36a87-9fec-41f9-b626-73b950003845" aria-label="Canvas film: four agent canvases beside the conversation" style="width: 100%">
      <source src="https://github.com/user-attachments/assets/381f7395-4f1b-4167-8749-7f85080da8ce" type="video/mp4">
    </video>
    <video class="only-dark" controls playsinline preload="none" poster="https://github.com/user-attachments/assets/d2b7a4a6-0723-4d22-af2a-6b2ee7213602" aria-label="Canvas film: four agent canvases beside the conversation" style="width: 100%">
      <source src="https://github.com/user-attachments/assets/1a820f71-dd19-415f-9301-611310c3d510" type="video/mp4">
    </video>

=== "Scheduling"

    A week grid of four people's availability; the pick goes back with **Send**, and the agent books the meeting after approval.

    <video controls playsinline preload="metadata" aria-label="An agent shows a week grid; the user picks a slot and sends it, and the agent books it" style="width: 100%">
      <source src="https://github.com/user-attachments/assets/12aed795-793e-4de2-96fa-979f04d9545f#t=0.1" type="video/mp4" media="(prefers-color-scheme: dark)">
      <source src="https://github.com/user-attachments/assets/8fcc4a9d-73f6-4fd6-9b13-c5f456397cbc#t=0.1" type="video/mp4">
    </video>

=== "Data"

    An interactive fit drawn in inline SVG with the theme variables; leaving out an outlier refits the curve live.

    <video controls playsinline preload="metadata" aria-label="An agent shows a data fit; the user leaves out an outlier and sends back the fit" style="width: 100%">
      <source src="https://github.com/user-attachments/assets/28b4b4a2-fc73-4521-b93e-ff6dd8b35aa5#t=0.1" type="video/mp4" media="(prefers-color-scheme: dark)">
      <source src="https://github.com/user-attachments/assets/6abc66b1-73ae-4692-9c7c-0bdb986bac73#t=0.1" type="video/mp4">
    </video>

=== "Slides"

    A deck the agent writes to a workspace file and shows by `path`; after a change request it rewrites the file and the open deck updates in place.

    <video controls playsinline preload="metadata" aria-label="An agent shows a slide deck from its workspace and updates it in place after a change request" style="width: 100%">
      <source src="https://github.com/user-attachments/assets/15d64a9a-cf64-4f60-89ea-9249ea35bee0#t=0.1" type="video/mp4" media="(prefers-color-scheme: dark)">
      <source src="https://github.com/user-attachments/assets/bfe89d42-9d05-4554-b0dc-d731d73f9da3#t=0.1" type="video/mp4">
    </video>

=== "Trip planner"

    One canvas, two steps: pick a stay, then plan the days against a live budget, updated in place with `canvas_event_id`.

    <video controls playsinline preload="metadata" aria-label="One canvas, two steps: pick a stay, then plan the days against a live budget" style="width: 100%">
      <source src="https://github.com/user-attachments/assets/02f40dbd-6e39-463a-831d-d941ef32ecf9#t=0.1" type="video/mp4" media="(prefers-color-scheme: dark)">
      <source src="https://github.com/user-attachments/assets/e9c04cf1-74c2-48de-866b-b43ac529146d#t=0.1" type="video/mp4">
    </video>

## At work

### Ask, approve, done

An agent finds two requests in the inbox, picks a free slot, and pauses for approval before it books the meeting.
See [Tool Approval](https://docs.mindroom.chat/tool-approval/#tool-approval).

<video controls playsinline preload="metadata" aria-label="Ask, approve, done" style="width: 100%">
  <source src="https://github.com/user-attachments/assets/6a2033ea-3354-4afd-9d58-fc617b18cd24#t=0.1" type="video/mp4" media="(prefers-color-scheme: dark)">
  <source src="https://github.com/user-attachments/assets/d62d98e8-c066-4e1f-8a8f-d840da7b0bd1#t=0.1" type="video/mp4">
</video>

### One thread, the whole team

Three colleagues work with one agent in a shared thread, and everyone sees each answer stream in live.
See [Streaming Responses](https://docs.mindroom.chat/streaming/).

<video controls playsinline preload="metadata" aria-label="One thread, the whole team" style="width: 100%">
  <source src="https://github.com/user-attachments/assets/b5772e74-de93-4559-8f4f-b045add92caa#t=0.1" type="video/mp4" media="(prefers-color-scheme: dark)">
  <source src="https://github.com/user-attachments/assets/ec8dc609-e4b5-4db4-b806-d8c93fee52a0#t=0.1" type="video/mp4">
</video>

### Bring the team in

New colleagues are invited to a room, and the agent tells them what is left before the deadline.
See [Authorization](https://docs.mindroom.chat/authorization/).

<video controls playsinline preload="metadata" aria-label="Bring the team in" style="width: 100%">
  <source src="https://github.com/user-attachments/assets/a98cf35e-9c3d-40b8-9827-7f5d2a4dbc28#t=0.1" type="video/mp4" media="(prefers-color-scheme: dark)">
  <source src="https://github.com/user-attachments/assets/6ac07018-9961-465a-b38d-a3a34eba75b6#t=0.1" type="video/mp4">
</video>

### Tag two agents, get a team

Mentioning two agents in one message forms a team that researches and answers together.
See [Team Configuration](https://docs.mindroom.chat/configuration/teams/).

<video controls playsinline preload="metadata" aria-label="Tag two agents, get a team" style="width: 100%">
  <source src="https://github.com/user-attachments/assets/b6ea7dff-8542-409f-823a-0cd3590a9555#t=0.1" type="video/mp4" media="(prefers-color-scheme: dark)">
  <source src="https://github.com/user-attachments/assets/3c28159d-4d3e-4e47-bc5e-07e4e0c5eed1#t=0.1" type="video/mp4">
</video>

### From alert to all-clear

One agent traces failing signups to a deploy, rolls it back with approval, and hands the customer update to another agent.
See [Agent conversations](https://docs.mindroom.chat/tools/matrix-message/#agent-conversations).

<video controls playsinline preload="metadata" aria-label="From alert to all-clear" style="width: 100%">
  <source src="https://github.com/user-attachments/assets/845b92f1-fa94-4aab-af0c-381d3cab5fb1#t=0.1" type="video/mp4" media="(prefers-color-scheme: dark)">
  <source src="https://github.com/user-attachments/assets/b9b9cb8d-e334-4319-8b33-d6a885d11580#t=0.1" type="video/mp4">
</video>

### Connect once, then just ask

A user connects an account from the conversation, and the agent answers from it.
See [OAuth Integration Framework](https://docs.mindroom.chat/oauth-framework/).

<video controls playsinline preload="metadata" aria-label="Connect once, then just ask" style="width: 100%">
  <source src="https://github.com/user-attachments/assets/9c301417-f0ee-43d1-ac42-8c974d42888f#t=0.1" type="video/mp4" media="(prefers-color-scheme: dark)">
  <source src="https://github.com/user-attachments/assets/4689c253-d026-4dee-afb8-7f178ea793df#t=0.1" type="video/mp4">
</video>

### What the policy actually says

The agent answers a travel question from Confluence and Drive, with its sources.
See [Atlassian Cloud](https://docs.mindroom.chat/tools/atlassian/).

<video controls playsinline preload="metadata" aria-label="What the policy actually says" style="width: 100%">
  <source src="https://github.com/user-attachments/assets/0dee9f09-80e1-4244-bac3-4da13072bab4#t=0.1" type="video/mp4" media="(prefers-color-scheme: dark)">
  <source src="https://github.com/user-attachments/assets/a2f812aa-bd2c-4996-bc13-f41b2f3a8f94#t=0.1" type="video/mp4">
</video>

### Answers you can trust

The agent compares two Drive documents and flags a stale number before a drawing goes out.
See [Google Drive](https://docs.mindroom.chat/tools/data-and-databases/#google_drive).

<video controls playsinline preload="metadata" aria-label="Answers you can trust" style="width: 100%">
  <source src="https://github.com/user-attachments/assets/b8f28170-351a-4c6a-b49a-f5b6bd2a3df1#t=0.1" type="video/mp4" media="(prefers-color-scheme: dark)">
  <source src="https://github.com/user-attachments/assets/58281caf-f956-4584-87ec-15bd2a2242cb#t=0.1" type="video/mp4">
</video>

### Two policies, one draft

The agent merges two travel policies into one draft and asks a multiple-choice question where they disagree.
See [Interactive Q&A](https://docs.mindroom.chat/interactive/).

<video controls playsinline preload="metadata" aria-label="Two policies, one draft" style="width: 100%">
  <source src="https://github.com/user-attachments/assets/14da1dfa-9892-4ce8-a2ed-ad67673ea4b5#t=0.1" type="video/mp4" media="(prefers-color-scheme: dark)">
  <source src="https://github.com/user-attachments/assets/9cef359e-15d0-4f0a-810a-ec34811cf561#t=0.1" type="video/mp4">
</video>

### From numbers to a board deck

The agent gathers the quarter's numbers and the launch status and builds a five-slide board deck.
See [Execution & Coding](https://docs.mindroom.chat/tools/execution-and-coding/).

<video controls playsinline preload="metadata" aria-label="From numbers to a board deck" style="width: 100%">
  <source src="https://github.com/user-attachments/assets/d12003ab-9537-48fb-9200-025e89078ec1#t=0.1" type="video/mp4" media="(prefers-color-scheme: dark)">
  <source src="https://github.com/user-attachments/assets/c599de91-25d6-4cce-b6f2-c7b288dfeece#t=0.1" type="video/mp4">
</video>

### Real analysis, real numbers

The agent writes and runs an analysis script on its own computer and posts the fitted result with a plot.
See [Execution & Coding](https://docs.mindroom.chat/tools/execution-and-coding/).

<video controls playsinline preload="metadata" aria-label="Real analysis, real numbers" style="width: 100%">
  <source src="https://github.com/user-attachments/assets/1bc1dc4d-742f-490b-a177-3679ae412ec2#t=0.1" type="video/mp4" media="(prefers-color-scheme: dark)">
  <source src="https://github.com/user-attachments/assets/ac87d6b3-a82a-491d-ae5c-7d114f55539f#t=0.1" type="video/mp4">
</video>

### It drives, you take the wheel

The agent reorders supplies in its browser while the user watches, takes control, and hands it back.
See [Worker Computer](https://docs.mindroom.chat/tools/worker-computer/#watch-take-control-and-resume).

<video controls playsinline preload="metadata" aria-label="It drives, you take the wheel" style="width: 100%">
  <source src="https://github.com/user-attachments/assets/63868e17-6824-4073-8904-e1b7abc4b173#t=0.1" type="video/mp4" media="(prefers-color-scheme: dark)">
  <source src="https://github.com/user-attachments/assets/cc079b2f-6dbf-4509-9fdc-4ec21da85d7a#t=0.1" type="video/mp4">
</video>

### Your morning, already handled

A scheduled task posts a morning brief.
See [Scheduling](https://docs.mindroom.chat/scheduling/).

<video controls playsinline preload="metadata" aria-label="Your morning, already handled" style="width: 100%">
  <source src="https://github.com/user-attachments/assets/cfbbac8e-6942-4bac-a920-ac3946951174#t=0.1" type="video/mp4" media="(prefers-color-scheme: dark)">
  <source src="https://github.com/user-attachments/assets/a3bacdb7-7d59-42d2-8d0f-33f391c412b2#t=0.1" type="video/mp4">
</video>

### Nothing falls through the cracks

A check asked for in conversation becomes a weekly scheduled report.
See [Scheduler tool](https://docs.mindroom.chat/scheduling/#scheduler).

<video controls playsinline preload="metadata" aria-label="Nothing falls through the cracks" style="width: 100%">
  <source src="https://github.com/user-attachments/assets/41986747-dfb3-41cd-b3c6-b60f8eabdab8#t=0.1" type="video/mp4" media="(prefers-color-scheme: dark)">
  <source src="https://github.com/user-attachments/assets/d8729fbc-7377-4b80-b3c2-c39394eb19a4#t=0.1" type="video/mp4">
</video>

### Say it, and it's handled

A voice message between meetings becomes a reminder.
See [Voice Messages](https://docs.mindroom.chat/voice/).

<video controls playsinline preload="metadata" aria-label="Say it, and it's handled" style="width: 100%">
  <source src="https://github.com/user-attachments/assets/8abba58e-790f-4c80-a57d-62ab683ea18c#t=0.1" type="video/mp4" media="(prefers-color-scheme: dark)">
  <source src="https://github.com/user-attachments/assets/7bf1bb2f-31c7-4ef4-ac07-4ec1b22b2da1#t=0.1" type="video/mp4">
</video>

## At home

### Lock up on the way out

A voice note from a phone locks up, turns off the lights, and lowers the heating.
See [Home Assistant](https://docs.mindroom.chat/tools/location-commerce-and-home/#homeassistant).

<video controls playsinline preload="metadata" aria-label="Lock up on the way out" style="width: 100%">
  <source src="https://github.com/user-attachments/assets/ae8bf099-0f11-4097-8de1-de17efbb7bc8#t=0.1" type="video/mp4" media="(prefers-color-scheme: dark)">
  <source src="https://github.com/user-attachments/assets/01e6b9c7-db21-49e9-897c-d03ced90ee14#t=0.1" type="video/mp4">
</video>

### A weekend away, planned together

Two people plan a weekend trip, and the agent checks the calendar, offers options, and books the one they pick.
See [Google Calendar](https://docs.mindroom.chat/tools/calendar-and-scheduling/#google_calendar).

<video controls playsinline preload="metadata" aria-label="A weekend away, planned together" style="width: 100%">
  <source src="https://github.com/user-attachments/assets/4fea9538-5d22-43b4-9147-7e8b496400a8#t=0.1" type="video/mp4" media="(prefers-color-scheme: dark)">
  <source src="https://github.com/user-attachments/assets/ba40bcb7-19aa-4085-9c28-e99f1d171320#t=0.1" type="video/mp4">
</video>

### Dinners that fit everyone

The agent remembers each person's diet and plans the week's dinners.
See [Memory System](https://docs.mindroom.chat/memory/).

<video controls playsinline preload="metadata" aria-label="Dinners that fit everyone" style="width: 100%">
  <source src="https://github.com/user-attachments/assets/75a32c85-7528-4b72-b268-0de8ec17f794#t=0.1" type="video/mp4" media="(prefers-color-scheme: dark)">
  <source src="https://github.com/user-attachments/assets/c4b6f6e3-03a9-40fd-808b-0d18abdaf263#t=0.1" type="video/mp4">
</video>

# Getting Started

This page installs MindRoom and gets a first agent answering in chat.
Pick one setup:

| Setup | Use when |
| --- | --- |
| [Hosted Matrix + local MindRoom](#recommended-hosted-matrix-local-mindroom-uvx-only) (recommended) | You want the simplest start: you run only MindRoom, and Matrix and the chat UI are hosted at `mindroom.chat` |
| [NixOS LXC container](#preferred-alternative-nixos-lxc-container-agent-controlled-machine) | You want an agent to fully manage its own persistent machine while the host controls what it can see |
| [Full stack Docker Compose](#alternative-full-stack-docker-compose) | You want everything local: dashboard, Matrix homeserver, and chat client |
| [Manual install](#manual-install-with-your-own-matrix-homeserver) | You already run a Matrix homeserver |



## Recommended: Hosted Matrix + Local MindRoom (`uvx` only)

The Matrix homeserver is hosted at `mindroom.chat` and the chat UI at `chat.mindroom.chat`; only MindRoom runs on your machine.
Watch the 2-minute setup video:

[![MindRoom: installing and talking to my first AI agent in 2 minutes](https://img.youtube.com/vi/jR3xLUxyWhg/maxresdefault.jpg)](https://youtu.be/jR3xLUxyWhg)

**Prerequisite:** Install [uv](https://docs.astral.sh/uv/getting-started/installation/).

### 1. Run MindRoom

```bash
uvx mindroom run
```

When no config exists and MindRoom runs in a terminal, it asks a few setup questions:

1. Choose a model provider preset (default `openai`; the choices are listed under [`config init`](https://docs.mindroom.chat/cli/#config-init)).
2. For `anthropic`, `openai`, or `openrouter`, paste the API key; input is hidden.
   Press Enter to skip, then connect the provider later through the [dashboard](https://docs.mindroom.chat/dashboard/#connect-your-ai-provider), or add the key to `~/.mindroom/.env` and restart `mindroom run`.
   MindRoom does not ask when the key is already set in your environment.
   Other presets print their remaining step instead, such as `codex login`, `ollama pull`, or the Azure, Bedrock, or Vertex AI settings to add to `~/.mindroom/.env` before restarting.

MindRoom then writes `~/.mindroom/config.yaml` and `~/.mindroom/.env` with hosted Matrix defaults (`MATRIX_HOMESERVER=https://mindroom.chat`) and a starter `Mind` agent in a `Personal` room.

### 2. Approve pairing

MindRoom prints a pairing link and QR code.
Open the link or scan the QR code with your MindRoom Chat account to approve, or enter the displayed code in MindRoom Chat → Settings → Local MindRoom.
After approval, MindRoom prints the approving account and asks `Is this your account? [Y/n]` before saving anything.
Pair codes expire after 10 minutes, and `mindroom run` prints a new one when that happens.
See [connect](https://docs.mindroom.chat/deployment/hosted-matrix/#connect) for pairing details, the credentials it saves, and how to revoke a pairing.

### 3. Choose how MindRoom keeps running

After pairing, first-run setup asks whether to run MindRoom in the background and start it at login, as a systemd user service on Linux or a launchd agent on macOS.
Pressing Enter installs and starts the service, the same as `mindroom service install`, and returns to your shell.
Check it with `mindroom service status`, and run `mindroom service restart` after editing `~/.mindroom/.env`.
Answering `n` runs MindRoom and the dashboard in the terminal instead, which also happens when the service cannot be installed.
See [`mindroom service`](https://docs.mindroom.chat/cli/#service) for when the question is skipped.

### Set up without a terminal

Without a terminal (services, Docker, the macOS app), a missing config is an error that points to `mindroom config init`, unless you pass `--provider`.
Flags answer the setup questions up front, which lets a script or coding agent set up MindRoom:

```bash
OPENAI_API_KEY=sk-... uvx mindroom run --provider openai --service
```

`--provider` picks the preset and the API key comes from the environment.
`--service` installs the login service after pairing, and `--no-service` skips the question.
MindRoom prints the pairing link and code for a person to approve, and prints the approving account instead of asking about it.

### Create the config first with `config init`

Use `config init` to review the files before starting, to choose a preset without prompts, or to use a self-hosted homeserver:

```bash
uvx mindroom config init --provider anthropic
$EDITOR ~/.mindroom/.env
uvx mindroom run
```

The generated `.env` lists the credentials the chosen preset needs; set them before running.
See [`config init`](https://docs.mindroom.chat/cli/#config-init) for every option and the extra steps for the `codex`, `kimi`, `ollama`, and `llama.cpp` presets, and [Model Environment Variables](https://docs.mindroom.chat/configuration/models/#environment-variables) for each provider's credentials.

### Verify

- **Chat:** open `https://chat.mindroom.chat` and send `@mind hello` in the `Personal` room.
- **Dashboard:** open `http://localhost:8765` to configure agents, models, and tools.
  It asks for the `MINDROOM_API_KEY` that setup generated in `~/.mindroom/.env`, because `mindroom run` serves the dashboard on every network interface by default.
- **Preflight check:** `uvx mindroom doctor` checks config, API keys, Matrix connectivity, pairing, and storage in one pass; see [doctor](https://docs.mindroom.chat/cli/#doctor).

See [Hosted Matrix + Local Backend](https://docs.mindroom.chat/deployment/hosted-matrix/) for what runs where and the credential and trust model.
To run worker-routed execution tools such as `coding`, `docker`, `file`, `python`, and `shell` in dedicated Docker workers on the same machine, see [Sandbox Proxy Isolation](https://docs.mindroom.chat/deployment/sandbox-proxy/).

## Preferred alternative: NixOS LXC container (agent-controlled machine)

Use this when you want to give a MindRoom agent full freedom over its own virtual machine while you, from the host, control precisely what it can see.
The [mindroom-ai/lxc-nixos](https://github.com/mindroom-ai/lxc-nixos) flake provisions an Incus LXC system container running NixOS with MindRoom, the Tuwunel Matrix homeserver, MindRoom Chat, and Caddy, plus Docker and `ragenix`-based secrets.
Because the whole machine is declared in the flake, the agent can rebuild and manage the persistent system it runs on without touching the host, unlike the mostly stateless Docker Compose stack.
It is harder to set up by hand, but a coding agent such as Codex or Claude Code can follow the repo's `AGENTS.md` runbook directly.

It requires a Linux host running [Incus](https://linuxcontainers.org/incus/docs/main/installing/):

```bash
git clone https://github.com/mindroom-ai/lxc-nixos.git
cd lxc-nixos
incus launch images:nixos/unstable mindroom -c security.nesting=true
incus config device add mindroom repo disk source="$PWD" path=/mnt/repo shift=true
```

Then follow the repo's `AGENTS.md` runbook for operator SSH keys, secrets bootstrap, and the `nixos-rebuild switch` deployment.

## Alternative: Full Stack Docker Compose

Use this when you want everything local: the MindRoom dashboard, a Matrix homeserver, and the MindRoom Chat client in one stack.
It requires Docker and Docker Compose.

```bash
git clone https://github.com/mindroom-ai/mindroom-stack
cd mindroom-stack
cp .env.example .env
$EDITOR .env  # set ANTHROPIC_API_KEY for the default stack config
./scripts/quickstart.py
```

The quickstart validates provider configuration, starts the stack, waits until it is ready, and explains common port and startup failures.
The default stack config uses Anthropic and requires `ANTHROPIC_API_KEY`.
To use another provider, edit `config/config.yaml` and supply its credentials before starting; see the [stack model configuration guide](https://github.com/mindroom-ai/mindroom-stack#configure-models).

The stack serves the MindRoom dashboard at `http://localhost:8765`, the MindRoom Chat client at `http://localhost:8080`, and Matrix at `http://localhost:8008`, using the published `mindroom`, `mindroom-chat`, and `mindroom-tuwunel` images.
To reach it from another device, set `CLIENT_HOMESERVER_URL=http://<host-ip>:8008` in `.env` before starting.
See the [stack README](https://github.com/mindroom-ai/mindroom-stack) for running `docker compose` directly, changing host ports, and pinning images.

## Manual install with your own Matrix homeserver

Use this if you already have a Matrix homeserver and want to run MindRoom directly.

Prerequisites:

- Python 3.12 or higher.
- A Matrix homeserver where MindRoom can create agent accounts (see [Matrix account provisioning](#matrix-account-provisioning)).
- Credentials for at least one AI provider.

### Installation

=== "uv (recommended)"

    ```bash
    uv tool install mindroom
    ```

=== "pip"

    ```bash
    pip install mindroom
    ```

=== "From source"

    ```bash
    git clone https://github.com/mindroom-ai/mindroom
    cd mindroom
    uv sync
    source .venv/bin/activate
    ```

    Install Node.js 24 and [Bun](https://bun.sh/) to build missing dashboard assets on first run, or set `MINDROOM_FRONTEND_DIST` to a prebuilt dashboard directory.
    Without assets and Bun, the API is available but the dashboard is not.

### Configuration

Generate a starter with `mindroom config init --matrix-server self-hosted`, or write `config.yaml` yourself.
A generated config contains `__MINDROOM_OWNER_USER_ID_FROM_PAIRING__` placeholders; replace each with your quoted Matrix user ID, such as `"@alice:matrix.example.com"`, because only hosted pairing fills them in.
See [Configuration](https://docs.mindroom.chat/configuration/#configuration-file) for where MindRoom looks for `config.yaml`.

A minimal hand-written `config.yaml`:

```yaml
agents:
  assistant:
    display_name: Assistant
    role: A helpful AI assistant that can answer questions
    model: default
    rooms: [lobby]

models:
  default:
    provider: openai
    id: gpt-6-astra

defaults:
  tools: [scheduler]
  markdown: true

administrators:
  - "@alice:matrix.example.com"
room_defaults:
  invite_users:
    - "@alice:matrix.example.com"

timezone: America/Los_Angeles
```

Put credentials in a `.env` next to `config.yaml`:

```bash
MATRIX_HOMESERVER=https://matrix.example.com

# Recommended when the homeserver supports token-gated registration.
# MATRIX_REGISTRATION_TOKEN=your-registration-token

# Optional: for self-signed certificates (development)
# MATRIX_SSL_VERIFY=false

# Optional: when server_name differs from the homeserver hostname
# MATRIX_SERVER_NAME=example.com

# AI provider API keys
OPENAI_API_KEY=your_openai_key
# ANTHROPIC_API_KEY=your_anthropic_key
# OPENROUTER_API_KEY=your_openrouter_key

# Dashboard API key; generate one with `openssl rand -hex 32`.
# Set it empty (MINDROOM_API_KEY=) only with `--api-host 127.0.0.1`; `mindroom run` listens on every interface by default.
MINDROOM_API_KEY=replace-with-a-long-random-secret
```

See [Environment Variables](https://docs.mindroom.chat/configuration/#environment-variables) for every setting.

### Matrix account provisioning

MindRoom creates a Matrix account for each agent, team, and the router.
Configure one way for it to do so: hosted provisioning, `MATRIX_REGISTRATION_TOKEN`, `MATRIX_REGISTRATION_SHARED_SECRET`, an [application service](https://docs.mindroom.chat/matrix/#agent-users), or intentionally open registration.
If registration fails, check the selected credentials and the homeserver's registration policy.

### Optional: local Synapse and MindRoom Chat with Docker

On Linux or macOS, `local-stack-setup` starts Synapse and a MindRoom Chat container from the core MindRoom repository's `local/matrix` Compose files and writes the matching Matrix settings to the `.env` next to your active config:

```bash
mindroom local-stack-setup --synapse-dir /path/to/mindroom/local/matrix
```

From a source checkout, run `uv run mindroom local-stack-setup --synapse-dir local/matrix`.
With a custom config path, export `MINDROOM_CONFIG_PATH` first so it updates the matching `.env`.
See [local-stack-setup](https://docs.mindroom.chat/cli/#local-stack-setup) for its options.

### Run MindRoom

```bash
mindroom run
```

MindRoom connects to the homeserver, creates Matrix users for each agent, creates and joins the configured rooms, and starts answering messages.
See [`mindroom run`](https://docs.mindroom.chat/cli/#run) for options such as `--config` and `--storage-path`.

## Next Steps

- Learn about [agent configuration](https://docs.mindroom.chat/configuration/agents/)
- Learn about [OpenClaw workspace import](https://docs.mindroom.chat/openclaw/) if you want file-based memory/context patterns
- Explore [available tools](https://docs.mindroom.chat/tools/)
- Set up [teams for multi-agent collaboration](https://docs.mindroom.chat/configuration/teams/)

# macOS App

The MindRoom macOS app is a native window with a menu bar companion for two independent roles:

- **Local agents** run MindRoom on this Mac and connect to your Matrix chat account.
- **Computer access** lets a paired agent running elsewhere observe or control selected applications, read selected folders, and request shell commands that you approve on this Mac.

Use either role, or both; starting one does not start the other.

## Requirements

- macOS 14 Sonoma or later on an Apple silicon Mac; Intel Macs are not supported.
- Network access to install the MindRoom runtime and reach your Matrix server.
- For local agents, a model provider credential, local model, or supported provider login.
- For computer access, a Desktop-enabled MindRoom agent; application access also needs the macOS permissions in the [Desktop guide](https://docs.mindroom.chat/tools/desktop/).

## Install

```bash
brew install --cask mindroom-ai/tap/mindroom
```

Open **MindRoom** from Applications or Spotlight.
The window has **Overview**, **Chat**, **Dashboard**, **Local agents**, **Computer access**, and **Settings** sections, and Overview shows the status of each role.

## Chat in the App

**Chat** opens your chat client inside the app.
Sign in once; the app remembers that session separately from your browser, and switching sections keeps your conversation and draft.
The default chat website is `https://chat.mindroom.chat`; for a self-hosted client, enter an HTTPS URL or a loopback HTTP URL in **Settings** > **Chat website** and select **Save Chat Website**.
Use **Open in Browser** to use your browser session or a sign-in provider that requires a browser.
**Reload** returns to the configured chat website, or reloads the approval page while you connect local agents.

## Set Up Local Agents

**Local agents** has four numbered steps, each with its own status.
The app opens at the first step that needs attention: **Install** when the runtime needs installing or updating, **Check** when configuration exists without a service, and **Start** when a service is installed.

1. **Install** finds an existing `mindroom` executable, including a terminal installation, or installs the runtime release that matches the app version with **Install MindRoom**.
   When the installed runtime is a different release, for example after an app update, it shows **Update needed** and **Update MindRoom**.
   Configuring, pairing, checking, and installing the service wait until the runtime matches; starting, stopping, and restarting an installed service stay available.
   Installing the runtime does not install the background service.
2. **Configure** uses the installed service's saved configuration, or `~/.mindroom/config.yaml` for a new service, and shows the path.
   **Prepare Configuration** creates missing files and keeps existing configuration and credentials.
   Under **Connect or reconnect your chat account**, **Connect Account** opens the approval page in the **Chat** section.
   Check that the code above Chat matches the page and that the signed-in account is yours, then click **Approve**; **Cancel** stops waiting without saving credentials.
   An unapproved code expires after 10 minutes; choose **Connect Account** again for a new one.
   Pairing a Mac that is already connected asks first, because it creates a new connection and a new agent namespace while existing agents keep working.
   Use **Open Config Folder** to add AI provider credentials to the adjacent `.env` or configure a local model in `config.yaml`.
3. **Check** (**Check Setup**) runs `mindroom doctor` on the displayed configuration and the service's storage path, covering configuration, providers, Matrix connectivity, and storage.
   **Files found** only means configuration exists; **Passed last check** means a check in this app session had no failures or warnings.
   Warnings show **Needs attention** with a summary, and **Command output** has details.
   Editing `config.yaml` or `.env` clears the result; rerun the check after editing included files or changing external services.
4. **Start** offers **Install and Start Agents** when the launchd service is missing, **Start Agents** when it is stopped, and stop, restart, chat, and dashboard controls when it runs.
   A check is recommended but not required.
   An installed service always uses its saved configuration path, including a custom path chosen in the terminal.
   If the service starts before your account is connected, the section shows **Waiting for your chat account** until you choose **Connect Account** and approve.

**Refresh Status** rereads the runtime, configuration, and service state, including changes made in a terminal.
These controls manage only this Mac's launchd service.
**Service running** means the process is running; use Chat or the dashboard to confirm agents are ready.

### Where Do My Agents Run?

Your agents run on this Mac.
The hosted `mindroom.chat` Matrix server only relays their chat messages, and signing in to `chat.mindroom.chat` creates your hosted Matrix account without moving agents to the cloud.
For pairing details, see [Hosted Matrix](https://docs.mindroom.chat/deployment/hosted-matrix/).

### Your Own Matrix Server

Under **Use your own Matrix server**, choose **Prepare Self-Hosted Configuration**, then edit `config.yaml` and `.env` for your server and model provider.
Run the Matrix server separately from this app.

### Open the Dashboard

While the service runs, **Open Dashboard** opens the local dashboard in the app for agent and model configuration.
The app signs in with `MINDROOM_API_KEY` from `~/.mindroom/.env` and uses `MINDROOM_URL` from the same file, defaulting to `http://127.0.0.1:8765`.
`MINDROOM_URL` must be an HTTP loopback URL (`localhost`, `127.0.0.1`, or `[::1]`) with an explicit port; otherwise the app reports `Local dashboard address is invalid. Check MINDROOM_URL and retry.`
The key is sent only to this Mac's MindRoom service, so if the dashboard cannot connect while the service is starting, select **Reload**.
After changing `MINDROOM_URL` or `MINDROOM_API_KEY`, select **Reload** in Dashboard.
The dashboard session is separate from Chat and ends when the app quits.

## Computer Access

Computer access uses the Desktop Helper bundled with the app and does not need the local-agent runtime or service.
The **Computer access** section has a summary and four steps: **Connect**, **Access**, **Permissions**, and **Start**.
The summary shows **Not connected**, **Connection saved · Access off**, or **Connected**, and always names the next required action.

1. In a private chat with your Desktop-enabled agent, send `!desktop setup` and copy its JSON setup data.
2. In **Connect**, paste it into **Setup data** and select **Import Setup**.
3. Check the homeserver, Matrix account, controller fingerprint, requester, and agent before signing in, because the setup data chooses which server receives your sign-in.
   The app reuses a matching saved Matrix login; otherwise choose **Sign In with Browser** or the password option.
4. Confirm the values again and select **Save and Connect**.
5. Copy the displayed confirmation command into the same agent chat, and after the agent confirms pairing, select **I’ve Confirmed in Chat**.
   If the app closes before this step, request fresh setup data and repeat **Connect**; the saved login is kept.
6. In **Access**, choose **Applications**, **Read-only folders**, or **Shell commands**, and save each choice.
   For applications, search and check the ones to allow, then select **Save App Access**; search accepts partial and reordered words, such as `chr goo` for Google Chrome.
   The list shows running apps and apps in standard folders; use **Add App…** to choose an application from another location.
   For folders and shell commands, see [Folders and Shell Commands](#folders-and-shell-commands).
7. If you saved applications, allow Accessibility and Screen Recording in **Permissions** with **Request** or **Open Settings**, restart the app if macOS requires it, and select **Check Again**.
   Without saved applications, **Permissions** shows **Not needed**.
8. Select **Start Observe Only**, or **Start Access** when folders or shell commands are saved.

Start needs at least one saved application, folder, or shell choice.
**Save App Access** and **Save Folder and Shell Access** each update their own choices without changing the other.
For homeservers behind Cloudflare Access, install `cloudflared`; see [Homeservers Behind an Identity-Aware Proxy](https://docs.mindroom.chat/tools/desktop/#homeservers-behind-an-identity-aware-proxy).

### Control Applications

Applications start observe-only.
**Grant Control…** shows the identities, allowed applications, and duration for confirmation, and control ends when that lease expires; it is never renewed at launch or restart.
**Revoke Now** removes input authority while observation continues, and **Stop Access** ends the session.
Controlling a terminal, a scripting or automation app, the primary screen, or a browser or Matrix client signed in as you gives the agent your own authority; see [Security Model](https://docs.mindroom.chat/tools/desktop/#security-model).
Optional browser settings and redacted diagnostics are in expandable sections; see the [Desktop guide](https://docs.mindroom.chat/tools/desktop/#use-the-signed-in-browser-profile) for browser-extension setup.

### Folders and Shell Commands

In **Access**, **Read-only folders** lists the folders agents may list and read; **Add Folder…** adds one and **Remove** drops one.
**Shell commands** has one switch, **Allow shell command requests**.
Save both with **Save Folder and Shell Access**; while access runs, **Stop and Save…** stops it first after confirmation.
Approved shell commands run with your full macOS account access; read [Shell Commands](https://docs.mindroom.chat/tools/desktop/#shell-commands) before enabling them.

While access runs with shell requests allowed, each request appears, unless auto-approval is active, on an approval card in the window and in a separate approval window that does not take keyboard focus.
Closing that window or choosing **Later** leaves the request waiting until it expires.
**Approve Once** and **Approve & Allow…** become available one second after a request appears.
Choose **Reject**, **Approve Once**, or **Approve & Allow…** with **5 Minutes**, **15 Minutes**, **60 Minutes**, or **Until I Stop**; without a waiting request, **Allow Without Asking…** offers the same durations.
Both auto-approval choices ask for confirmation and list the agents and requesters they cover; see [Local Approval](https://docs.mindroom.chat/tools/desktop/#local-approval) for what auto-approval allows.
While a command waits, every window section shows a **Review Command** bar and the menu bar shows **Command waiting** with **Review Command…**, which opens the card but never approves.
**Revoke Shell Access** ends auto-approval, rejects a waiting request, and stops running commands.
**Background commands** lists commands still running after their first reply, with a **Kill** button for each.

### Use the Terminal with the App

The app and `mindroom desktop setup` share the same saved setup in `~/.mindroom`; an open app picks up terminal setup while access is stopped.
You can save folders and shell access with `mindroom desktop access` and start from either the app or `mindroom desktop run`.
Stop the bridge in the interface that started it before starting it in the other.
A terminal `--config` or `--storage-path` override creates a separate setup that the app does not use.

### Fix Computer Access Problems

- **Permission shows Not allowed yet although System Settings shows MindRoom enabled**: quit and reopen MindRoom.
  If you replaced the signed release with a local build, reinstall the signed release or remove the old permission entry and approve the current copy, then select **Check Again**.
- **Access fails to start**: **View Details** shows the full error, recovery advice, and redacted diagnostics to copy for support.
- **Connection or TLS certificate errors**: your saved setup is kept, so check the network or update the app and select **Start Access** again; you do not need to sign in or pair again.

See the [Desktop guide](https://docs.mindroom.chat/tools/desktop/) for pairing recovery and device rotation.

## Window, Menu Bar, and Login

Closing the window keeps the app and its background work running in the menu bar; reopen it with **Open MindRoom…** or by opening the app again.
The menu shows local-agent and computer-access status as shortcuts to their sections, plus chat, dashboard, start and stop, **Revoke Computer Control** while a control lease is active, **Revoke Shell Access** while shell auto-approval or a command is active, settings, and quit actions.

**Settings** > **Open menu bar app at login** launches the menu app at login.
Local agents start at login through their own launchd service, while computer access always needs an explicit start in the app.

**Quit MindRoom** stops computer access but leaves the local-agent service running; use **Stop Local Agents** to stop it.
Let a running runtime action finish before quitting.

## Updates

**Settings** separates app updates from runtime updates:

- **Check App Updates…** updates the app, including the bundled Desktop Helper.
- **Update Local Runtime** installs the runtime release that matches the app version; `uv tool upgrade mindroom` does not move the runtime off that version.
- **Apply Runtime to Service…** reinstalls the background service on the updated runtime and starts or restarts local agents after confirmation.

Homebrew users can also update the app with:

```bash
brew update
brew upgrade --cask mindroom
```

## Logs and Troubleshooting

**Open Logs Folder** opens `~/Library/Logs/mindroom`, and **Open Config Folder** opens the folder holding the local agents' configuration, `~/.mindroom` by default, which the CLI shares.
Failed local-agent actions show their output in the window with a copy action.
If the dashboard does not open, start the service and check its logs for missing provider credentials or startup errors.

## Uninstall

```bash
brew uninstall --cask mindroom
```

Use `brew uninstall --zap --cask mindroom` to also remove app preferences and logs.
Neither removes `~/.mindroom`, which holds configuration, credentials, and agent data, or the runtime; remove the runtime with `uv tool uninstall mindroom`.

# iOS App

MindRoom AI is the MindRoom Chat client for iPhone, iPad, and Apple silicon Macs, available on the App Store.
It requires iOS or iPadOS 16.4 or later, or macOS 13.3 or later on a Mac with an M1 chip or newer.

<p>
  <a class="md-button md-button--primary" href="https://apps.apple.com/us/app/mindroom-ai/id6760272172">Download on the App Store</a>
</p>

## Sign In

The app is a Matrix client, so sign in with the Matrix homeserver and account you normally use.
For hosted MindRoom, the homeserver is `https://mindroom.chat`.

To pair a local MindRoom runtime with that account, follow [Getting Started](https://docs.mindroom.chat/getting-started/#2-approve-pairing); on a computer, approve the pairing at `https://chat.mindroom.chat`.

## Reporting Issues

Report problems through the [support page](https://docs.mindroom.chat/support/#what-to-include-in-a-support-request), which lists the device, app, and homeserver details to include.

# Web Dashboard

The web dashboard configures agents, teams, rooms, models, credentials, and integrations without editing YAML by hand.
Each editor keeps a local draft and writes `config.yaml` when you save.
This page covers opening the dashboard, what each tab does, saving and conflict errors, and the dashboard REST API.

## Accessing the Dashboard

**Standalone:** run `mindroom run` and open `http://localhost:8765`.
When running from a source checkout, MindRoom builds the dashboard assets on first start if Bun is available.
When `MINDROOM_API_KEY` is set, the dashboard first asks for that key, and the login page shows which `.env` file holds it.
See [Authorization](https://docs.mindroom.chat/authorization/#dashboard-configuration) for who can open the dashboard and [Environment Variables](https://docs.mindroom.chat/configuration/#dashboard-and-api) for the related settings.

**SaaS platform:** open `https://<instance-id>.mindroom.chat`.

## Connect Your AI Provider

When the router, an agent, or a team uses a model whose provider has no API key, every dashboard page shows a **Connect your AI provider** banner.
Keys in `.env`, keys saved per model on the **Models** tab, and provider keys saved under their environment variable name (for example `OPENROUTER_API_KEY`) all count as set.
Select **Connect provider**, choose OpenRouter, Anthropic, or OpenAI, and paste a key.
MindRoom checks the key with the provider without using credits and saves it as the `openrouter`, `anthropic`, or `openai` credential only if the provider accepts it.
OpenRouter is recommended for the hosted default setup, where one key covers chat, memory, and voice.
If you connect Anthropic or OpenAI while your models still use OpenRouter, the dialog lists the affected models so you can switch them on the **Models** tab.
When an agent cannot reply because no provider key is set, it answers in chat with a pointer to this setup step, including the dashboard address when `MINDROOM_PUBLIC_URL` is set.

## Dashboard Tabs

### Dashboard (Home)

The home tab shows today's activity, upcoming schedules, and shortcuts to room and agent editors.
Select **Browse workspace** to search and filter agents, rooms, and teams, inspect an item and open its editor, or **Export summary**.
The exported JSON summary lists counts, agents, rooms, teams, and model configurations; it is not a complete `config.yaml` backup.

### Agents

- **Display name**, **Role description**, **Model**, **Instructions**, and **Rooms**
- **Memory backend** - inherit the global backend or override it per agent (`mem0`, `file`, or `none`)
- **Tools** - configured tools (green badge) and tools that need no setup
- **Learning** and **Learning mode** (`always` or `agentic`); see [Agent Configuration](https://docs.mindroom.chat/configuration/agents/)
- **Lazy tool loading** - open a checked tool's settings and enable **Load lazily** (`defer`) or **Load at session start** (`initial`, which requires `defer`); presets and control-plane tools such as `dynamic_tools` do not offer it (see [Dynamic Tools](https://docs.mindroom.chat/tools/dynamic-tools/))
- **More settings** - every other agent field, such as `participation`, `mid_turn`, `accept_invites`, `access`, `memory_search`, `thread_exports`, and `room_thread_modes`

### Teams

- **Display name** and **Team purpose**
- **Collaboration mode** - Coordinate or Collaborate (see [Teams](https://docs.mindroom.chat/configuration/teams/))
- **Team model** - optional model override
- **Team members** and **Team rooms**

### Rooms

- **Room Admins** - the panel at the top of the tab edits `room_defaults.admins`, the default Matrix room-admin list; add or remove user IDs and click **Save**
- **Display name** and **Description**
- **Room model** - optional model override
- **Agents in room**
- **More settings** - per-room `join_policy`, `listed`, `encrypted`, `invite_users`, and `admins` overrides (see [Rooms](https://docs.mindroom.chat/rooms/))

### Schedules

Lists scheduled tasks across rooms with room, status, schedule type, delivery mode, and next run time.
You can edit a task's timing, description, and visible or silent delivery, or cancel it.
See [Scheduling](https://docs.mindroom.chat/scheduling/).

### External Rooms

The **External** tab lists, per agent, rooms the agent has joined that are not in the configuration.
Select rooms and use **Leave rooms** to remove the agents, or open a room in your Matrix client.

### Models

Add and edit models with provider, model ID, host URL, and advanced settings, and set provider API keys.
A row's **Save** only updates the draft; click **Save All Changes** to write model changes to `config.yaml`.
The add-model dropdown does not have a preset for every supported provider, but the dashboard keeps any provider already in the configuration.
A model's **More settings** section appears below the table while its row is being edited, and **Cancel** on the row also reverts More settings changes made since the last save.
See [Models](https://docs.mindroom.chat/configuration/models/) for providers and fields.

### Usage

See [Usage Tracking](https://docs.mindroom.chat/usage/#dashboard-usage-tab).

### Memory

Sets the global memory backend, embedder, file backend, and auto-flush settings; per-agent backend overrides are on the **Agents** tab.
See [Memory](https://docs.mindroom.chat/memory/#ui-configuration) for the fields.

### Knowledge

- Create, edit, and delete knowledge bases, including `description`, `mode` (semantic search or files-only), `path`, `watch`, and Git repository, branch, filtering, credentials service, and sync options
- Upload and remove files for non-Git bases
- Reindex or sync a base on demand
- See index status (`file_count` and `indexed_count`)

Git-backed bases hide upload and delete controls; change files in the repository, then sync.
The file list shows only files that pass the base's `include_patterns` and `exclude_patterns`.
See [Knowledge Bases](https://docs.mindroom.chat/knowledge/) for configuration and sync behavior; assign bases to agents on the **Agents** tab.

### Credentials

- List, create, edit (as raw JSON), test, and delete credential services, such as `github_private` or `model:sonnet`
- Reference a stored service from config, for example `knowledge_bases.<id>.git.credentials_service`

A new service named after a provider key environment variable, such as `ANTHROPIC_API_KEY`, is saved as the canonical provider service (`anthropic`) that the Models tab and runtime read.
An existing service with an environment-variable name keeps receiving writes, because config may reference it by that exact name.
See [Credential Storage](https://docs.mindroom.chat/oauth-framework/#credential-storage) for credentials imported from `.env`, such as `GITHUB_TOKEN`.

### Voice

Enables voice messages and configures speech-to-text (OpenAI or a self-hosted OpenAI-compatible service), the transcript-cleanup model, and [voice calls](https://docs.mindroom.chat/voice-calls/#calls-configuration) with named call profiles and per-agent assignments.
See [Voice Messages](https://docs.mindroom.chat/voice/).

### Tools

The **Tools** tab connects external services that tools need, including OAuth flows such as Google, Spotify, and Home Assistant.
Search tools, filter by category or status (**Available**, **Unconfigured**, **Configured**), and choose **Shared deployment credentials** or an agent with a worker scope to configure that scope (see [Dashboard credentials and worker scopes](https://docs.mindroom.chat/deployment/sandbox-proxy/#dashboard-credentials-and-worker-scopes)).

### Skills

Lists installed skills with origin and edit status, shows `SKILL.md` content, and creates, edits, and deletes user skills.
See [Skills](https://docs.mindroom.chat/skills/#installing-and-managing-skills).

### Settings

The **Settings** tab edits every top-level configuration section without a dedicated tab, grouped into Responses, History and context, Tools and workers, Router, Personal rooms, Room defaults, Tool approval, Prompts, MCP servers, Plugins, Access and identity, Matrix and runtime, and Diagnostics.
Any option no tab claims appears under **Other**.
**Tools and workers** also edits per-tool overrides for the default tools every agent inherits.

## Editing and Saving

### More Settings and Schema Forms

The Agents, Teams, Rooms, Memory, Knowledge, Voice, and Models tabs show a collapsible **More settings** section with the fields their main editor does not cover.
These sections and the Settings tab are generated from the running backend's configuration schema.
Each field shows its description and default, references such as model or agent names are pickers, and credential fields are masked.
Resetting a field removes it from `config.yaml` so the default applies again, and optional blocks are added or removed with their **Configure** checkbox.
Validation errors from a save appear beside the affected field, and Settings sections with errors are marked **Needs attention**.

### Save Status

The header shows **Synced**, **Syncing...**, **Sync Error**, or **Disconnected** (lost connection to the backend).
The dashboard has dark and light themes and works on mobile.

### Save Conflicts

If another dashboard tab or a server-side edit changed the configuration, saving shows `Configuration changed elsewhere. Your draft has not been saved. Copy any changes you want to keep, then refresh this page and reapply them.`
Saving stays blocked until you refresh, even if you edit the draft.

### Invalid Configuration

If `config.yaml` fails validation when the dashboard loads, it opens a raw YAML recovery editor so you can fix and save the whole file.

### Configurations Split With `!include`

When the configuration uses [include tags](https://docs.mindroom.chat/configuration/#splitting-the-configuration-into-multiple-files), the dashboard shows a banner and structured saves fail, so edit the included source files directly.
The raw recovery editor still edits the top-level file's text.
Through the API, structured saves return HTTP 409 with `{"code": "config_composed_from_includes", "message": "configuration uses !include; edit the source files instead"}`, unlike the plain-text 409 for a stale write, which can be retried.
If the source files cannot be read well enough to tell whether they use `!include`, structured saves return HTTP 422; repair the files or edit the raw YAML.
`POST /api/config/load` reports include use in the `x-mindroom-config-uses-includes` response header, and `GET /api/config/raw` in its `uses_includes` field.

## API Endpoints

The dashboard uses the backend REST API under `/api/`, with the same authentication as the dashboard.

### Configuration API

| Method | Endpoint | Description |
|--------|----------|-------------|
| POST | `/api/config/load` | Fetch current configuration |
| PUT | `/api/config/save` | Save full configuration |
| GET | `/api/config/raw` | Fetch raw `config.yaml` text |
| PUT | `/api/config/raw` | Replace raw `config.yaml` text |
| GET | `/api/config/schema` | Configuration JSON schema used by Settings and More settings |
| GET | `/api/config/agents` | List agents |
| POST | `/api/config/agents` | Create agent |
| PUT | `/api/config/agents/{id}` | Update agent |
| DELETE | `/api/config/agents/{id}` | Delete agent |
| GET | `/api/config/teams` | List teams |
| POST | `/api/config/teams` | Create team |
| PUT | `/api/config/teams/{id}` | Update team |
| DELETE | `/api/config/teams/{id}` | Delete team |
| GET | `/api/config/models` | List model configurations |
| PUT | `/api/config/models/{id}` | Update model configuration |
| GET | `/api/config/room-models` | Get room model overrides |
| PUT | `/api/config/room-models` | Update room model overrides |
| POST | `/api/config/agent-policies` | Derived agent policies for a draft config |

### Credentials API

| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/api/credentials/list` | List services with credentials |
| GET | `/api/credentials/{service}/status` | Get credential status |
| GET | `/api/credentials/{service}` | Get credentials for editing |
| POST | `/api/credentials/{service}` | Set credentials |
| POST | `/api/credentials/{service}/api-key` | Set API key |
| GET | `/api/credentials/{service}/api-key` | Get masked API key |
| POST | `/api/credentials/{service}/test` | Check stored credentials exist |
| DELETE | `/api/credentials/{service}` | Delete credentials |
| POST | `/api/credentials/{service}/copy-from/{source_service}` | Copy credentials from another service |

Add `agent_name` to target an agent's credentials in its saved worker scope.
The optional `execution_scope` requires `agent_name` and must match that agent's saved scope; save the configuration first after changing the scope.
Agent-scoped requests return HTTP 403 unless the requester may manage that agent's credentials; see [Platform and credential authority](https://docs.mindroom.chat/authorization/#platform-and-credential-authority).

### Knowledge API

| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/api/knowledge/bases` | List knowledge bases |
| GET | `/api/knowledge/bases/{base_id}/files` | List files in a base |
| POST | `/api/knowledge/bases/{base_id}/upload` | Upload files to a non-Git base |
| DELETE | `/api/knowledge/bases/{base_id}/files/{path}` | Delete a file from a non-Git base |
| GET | `/api/knowledge/bases/{base_id}/status` | Get indexing status |
| POST | `/api/knowledge/bases/{base_id}/reindex` | Reindex a base, or sync a Git-backed base |

Upload and delete return HTTP 409 for Git-backed bases (see [Sync Behavior](https://docs.mindroom.chat/knowledge/#sync-behavior)).

### Skills API

| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/api/skills` | List installed skills |
| GET | `/api/skills/{skill_name}` | Get skill content, origin, and edit status |
| POST | `/api/skills` | Create a user skill |
| PUT | `/api/skills/{skill_name}` | Update a user skill |
| DELETE | `/api/skills/{skill_name}` | Delete a user skill |

### Schedules API

| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/api/schedules` | List scheduled tasks, optionally filtered by room, including each task's `silent` flag |
| PUT | `/api/schedules/{task_id}` | Edit a scheduled task, including `silent` |
| DELETE | `/api/schedules/{task_id}` | Cancel a scheduled task |

### Workers API

| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/api/workers` | List sandbox workers |
| POST | `/api/workers/cleanup` | Clean up idle sandbox workers |

### Tools and Matrix API

| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/api/tools` | List available tools |
| GET | `/api/rooms` | List configured rooms |
| GET | `/api/matrix/agents/rooms` | All agents' room memberships |
| GET | `/api/matrix/agents/{id}/rooms` | One agent's rooms |
| POST | `/api/matrix/rooms/leave` | Leave one room |
| POST | `/api/matrix/rooms/leave-bulk` | Leave multiple rooms |

For usage reports and health endpoints, see [Usage Report API](https://docs.mindroom.chat/usage/#usage-report-api) and [Health & Readiness](https://docs.mindroom.chat/deployment/operational-log-events/#health-readiness).

# OpenClaw Workspace Import

This page covers moving an OpenClaw workspace into MindRoom: where to put the files, a starting agent config, the `openclaw_compat` tool preset, and what does not carry over.
MindRoom reuses OpenClaw's file-based workspace (`SOUL.md`, `AGENTS.md`, `USER.md`, `MEMORY.md`, daily notes, and skills); it is not a clone of the OpenClaw gateway.

## What carries over

Supported:

- File-based identity and instruction documents, loaded through `context_files`
- File-first memory (`MEMORY.md` plus `memory/` notes) through MindRoom's `file` memory backend
- OpenClaw skills, including `metadata.openclaw` eligibility gating
- A matching tool bundle through the [`openclaw_compat`](#openclaw_compat) preset, including Matrix messaging with `matrix_message`

Not included:

- The OpenClaw gateway control plane
- Device nodes and canvas platform tools
- OpenClaw tool aliases such as `exec`, `process`, `web_search`, `web_fetch`, `tts`, and `image`; use MindRoom's native tools instead
- The heartbeat runtime; schedule heartbeats with the `scheduler` tool instead (see [Scheduling](https://docs.mindroom.chat/scheduling/))

## Import a workspace

1. Copy or sync your OpenClaw files into the agent's workspace, `agents/<agent>/workspace/` in the storage directory (`mindroom_data/` by default):

    ```text
    mindroom_data/
    └── agents/
        └── openclaw/
            └── workspace/
                ├── SOUL.md
                ├── AGENTS.md
                ├── USER.md
                ├── IDENTITY.md
                ├── MEMORY.md
                ├── TOOLS.md
                ├── HEARTBEAT.md
                ├── memory/
                │   ├── YYYY-MM-DD.md
                │   └── topic-notes.md
                └── skills/
                    └── transcribe/
                        └── SKILL.md
    ```

2. Add an agent to `config.yaml`, starting from this example:

    ```yaml
    agents:
      openclaw:
        display_name: OpenClawAgent
        role: OpenClaw-style personal assistant with persistent file-based identity and memory.
        model: default
        rooms: [personal]
        include_default_tools: false
        learning: false
        memory_backend: file

        instructions:
          - You wake up fresh each session with no memory of previous conversations. Your context files are already loaded into your system prompt.
          - Important long-term context is persisted by the configured MindRoom memory backend. If something must be preserved exactly, write/update the relevant file directly.
          - MEMORY.md is curated long-term memory; daily files are short-lived notes and logs.
          - Ask before external/public actions and destructive operations.
          - Before answering prior-history questions, search memory files first with `search_memories`.

        context_files:
          - SOUL.md
          - AGENTS.md
          - USER.md
          - IDENTITY.md
          - TOOLS.md
          - HEARTBEAT.md

        tools:
          - openclaw_compat
          - memory
          - python

    memory:
      search:
        mode: semantic
      auto_flush:
        enabled: true
    ```

`context_files`, file memory, and `search_memories` all read this one workspace.
`context_files` paths are relative to it; see [File-Based Context Loading](https://docs.mindroom.chat/configuration/agents/#file-based-context-loading).

## [`openclaw_compat`]

`openclaw_compat` is a preset that belongs in an agent's `tools:` list and expands to `shell`, `coding`, `duckduckgo`, `website`, `browser`, `scheduler`, and `matrix_message`.
`matrix_message` also enables `attachments` and `matrix_room`, so the agent gets all nine tools.
The preset has no callable functions of its own, and listing it next to one of its member tools does not create duplicates.

The preset has no configuration fields and cannot be deferred.
Setting `defer` or `initial` on it fails with `'openclaw_compat' is a preset and cannot be deferred; defer/initial are only valid on individual tools.`
To configure or lazy-load a member tool, list that tool individually, and if you need only one or two of the tools, list them instead of the preset.

## Memory

OpenClaw-style agents use the same memory backends as every other agent; see [Memory System](https://docs.mindroom.chat/memory/) for backends, file layout, semantic search, and auto-flush.
The recommended setup is `memory_backend: file` with `memory.auto_flush.enabled: true`, as in the example above.

With the `file` backend, MindRoom loads `MEMORY.md` into every reply's prompt automatically, so leave it out of `context_files`.
If you switch the agent to `mem0` and still want `MEMORY.md` preloaded, add it to `context_files`.
Use [knowledge bases](https://docs.mindroom.chat/knowledge/) only for non-memory documents that should be searchable as external knowledge.

## Context and history

Preloaded `context_files` are capped by `defaults.max_preload_chars` (default `50000`); see [File-Based Context Loading](https://docs.mindroom.chat/configuration/agents/#file-based-context-loading) for what gets dropped when a workspace exceeds it.
How much conversation history each reply sees is set by `num_history_runs`, `num_history_messages`, and `compaction`; see [History & Compaction](https://docs.mindroom.chat/configuration/history/).

## Room conversations instead of threads

MindRoom replies in Matrix threads by default, while OpenClaw keeps one continuous conversation per room.
To match OpenClaw, especially on mobile or through bridges (Telegram, Signal, WhatsApp), set `thread_mode: room` on the agent; see [Threads, Replies & Participation](https://docs.mindroom.chat/configuration/threads/).

## Privacy

`context_files` and file memory load in every room the agent is in.
If `MEMORY.md` or another workspace file is sensitive, keep the agent in private rooms only, or split it into a private and a public agent and leave the sensitive files out of the public agent's workspace.

## Skills

Skills in `agents/<agent>/workspace/skills/<name>/` load for that agent without being listed in `skills:`, so copying the OpenClaw workspace's `skills/` folder keeps them portable.
To share a skill across agents, install it under `~/.mindroom/skills/<name>/` and add its name to each agent's `skills:` allowlist:

```bash
mkdir -p ~/.mindroom/skills
cp -r /path/to/openclaw-workspace/skills/transcribe ~/.mindroom/skills/
```

Set any environment variables the skill's `SKILL.md` requires, such as `WHISPER_URL`.
See [Skill locations and precedence](https://docs.mindroom.chat/skills/#skill-locations-and-precedence) and [`metadata.openclaw` eligibility gating](https://docs.mindroom.chat/skills/#eligibility-gating-openclaw-metadata) (`os`, `requires`, `always`).

# Configuration

MindRoom is configured through one `config.yaml` file, optionally split across included files, plus environment variables.
This page covers where the file lives, a minimal working configuration, which page owns each top-level section, splitting the file, built-in prompt overrides, and the environment variable reference.

## Configuration File

If `MINDROOM_CONFIG_PATH` is set, MindRoom uses that path, even when no file exists there.
Otherwise, it uses `./config.yaml` in the current working directory if present, then `~/.mindroom/config.yaml`.

Data storage (`mindroom_data/`) is placed next to the config file by default.

Validate a file with:

```bash
mindroom config validate --path /path/to/config.yaml
```

Edits to `config.yaml` apply by hot reload without restarting MindRoom, except [event journal](https://docs.mindroom.chat/deployment/storage/#event-journal) changes, which need a restart.
A reload waits for active responses to finish, for at most 600 seconds.
To confirm that a reload finished, use [`mindroom config check-applied`](https://docs.mindroom.chat/deployment/config-bundles/#config-fingerprint-and-config-check-applied).

## Minimal Configuration

All top-level sections are optional; configure at least one agent for conversational replies.
This configuration runs one agent in a `lobby` room, with `ANTHROPIC_API_KEY` set in the environment or in the `.env` next to `config.yaml`:

```yaml
models:
  default:
    provider: anthropic
    id: claude-sonnet-5-5

agents:
  assistant:
    display_name: Assistant
    role: A helpful AI assistant
    model: default
    rooms: [lobby]

timezone: America/Los_Angeles
```

Keep `models.default` configured, because room topic generation and automatic thread summaries fall back to it even when agents, teams, and the router select other models.

## Top-Level Sections

| Section | Purpose | Documented in |
| --- | --- | --- |
| `agents` | Individual AI agents | [Agents](https://docs.mindroom.chat/configuration/agents/) |
| `models` | Model providers and IDs | [Models](https://docs.mindroom.chat/configuration/models/) |
| `teams` | Multi-agent collaboration | [Teams](https://docs.mindroom.chat/configuration/teams/) |
| `router` | Message routing | [Router](https://docs.mindroom.chat/configuration/router/) |
| `defaults` | Fallback values for agent settings and global-only behavior | [Agents — Defaults](https://docs.mindroom.chat/configuration/agents/#defaults) |
| `memory` | Memory backends and embedder | [Memory](https://docs.mindroom.chat/memory/) |
| `knowledge_bases` | File-backed knowledge bases | [Knowledge Bases](https://docs.mindroom.chat/knowledge/) |
| `mcp_servers` | External Model Context Protocol servers | [MCP](https://docs.mindroom.chat/mcp/) |
| `plugins` | Plugin loading | [Plugins](https://docs.mindroom.chat/plugins/) |
| `voice` | Speech-to-text for voice messages | [Voice](https://docs.mindroom.chat/voice/) |
| `calls` | Agents joining Element Call voice calls | [Voice Calls](https://docs.mindroom.chat/voice-calls/#calls-configuration) |
| `administrators`, `authorization`, `bot_accounts` | Platform administrators, command access, bridge aliases, and bridge bots | [Authorization](https://docs.mindroom.chat/authorization/) |
| `room_defaults`, `rooms` | Managed room policy, invitations, and room admins | [Rooms & Spaces](https://docs.mindroom.chat/rooms/#room-policy) |
| `room_models` | Map of managed room name to model name, the authored model default for a room | [Models](https://docs.mindroom.chat/configuration/models/#room_model) (`!room_model`) |
| `room_thread_summary_models` | Map of room alias or Matrix room ID to automatic thread summary model | [Matrix & Attachments](https://docs.mindroom.chat/tools/matrix-and-attachments/#automatic-thread-summaries) |
| `matrix_space` | Root Matrix Space for managed rooms | [Matrix Space](https://docs.mindroom.chat/rooms/#matrix-space) |
| `timezone`, `scheduler_catch_up_grace_seconds` | Scheduled task timezone (default `UTC`) and missed-run catch-up | [Scheduling](https://docs.mindroom.chat/scheduling/) |
| `external_trigger_policy` | Inbound external triggers | [External Triggers](https://docs.mindroom.chat/external-triggers/) |
| `tool_approval` | Human approval for tool calls | [Tool Approval](https://docs.mindroom.chat/tool-approval/#tool-approval) |
| `personal_rooms` | One private room per onboarded user | [Personal Rooms](https://docs.mindroom.chat/personal-rooms/#personal-rooms) |
| `prompts` | Built-in prompt overrides | [Built-In Prompt Overrides](#built-in-prompt-overrides) |
| `matrix_sync` | Matrix sync transport and limits | [Matrix Sync](https://docs.mindroom.chat/matrix/#matrix-sync) |
| `event_journal` | Storage for the Matrix event journal | [Event Journal](https://docs.mindroom.chat/deployment/storage/#event-journal) |
| `debug` | Provider request logging | [Debug Logging](https://docs.mindroom.chat/deployment/operational-log-events/#debug-logging) |
| `mindroom_user` | Internal MindRoom user account | [Internal User](https://docs.mindroom.chat/matrix/#internal-user) |

Optional tools that agents load on demand are configured as described in [Dynamic Tools](https://docs.mindroom.chat/tools/dynamic-tools/), and skills in [Skills](https://docs.mindroom.chat/skills/).

## Splitting the Configuration Into Multiple Files

Large configs can be split across multiple files with Home-Assistant-style include tags.

| Tag | Result |
|-----|--------|
| `!include rel/path.yaml` | The parsed content of that YAML file, with nested include tags resolved recursively |
| `!expand rel/path.yaml` | Splices the file's YAML list into the surrounding list at this item |
| `!include_text rel/path.md` | The file's raw text as a string (one trailing newline stripped), for long prompt or instruction blocks |
| `!include_dir_list rel/dir` | A list with one item per YAML file in the directory |
| `!include_dir_named rel/dir` | A mapping of filename-without-extension to each file's parsed content |
| `!include_dir_merge_list rel/dir` | The concatenation of the lists contained in each file |
| `!include_dir_merge_named rel/dir` | The merge of the mappings contained in each file |

A realistic split keeps each agent in its own file, shared lists in `_`-prefixed snippets, and long prompts in Markdown:

```yaml
# config.yaml
agents: !include_dir_merge_named agents/
models: !include models.yaml
```

```yaml
# agents/researcher.yaml
researcher:
  display_name: Researcher
  role: Deep research and analysis
  model: default
  tools:
    - !expand _shared/web_tools.yaml
    - calculator
  instructions:
    - !include_text ../prompts/researcher.md
```

```yaml
# agents/_shared/web_tools.yaml
- duckduckgo
- website
- browser
```

Here `tools` resolves to `[duckduckgo, website, browser, calculator]`.

- Relative paths resolve against the directory of the file containing the tag.
- Absolute paths are rejected, and every include must stay inside the top-level config file's directory after symlinks and `..` are resolved.
- Explicit include paths may not contain hidden components starting with `.`, so `!include_text .env` fails.
- Directory includes recurse into subdirectories, take only `.yaml`/`.yml` files in lexicographic order of their relative path, and skip files and directories whose name starts with `.` or `_`.
- YAML anchors cannot cross an include boundary, so share values through `_`-prefixed snippet files.
- `!expand` is only valid as a list item, may appear several times in one list, and splices one level while keeping order and duplicates.
  The expanded file must be a YAML list; `[]` adds nothing, while an empty file, `null`, a scalar, or a mapping is an error.
  An ordinary `- !include tools.yaml` inserts the included value as one item, and `tools: !include tools.yaml` replaces the whole list.
- An empty included file resolves to `null` under `!include` and contributes nothing to directory includes.
- Include cycles, missing files, duplicate keys across `!include_dir_merge_named` files, and duplicate filename stems under `!include_dir_named` are errors that name the file and line of the failing tag.

Editing any file in the include tree triggers the same hot reload as editing `config.yaml`, and a multi-file update such as `git pull` applies as one reload.
Adding a file to an included directory or deleting an included file does not trigger a reload by itself; save `config.yaml` again (for example with `touch config.yaml`) or restart MindRoom to apply it.
After a failed reload, fixing any file the failed attempt read triggers a retry.

When migrating an existing monolith, diff the fully merged config before and after the split:

```bash
mindroom config resolve > before.yaml
# split config.yaml into include files
mindroom config resolve > after.yaml
diff before.yaml after.yaml
```

When the configuration uses include tags, structured saves from the dashboard and the self-config tools are rejected, so edit the source files instead; the raw config editor still edits the top-level file's text (see [Dashboard](https://docs.mindroom.chat/dashboard/#configurations-split-with-include)).
`MINDROOM_CONFIG_TEMPLATE` copies only the single template file, so a template that uses includes needs its included files shipped next to the config path.

## Built-In Prompt Overrides

Use the optional root `prompts` block to replace MindRoom's built-in prompts, keyed by the prompt's uppercase name:

```yaml
prompts:
  HIDDEN_TOOL_CALLS_PROMPT: |
    Tool calls are hidden from users.
    Answer directly without narrating internal tool use.
  ROUTER_AGENT_SELECTION_PROMPT_TEMPLATE: |
    Decide which agent or team should respond to this message.

    Available agents and teams:

    {agents_info}

    User message: {message}

    Return only the agent or team name.
```

An unknown key fails config validation with an error that lists every allowed prompt name.
Template prompts accept only their own `{field_name}` placeholders, and an unsupported placeholder fails validation with an error that lists the allowed ones.
Placeholders are exact `{field_name}` replacements, not Jinja or Python `str.format`, so `{message.text}`, `{message!r}`, and `{message:.2f}` are rejected.
Escape literal braces as `{{` and `}}` in overrides that use placeholders.
Changed overrides apply through hot reload.
Changing a prompt that agents build in when they start, such as `HIDDEN_TOOL_CALLS_PROMPT` or `AGENT_IDENTITY_CONTEXT_TEMPLATE`, restarts every agent, team, and the router; other prompt changes apply without restarts.

## Environment Variables

Set variables in the process environment or in the `.env` file next to `config.yaml`; the process environment wins.
`MINDROOM_CONFIG_PATH`, `LOG_LEVEL`, `MINDROOM_LOGGER_LEVELS`, `MINDROOM_LOG_FORMAT`, `NO_COLOR`, and [`MINDROOM_TIMING`](https://docs.mindroom.chat/deployment/operational-log-events/#debug-logging) are read only from the process environment, so the `.env` file does not set them.
Container `env_file`/`--env-file` injection sets process variables and therefore works.

### Core

| Variable | Description | Default |
|----------|-------------|---------|
| `MINDROOM_CONFIG_PATH` | Path to `config.yaml` | `./config.yaml`, then `~/.mindroom/config.yaml` |
| `MINDROOM_STORAGE_PATH` | Data storage directory | `mindroom_data/` next to config |
| `MINDROOM_SESSION_STORAGE_PATH` | Separate root for agent and team session databases; relative paths resolve from the config directory, and other state stays under `MINDROOM_STORAGE_PATH` (see [Storage](https://docs.mindroom.chat/deployment/storage/)) | `MINDROOM_STORAGE_PATH` |
| `MINDROOM_CONFIG_TEMPLATE` | File copied to the config path when `config.yaml` does not exist, used by Docker images to seed config | _(unset)_ |
| `MINDROOM_CREDENTIALS_ENCRYPTION_KEY` | Key for encrypted-at-rest credential files (see [Credential Storage Encryption](https://docs.mindroom.chat/oauth-framework/#credential-storage-encryption)) | _(unset)_ |
| `LOG_LEVEL` | Logging level for `mindroom run` (`DEBUG`, `INFO`, `WARNING`, `ERROR`); `mindroom run --log-level` takes precedence | `INFO` |
| `MINDROOM_LOGGER_LEVELS` | Comma- or semicolon-separated logger level overrides, for example `mindroom:DEBUG,httpx:WARNING,nio:WARNING` | _(unset)_ |
| `MINDROOM_LOG_FORMAT` | `text` for readable logs or `json` for structured logs | `text` |
| `NO_COLOR` | Any nonempty value disables console colors and styled tracebacks; installed background services set this to `1` | _(unset)_ |

### Matrix

| Variable | Description | Default |
|----------|-------------|---------|
| `MATRIX_HOMESERVER` | Matrix homeserver URL | `http://localhost:8008` |
| `MATRIX_SERVER_NAME` | Server name for federation | _(derived from homeserver)_ |
| `MATRIX_SSL_VERIFY` | Set to `false` for a dev or self-signed homeserver; it covers `MATRIX_HOMESERVER` and its MatrixRTC voice-call requests, never the provisioning service or other homeservers (see [TLS Trust](https://docs.mindroom.chat/matrix/#tls-trust)) | `true` |
| `MATRIX_REGISTRATION_TOKEN` | Registration token for creating managed agent accounts on a self-hosted homeserver | _(unset)_ |
| `MATRIX_REGISTRATION_SHARED_SECRET`, `MATRIX_REGISTRATION_SHARED_SECRET_FILE` | Synapse shared registration secret, or a file containing it, for creating managed agent accounts without pairing | _(unset)_ |
| `MATRIX_MANAGED_ACCOUNT_AUTH` | `password` or `appservice` authentication for accounts MindRoom creates and operates (see [Agent Users](https://docs.mindroom.chat/matrix/#agent-users)) | `password` |
| `MATRIX_APPSERVICE_TOKEN`, `MATRIX_APPSERVICE_TOKEN_FILE` | Application-service token, or a file containing it, used when `MATRIX_MANAGED_ACCOUNT_AUTH=appservice` | _(unset)_ |
| `MINDROOM_MATRIX_HOMESERVER_STARTUP_TIMEOUT_SECONDS` | Seconds to wait at startup for the homeserver to respond (`0` waits indefinitely); a certificate verification failure stops startup immediately | _(wait indefinitely)_ |
| `MINDROOM_MATRIX_SYNC_STARTUP_TIMEOUT_SECONDS` | Positive seconds allowed for the first Matrix sync response | `600` |
| `MINDROOM_MATRIX_INGESTION_GRACE_SECONDS` | Positive seconds the sync watchdog and `/api/health` keep waiting while event catch-up is still making progress; set it above the deployment's observed healthy catch-up time | `600` |

Hosted pairing variables written by `mindroom connect` are described in [Hosted Matrix](https://docs.mindroom.chat/deployment/hosted-matrix/#connect), and desktop setup variables in [Desktop](https://docs.mindroom.chat/tools/desktop/).

### Model Providers

Provider API keys such as `ANTHROPIC_API_KEY` and `OPENAI_API_KEY`, their `_FILE` variants, and Vertex AI and Bedrock settings are listed in [Models — Environment Variables](https://docs.mindroom.chat/configuration/models/#environment-variables), and `codex login` and `CODEX_HOME` in [Codex Models with ChatGPT Login](https://docs.mindroom.chat/configuration/models/#codex-models-with-chatgpt-login).
Provider keys from the environment or `.env` are also copied into the shared credentials store, as described in [Automatic Credential Import](https://docs.mindroom.chat/oauth-framework/#automatic-credential-import).

| Variable | Description |
|----------|-------------|
| `OPENAI_BASE_URL` | Base URL for `provider: openai` models, such as a local inference server; a model's `extra_kwargs.base_url` overrides it, and `extra_kwargs.client_params.base_url` overrides both |

### Dashboard and API

| Variable | Description | Default |
|----------|-------------|---------|
| `MINDROOM_API_KEY` | API key for dashboard/API requests; `mindroom config init` and first-run `mindroom run` add a generated key to `.env` when it has none; unset or empty means open access | _(none)_ |
| `MINDROOM_PORT` | Port in the default OAuth callback URL when `MINDROOM_PUBLIC_URL` and `MINDROOM_BASE_URL` are unset, also read by deployment tooling; set the API server bind port with `mindroom run --api-port` instead | `8765` |
| `MINDROOM_DASHBOARD_ALLOWED_HOSTS` | Comma-separated extra host names that unauthenticated dashboard or `/v1` requests may address (see [Authorization](https://docs.mindroom.chat/authorization/#unauthenticated-dashboard-host-names)) | _(none)_ |
| `MINDROOM_DASHBOARD_CORS_ALLOWED_ORIGINS` | Comma-separated origins allowed credentialed dashboard CORS responses; cookie and trusted-upstream mutations still require the app's own origin | `http://localhost:3003`, `http://localhost:5173`, `http://127.0.0.1:3003`, `http://127.0.0.1:5173`, or the hosted public origins when `MINDROOM_PUBLIC_URL` is set |
| `MINDROOM_DASHBOARD_CORS_ALLOW_ALL_ORIGINS` | `true` allows every dashboard API origin while disabling credentialed CORS responses; without dashboard authentication, origins outside `MINDROOM_DASHBOARD_ALLOWED_HOSTS` are still refused | _(unset)_ |
| `MINDROOM_ENABLE_API_DOCS` | `false` hides the generated API docs at `/docs`, `/redoc`, and `/openapi.json` | `true` (`false` on hosted platform instances) |
| `OPENAI_COMPAT_API_KEYS` | Comma-separated API keys for `/v1/*` requests (see [OpenAI-Compatible API](https://docs.mindroom.chat/openai-api/)) | _(none; `/v1` is locked without this or the flag below)_ |
| `OPENAI_COMPAT_ALLOW_UNAUTHENTICATED` | `true` allows unauthenticated `/v1/*` access for local development only | _(unset)_ |
| `OPENAI_COMPAT_API_KEY_REQUESTERS` | JSON object mapping `/v1` API keys to Matrix user IDs (see [Requester identities](https://docs.mindroom.chat/openai-api/#requester-identities-and-delegation)) | _(none)_ |
| `MINDROOM_AUTO_BUILD_FRONTEND` | Set to `0` to skip the automatic dashboard frontend build | _(enabled)_ |

### Workers and Background Scripts

| Variable | Description | Default |
|----------|-------------|---------|
| `MINDROOM_NAMESPACE` | Installation namespace for Matrix identity isolation (4–32 lowercase alphanumeric chars) | _(none)_ |
| `MINDROOM_NO_AUTO_INSTALL_TOOLS` | `1`/`true`/`yes` disables automatic tool dependency installation | _(unset; auto-install enabled)_ |
| `MINDROOM_WORKER_BACKEND` | Worker backend for tool execution (`static_runner`, `docker`, or `kubernetes`) | `static_runner` |
| `MINDROOM_SANDBOX_PROXY_URL` | Sandbox proxy endpoint URL (static runner) | _(none)_ |
| `MINDROOM_SANDBOX_PROXY_TOKEN` | Auth token for the sandbox proxy | _(none)_ |
| `MINDROOM_SCRIPT_GATEWAY_URL` | Worker-reachable background-script gateway base URL, including `/api/script-gateway` | _(none)_ |
| `MINDROOM_SCRIPT_GATEWAY_PORT` | Port for a second listener that serves only the script gateway | _(none)_ |
| `MINDROOM_SCRIPT_GATEWAY_ISOLATED` | `true` attests that the Kubernetes script-gateway listener exposes only the gateway | `false` |
| `MINDROOM_KUBERNETES_DEFAULT_SCRIPT_RESOURCE_PROFILE` | Default Kubernetes background-script profile (`small`, `standard`, or `large`) when `start_script` omits `resource_profile` | `small` |
| `MINDROOM_KUBERNETES_SCRIPT_RESOURCE_PROFILES_JSON` | JSON object defining CPU and memory requests and limits for the `small`, `standard`, and `large` profiles | Built-in profiles |
| `MINDROOM_SCRIPT_RETENTION_SECONDS` | Positive seconds to retain finished background-script runs, tool-call receipts, and approval records | `2592000` (30 days) |

See [Background Scripts](https://docs.mindroom.chat/tools/background-scripts/#worker-and-network-requirements) for when each script gateway variable is required, and [Sandbox Proxy](https://docs.mindroom.chat/deployment/sandbox-proxy/#environment-variable-reference) for the other `MINDROOM_SANDBOX_*` and Kubernetes worker variables.
Credential seed variables are described in [Credential Seeds](https://docs.mindroom.chat/oauth-framework/#credential-seeds).

# Agent Configuration

This page covers the `agents:` section of `config.yaml`, where each agent is one AI actor with its own model, tools, rooms, and instructions.
It also covers requester-private agents, files preloaded into an agent's prompt, naming rules, and the `defaults:` section that every agent inherits from.

## Basic Agent

```yaml
agents:
  assistant:
    display_name: Assistant
    role: A helpful AI assistant
    model: sonnet
    rooms: [lobby]
```

The key (`assistant`) is the agent's name, `model` names an entry under `models:`, and `rooms` lists the rooms the agent joins.

A coding agent that runs its code tools in a worker, preloads workspace notes, and replies as plain room messages in a bridged room:

```yaml
agents:
  developer:
    display_name: Developer
    role: Generate code, manage files, execute shell commands
    model: sonnet
    tools:
      - file
      - github
      - shell:
          extra_env_passthrough: "DAWARICH_*"
    worker_tools: [shell, file]
    instructions:
      - Always read files before modifying them
    context_files: [AGENTS.md, USER.md]
    rooms: [lobby, dev, bridge_telegram]
    room_thread_modes:
      bridge_telegram: room
    access:
      members_of_rooms: [dev]
    delegate_to: [research]
```

To keep `defaults.tools` away from one agent:

```yaml
agents:
  researcher:
    display_name: Researcher
    role: Focus on deep research
    include_default_tools: false
    tools: [duckduckgo]
```

## Configuration Options

| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `display_name` | string | *required* | Name shown in Matrix |
| `role` | string | `""` | System prompt describing the agent's purpose and expertise |
| `model` | string | `"default"` | Key of an entry under `models:` |
| `tools` | list | `[]` | Tool names or single-key dicts with per-agent overrides; see [Tools](https://docs.mindroom.chat/tools/) and [Per-Agent Tool Configuration](https://docs.mindroom.chat/tools/#per-agent-tool-configuration). The agent gets these plus `defaults.tools`, without duplicates |
| `include_default_tools` | bool | `true` | Set `false` to leave out `defaults.tools` and its overrides for this agent |
| `skills` | list | `[]` | Skill names the agent can use; see [Skills](https://docs.mindroom.chat/skills/) |
| `skill_learning` | object | disabled | [Automatic skill learning](https://docs.mindroom.chat/skills/#settings), separate from `learning` |
| `instructions` | list | `[]` | Extra lines appended to the system prompt after the role |
| `minimal_instructions` | list | `[]` | Guidance included on every minimal-mode request; see [Prompt and `minimal_instructions`](https://docs.mindroom.chat/tools/agent-cli/#prompt-and-minimal_instructions) |
| `rooms` | list | `[]` | Room keys, aliases, or Matrix room IDs to join; missing managed rooms are created (see [Rooms](https://docs.mindroom.chat/rooms/)) |
| `accept_invites` | bool or list | `true` | `true` accepts every room invitation, `false` or `[]` accepts none, and a list accepts only inviters matching an exact or wildcard Matrix user ID. Rooms joined this way are kept across restarts. Joining a room never grants its members access; see [Authorization](https://docs.mindroom.chat/authorization/) |
| `access` | object | `null` | Who may converse with the agent: `current_room_members`, `members_of_rooms`, and `users`. When omitted, members of the agent's own managed `rooms` have access. See [Responder access](https://docs.mindroom.chat/authorization/#responder-access) |
| `credential_managers` | list | `[]` | Concrete Matrix user IDs (no wildcards) allowed to manage this agent's credentials and shared OAuth connections; grants no conversation access. See [OAuth](https://docs.mindroom.chat/oauth-framework/) |
| `participation` | object | `null` | Opt into adaptive replies in existing multi-human threads; see [Adaptive Participation](https://docs.mindroom.chat/configuration/threads/#adaptive-participation) |
| `mid_turn` | object | `null` | Judge whether messages queued during a response can wait until it finishes; see [Mid-Turn Coalescing](https://docs.mindroom.chat/configuration/threads/#mid-turn-coalescing) |
| `thread_mode` | string | `"thread"` | `thread` replies in Matrix threads; `room` sends plain room messages with one continuous conversation per room, which suits bridges (Telegram, Signal, WhatsApp) and mobile clients |
| `room_thread_modes` | map | `{}` | Per-room `thread` or `room` overrides keyed by room key, alias, or Matrix room ID; see [Thread Mode Resolution](https://docs.mindroom.chat/configuration/threads/#thread-mode-resolution) |
| `markdown` | bool | `null` | Instruct the agent to format replies as Markdown |
| `learning` | bool | `null` | Enable [Agno Learning](https://docs.mindroom.chat/memory/#agno-learning), a persistent profile of user preferences |
| `learning_mode` | string | `null` | `always` learns after every turn; `agentic` lets the agent decide through a tool call |
| `memory_backend` | string | `null` | `mem0`, `file`, or `none`, overriding `memory.backend`; `none` disables memory but not `learning`. See [Memory](https://docs.mindroom.chat/memory/) |
| `memory_search` | object | `null` | File-memory search override (`mode`, `include`, `include_entrypoint`) when the backend is `file`; omitted fields inherit `memory.search`. See [Searching file memory](https://docs.mindroom.chat/memory/#searching-file-memory) |
| `knowledge_bases` | list | `[]` | Keys under top-level `knowledge_bases`, each at most once; see [Knowledge Bases](https://docs.mindroom.chat/knowledge/) |
| `context_files` | list | `[]` | Workspace files preloaded into the prompt; see [File-Based Context Loading](#file-based-context-loading) |
| `private` | object | `null` | Give each requester a separate copy of the agent's state; see [Private Instances](#private-instances) |
| `num_history_runs`, `num_history_messages`, `compress_tool_results`, `max_tool_calls_from_history` | | | See [History Settings](https://docs.mindroom.chat/configuration/history/#history-settings) |
| `compaction` | object | `defaults.compaction` | Per-agent compaction overrides; `enabled: false` turns automatic compaction off. See [Agent Compaction Settings](https://docs.mindroom.chat/configuration/history/#agent-compaction-settings) |
| `max_tool_calls_per_turn` | int, >= 1 | `null` | Tool calls one turn may execute. Further calls return a tool error, and after this many plus two model requests the turn ends with the text produced so far, which also stops loops of unknown or malformed tool calls |
| `show_tool_calls` | bool | `null` | Show tool-call markers and trace metadata in Matrix messages; see [Tool Calls During Streaming](https://docs.mindroom.chat/streaming/#tool-calls-during-streaming) |
| `worker_tools` | list | `null` | Tools to run in an isolated worker instead of the primary process; `[]` runs everything in the primary process. When unset here and in `defaults`, the deployment's [execution mode](https://docs.mindroom.chat/deployment/sandbox-proxy/#execution-modes) decides. See [Worker Routing](https://docs.mindroom.chat/deployment/sandbox-proxy/#worker-routing) |
| `worker_scope` | string | `null` | How worker runtimes are shared: `shared` (one per agent), `user` (one per user, across agents), or `user_agent` (one per user and agent). Not allowed together with `private`. See [Worker scopes](https://docs.mindroom.chat/deployment/sandbox-proxy/#worker-scopes) |
| `file_access` | string | `null` | `workspace` limits path-taking tools such as `file`, `coding`, `attachments`, and `matrix_message` to the agent workspace and its attachments; `unrestricted` allows any path the tool's process can reach. `shell`, `python`, and other code-execution tools are never confined; isolate them with `worker_tools`. See [File access](https://docs.mindroom.chat/architecture/security-posture/#file-access) |
| `allow_self_config` | bool | `null` | Give the agent a tool to read and change its own entry under `agents:`; see [`self_config`](https://docs.mindroom.chat/tools/agent-orchestration/#self_config) |
| `delegate_to` | list | `[]` | Agents this agent may run as subagents, including itself only when listed; see [Agent Delegation](https://docs.mindroom.chat/tools/agent-orchestration/#agent-delegation) |
| `thread_exports` | bool or object | `null` | Keep YAML exports of the agent's threads under `<workspace>/thread_exports/`; `true` uses the defaults. See [Thread Exports](https://docs.mindroom.chat/thread-exports/#thread-exports) |

Unset fields that appear in the inheritance table under [Defaults](#defaults) take the `defaults` value; a per-agent value overrides it.
`memory_backend` and `memory_search` inherit from the top-level `memory:` section instead.

## Private Instances

Use `private` when one shared agent definition should give each requester their own workspace, file memory, and knowledge index.
The YAML stays shared, while each requester's files live in their own private root.

```yaml
knowledge_bases:
  company_docs:
    path: ./company_docs
    watch: false

agents:
  mind:
    display_name: Mind
    role: A persistent personal AI companion
    model: sonnet
    tools: [file, shell]
    worker_tools: [file, shell]
    memory_backend: file
    private:
      per: user
      root: mind_data
      template_dir: ./mind_template
      context_files: [SOUL.md, USER.md, MEMORY.md]
      knowledge:
        path: memory
        watch: false
    knowledge_bases: [company_docs]
```

Here every user gets their own `mind_data/` root, seeded from `./mind_template/` (for example `SOUL.md`, `USER.md`, `MEMORY.md`, and a `memory/` folder), while `company_docs` stays shared by everyone.
Private roots live at `<storage>/private_instances/<requester scope>/<agent>/<private.root>/`, not next to `config.yaml`, and dedicated workers mount only that root.

How a private agent behaves:

- The template directory is copied into each new private root, and template files added later are copied in without overwriting requester edits.
- The private root is created even without a template.
- `private.context_files` load from the private root; agent-level `context_files` still load from the shared agent workspace.
- With `memory_backend: file`, the private root is the requester's file-memory root; with any other backend, private files exist but are not file memory, and `none` turns memory off.
- Nothing is enabled implicitly: set `memory_backend`, `private.context_files`, and `private.knowledge` explicitly for whatever the template provides; the file names are up to you.
- Because `private.per` sets the agent's worker scope, configuring both `private` and `worker_scope` fails with `Private agents derive their execution scope from private.per; configure private or worker_scope, not both`.
- Private agents cannot be configured as team members, and a shared team member cannot reach one through `delegate_to`.
  Tagging a private agent together with other agents in a message still forms an ad hoc team for that requester.
- A private agent runs only for a known requester; otherwise it fails with `Private agent '<name>' requires an active execution identity to resolve requester-local state`.

### Private Fields

| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `private.per` | `user` or `user_agent` | *required* | Requester boundary that gets its own private instance; it also sets the agent's [worker scope](https://docs.mindroom.chat/deployment/sandbox-proxy/#worker-scopes) |
| `private.root` | string | `<agent_name>_data` | Relative directory name of the private root; cannot be absolute, contain `..`, or start with `sessions`, `learning`, `chroma`, `knowledge_db`, `memory_files`, `calls`, `agent_modes.json`, `agent_modes.lock`, `browser`, `browser-profiles`, or `.sessions-recovery.lock` |
| `private.template_dir` | string | `null` | Directory copied into each private root; relative paths resolve from `config.yaml`, absolute paths are allowed, and a missing directory is a config error (`Agent '<name>' has invalid private.template_dir`) |
| `private.context_files` | list | `null` | Private-root-relative files preloaded into the prompt; cannot escape the private root |
| `private.knowledge` | object | `null` | Per-requester knowledge index built from the private root; see [Private Agent Knowledge](https://docs.mindroom.chat/knowledge/#private-agent-knowledge). Omit it or set `enabled: false` for no index |
| `private.knowledge.enabled` | bool | `true` | Whether to index private knowledge |
| `private.knowledge.description` | string | `""` | What the private knowledge contains, shown to the agent in the `search_knowledge_base` tool description |
| `private.knowledge.path` | string | *required* when enabled | Knowledge directory relative to the private root (`.` allowed) |
| `private.knowledge.watch` | bool | `true` | Refresh the index in the background on access; when `false`, external edits need an explicit refresh |
| `private.knowledge.chunk_size` | int | `5000` | Maximum characters per indexed chunk (min: 128) |
| `private.knowledge.chunk_overlap` | int | `0` | Overlapping characters between adjacent chunks; must be smaller than `chunk_size` |
| `private.knowledge.git` | object | `null` | Git sync, with the same schema as `knowledge_bases.<id>.git`; needs a dedicated `path` subtree, see [Private Agent Knowledge](https://docs.mindroom.chat/knowledge/#private-agent-knowledge) |

## File-Based Context Loading

`context_files` inlines files into the agent's prompt under a `Personality Context` section, without a knowledge base.

- Paths are relative to the agent's workspace, `agents/<name>/workspace/` in the storage directory; absolute paths and `..` are rejected.
  With `memory_backend: file`, the same workspace holds the agent's file memory.
- Files load in list order, each headed by its resolved path so the agent knows where to edit it.
- Missing files are skipped with a warning in the logs.
- Edits take effect on the next reply without a restart.

`defaults.max_preload_chars` (default `50000`) caps the section.
When it is exceeded, MindRoom drops whole files from the start of the list first and then trims the last remaining file from its end.
Each affected file keeps its path and a marker with the omitted character count, so the agent can open the file for the rest.
A cap too small to hold the headings and markers fails with `max_preload_chars=<n> cannot fit required context headings and omission markers`.

## Naming Rules

Agent and team keys may contain only letters, digits, and underscores (`^[a-zA-Z0-9_]+$`).
The same key cannot appear under both `agents:` and `teams:`.
`router`, `user`, and `_shared` are reserved.

## Defaults

The `defaults` section sets values every agent inherits unless it sets its own, plus global-only settings that cannot be overridden per agent.

```yaml
defaults:
  tools: [scheduler]
  learning_mode: agentic
  max_tool_calls_per_turn: 200
  worker_tools: [shell, file, python]
  enable_streaming: true
  max_preload_chars: 50000
```

These defaults apply to each agent that leaves the same field unset:

| Field | Default |
|-------|---------|
| `markdown` | `true` |
| `learning` | `true` |
| `learning_mode` | `always` |
| `num_history_runs`, `num_history_messages`, `max_tool_calls_from_history` | `null` (no limit) |
| `compress_tool_results` | `false` |
| `compaction` | enabled; see [Agent Compaction Settings](https://docs.mindroom.chat/configuration/history/#agent-compaction-settings) |
| `max_tool_calls_per_turn` | `1000` |
| `show_tool_calls` | `true` |
| `worker_tools` | `null` (the deployment's execution mode decides) |
| `worker_scope` | `null` |
| `file_access` | `workspace` |
| `allow_self_config` | `false` |

`defaults.tools` (default `[scheduler]`) is added to every agent with `include_default_tools: true`; set it to `[]` to add nothing.
It accepts the same per-tool overrides as `agents.<name>.tools`.

These settings are global-only:

| Field | Default | Description |
|-------|---------|-------------|
| `enable_streaming` | `true` | Show replies as progressive message edits; see [Streaming](https://docs.mindroom.chat/streaming/#configuration) |
| `streaming` | see [Streaming](https://docs.mindroom.chat/streaming/#configuration) | Timing settings for progressive edits |
| `large_message_strategy` | `sidecar` | Oversized replies as a preview with the full text attached (`sidecar`) or as several complete messages (`split`); see [Large Messages](https://docs.mindroom.chat/streaming/#large-messages) |
| `coalescing.debounce_ms` | `1000` | Milliseconds (>= 0) to wait after media for more attachments or a trailing caption before replying; text replies immediately |
| `show_stop_button` | `true` | Add a 🛑 reaction while an agent responds; see [Stop Button](https://docs.mindroom.chat/chat-commands/#stop-button) |
| `max_consecutive_agent_replies` | `50` | Consecutive agent or team messages (>= 1) before agents stop waking each other; see [Agents mentioning other agents](https://docs.mindroom.chat/authorization/#agents-mentioning-other-agents) |
| `max_preload_chars` | `50000` | Cap (>= 1) on preloaded context files; see [File-Based Context Loading](#file-based-context-loading) |
| `tool_output_auto_save_threshold_bytes` | `51200` | Larger tool outputs are saved to the workspace; see [Workspace and tool output files](https://docs.mindroom.chat/tools/execution-and-coding/#workspace-and-tool-output-files) |
| `worker_grantable_credentials` | `null` | Shared credential services available inside isolated workers (`null` grants none); Google OAuth client and token services and `google_vertex_adc` cannot be granted. See [Credential leases](https://docs.mindroom.chat/deployment/sandbox-proxy/#credential-leases) |
| `thread_summary_model`, `thread_summary_temperature`, `thread_summary_first_threshold`, `thread_summary_subsequent_interval` | `null`, `0.2`, `1`, `10` | Automatic thread summaries; see [Automatic Thread Summaries](https://docs.mindroom.chat/tools/matrix-and-attachments/#automatic-thread-summaries) |

# Model Configuration

The `models` section names the AI providers and model IDs that agents, teams, and the router use.
This page covers model fields, provider credentials, provider-specific setup, request tuning, and switching models from chat.
The same model can have different IDs on different providers; check the [OpenAI](https://developers.openai.com/api/docs/models), [Claude](https://platform.claude.com/docs/en/models/overview), [Gemini](https://ai.google.dev/gemini-api/docs/models), and [OpenRouter](https://openrouter.ai/api/v1/models) catalogs.

## Supported Providers

- `anthropic` - Claude models (Anthropic)
- `bedrock_claude` - Anthropic Claude models on Amazon Bedrock
- `azure` - OpenAI models through Azure OpenAI deployments
- `openai` - GPT models and OpenAI-compatible endpoints
- `codex` or `openai_codex` - OpenAI models available through a local Codex CLI ChatGPT login
- `kimi` or `kimi_code` - Kimi models available through a local Kimi Code CLI login
- `google` or `gemini` - Google Gemini models
- `vertexai_claude` - Anthropic Claude models on Google Vertex AI
- `ollama` - Local models via Ollama
- `llama_cpp` - Local models through an OpenAI-compatible llama.cpp server
- `groq` - Groq-hosted models (fast inference)
- `openrouter` - OpenRouter-hosted models (access to many providers)
- `cerebras` - Cerebras-hosted models
- `deepseek` - DeepSeek models
- `zai` - Z.ai GLM models
- `synthetic` - Built-in Lorem Ipsum model for local conversations and load generation

To generate a starter config for one provider, use `mindroom config init --provider <preset>` (see [Getting Started](https://docs.mindroom.chat/getting-started/)).

## Model Config Fields

| Field | Required | Default | Description |
|-------|----------|---------|-------------|
| `provider` | Yes | - | The AI provider (see supported providers above) |
| `id` | Yes | - | Model ID specific to the provider; for Azure OpenAI, the deployment name |
| `display_name` | No | `null` | Friendly model name shown in clients |
| `icon` | No | `null` | Image path relative to the config file's directory, or a Matrix `mxc://` URI |
| `api` | No | `null` | `responses` or `chat_completions`; valid only with `provider: openai` (see [OpenAI API Models](#openai-api-models)); unset selects automatically |
| `host` | No | `null` | Host URL for self-hosted models (e.g., Ollama) |
| `api_key` | No | `null` | Model-specific API key used instead of the provider's shared key; equivalent to `extra_kwargs.api_key` |
| `extra_kwargs` | No | `null` | Additional provider-specific parameters (see [Extra Kwargs](#extra-kwargs)) |
| `context_window` | No | `null` | Provider context window in tokens, at least 1; budgets history replay and compaction (see [Context Window](https://docs.mindroom.chat/configuration/history/#context-window)); a `compaction.model` or `compaction.fallback_model` needs its own value |
| `stream_idle_timeout_seconds` | No | `null` | Seconds a streamed request may go without a provider event before it counts as stalled, at least 0; `0` disables the limit (see [Stalled Streams](#stalled-streams)) |

The mapping key, such as `default` below, is the stable name agents, teams, the router, and commands use.
`display_name` and `icon` only change how clients show the model and are never sent to the provider:

```yaml
models:
  default:
    provider: openai
    id: gpt-6-astra
    display_name: Quick helper
    icon: icons/helper.png
```

A local `icon` must be a PNG, JPEG, WebP, or GIF image of at most 1 MiB inside the config file's directory; absolute paths fail validation.
A missing, unreadable, unsupported, or out-of-directory image falls back to the client's provider icon and does not prevent startup.
External image URLs are not fetched.
Blank `display_name` or `icon` values count as unset.

## Configuration Examples

```yaml
models:
  # Anthropic Claude
  sonnet:
    provider: anthropic
    id: claude-sonnet-5-5
    context_window: 1000000

  haiku:
    provider: anthropic
    id: claude-haiku-4-5
    context_window: 200000

  # OpenAI
  gpt:
    provider: openai
    id: gpt-6-astra
    context_window: 1050000

  # Google Gemini (both 'google' and 'gemini' work as provider names)
  gemini:
    provider: google
    id: gemini-3.8-flash
    context_window: 1048576

  # Anthropic Claude on Vertex AI
  vertex_claude:
    provider: vertexai_claude
    id: claude-sonnet-5-5
    extra_kwargs:
      project_id: your-gcp-project
      region: global

  # Local via Ollama
  local:
    provider: ollama
    id: qwen3.8:27b
    context_window: 256000
    host: http://localhost:11434

  # OpenRouter (access to many model providers)
  openrouter:
    provider: openrouter
    id: anthropic/claude-sonnet-5.5

  # Groq (fast inference; Qwen3.8 is a preview model)
  groq:
    provider: groq
    id: qwen/qwen3.8-27b
    context_window: 131042

  # Cerebras (65,536 tokens on the free tier; paid plans support 131,072)
  cerebras:
    provider: cerebras
    id: qwen-3.8-27b
    context_window: 65536

  # DeepSeek
  deepseek:
    provider: deepseek
    id: deepseek-v4-pro
    context_window: 1048576

  # Z.ai GLM Coding Plan (OpenAI Chat Completions endpoint)
  glm:
    provider: zai
    id: glm-5.3
    context_window: 1048576
    stream_idle_timeout_seconds: 300  # hosted service behind a custom endpoint; see Stalled Streams
    extra_kwargs:
      base_url: https://api.z.ai/api/coding/paas/v4

  # Custom OpenAI-compatible endpoint (e.g., vLLM, llama.cpp server)
  custom:
    provider: openai
    id: my-model
    extra_kwargs:
      base_url: http://localhost:8080/v1
```

Azure, Bedrock, Codex, and Kimi examples are in their own sections below.
Claude Fable 5.1 uses `claude-fable-5-1` on Anthropic, `anthropic.claude-fable-5-1` on Bedrock, and `anthropic/claude-fable-5.1` on OpenRouter.
Claude Opus 5.5 and Sonnet 5.5 use `claude-opus-5-5` and `claude-sonnet-5-5` on Anthropic and Vertex AI, `anthropic.claude-opus-5-5` and `anthropic.claude-sonnet-5-5` on Bedrock, and `anthropic/claude-opus-5.5` and `anthropic/claude-sonnet-5.5` on OpenRouter.
For the direct DeepSeek API, use `deepseek-flash` for V4.1 Flash or `deepseek-v4-pro` for Pro; the older `deepseek-v4-flash` name is a [temporary compatibility route to V4.1 Flash](https://api-docs.deepseek.com/updates/#date-2026-09-10).
OpenRouter uses the distinct ID `deepseek/deepseek-v4.1-flash`.
[GLM 5.3](https://docs.z.ai/guides/llm/glm-5.3) is available through the GLM Coding Plan endpoint shown above.
The [Qwen3.8-27B model card](https://huggingface.co/Qwen/Qwen3.8-27B) and each hosting provider document their own context limits.

### Claude 5.5 and Fable 5.1 Limits

Fable 5.1, Opus 5.5, and Sonnet 5.5 follow the same [tool-choice rules](https://platform.claude.com/docs/en/models/opus-5-5/migration-guide): `auto` and `none` are allowed, while forcing `any` or a named tool returns an error.
They reject `thinking: {type: disabled}` and manual `budget_tokens`; lower effort with `extra_kwargs.output_config: {effort: low}` instead, or use `extra_kwargs.thinking: {type: between_tools}` on [Sonnet 5.5](https://platform.claude.com/docs/en/models/sonnet-5-5/whats-new-sonnet-5-5#turn-off-up-front-thinking).
With adaptive thinking, the notes these models write between tool calls are hidden, so responses show tool traces without that narration; Sonnet 5.5's `between_tools` setting returns those notes as text.

## Environment Variables

Provider credentials come from environment variables, stored credentials, CLI logins, or a model-specific key.
A model uses the key saved for it in the dashboard's **Models** editor first, then `api_key` or `extra_kwargs.api_key` from its config, then the provider's shared key from the environment or credential store.
Setting both `api_key` and `extra_kwargs.api_key` is a validation error, and a blank key counts as unset.
A model whose `extra_kwargs` authenticate another way (`auth_token` for `anthropic`, `azure_ad_token` or `azure_ad_token_provider` for `azure`, or `vertexai: true` for `google` and `gemini`) never receives the shared key, although the provider SDK may still read `ANTHROPIC_API_KEY` or `AZURE_OPENAI_API_KEY` from the process environment.
`codex`, `kimi`, `bedrock_claude`, `vertexai_claude`, and `synthetic` models authenticate without an API key and ignore a configured one.

Shared API keys are read from these variables:

```bash
ANTHROPIC_API_KEY=sk-ant-...
AZURE_OPENAI_API_KEY=...
AZURE_OPENAI_ENDPOINT=https://your-resource.openai.azure.com
# Optional, only when overriding the default Azure OpenAI API version:
# AZURE_OPENAI_API_VERSION=2024-10-21
# Optional Azure deployment for the client:
# AZURE_OPENAI_DEPLOYMENT=...
OPENAI_API_KEY=sk-...
GOOGLE_API_KEY=...
GROQ_API_KEY=...
OPENROUTER_API_KEY=...
CEREBRAS_API_KEY=...
DEEPSEEK_API_KEY=...
ZAI_API_KEY=...
```

For Amazon Bedrock Claude, use standard AWS credential resolution:

```bash
AWS_REGION=us-east-1          # AWS_DEFAULT_REGION is used when AWS_REGION is unset
# Optional static credentials:
AWS_ACCESS_KEY_ID=...
AWS_SECRET_ACCESS_KEY=...
AWS_SESSION_TOKEN=...
# Optional local profile instead:
AWS_PROFILE=...
```

Without static credentials or a profile, Bedrock uses the runtime IAM role.

For Ollama, you can also set:

```bash
OLLAMA_HOST=http://localhost:11434
```

For Vertex AI Claude, set these instead of an API key:

```bash
ANTHROPIC_VERTEX_PROJECT_ID=your-gcp-project
CLOUD_ML_REGION=global
# Optional custom Vertex AI endpoint:
# ANTHROPIC_VERTEX_BASE_URL=https://...
```

For `claude-sonnet-5-5`, use the [global endpoint or a supported multi-region endpoint](https://platform.claude.com/docs/en/build-with-claude/claude-on-vertex-ai#global-multi-region-and-regional-endpoints), with `region` / `CLOUD_ML_REGION` set to `global`, `us`, or `eu`.
Authenticate with `gcloud auth application-default login` or set `GOOGLE_APPLICATION_CREDENTIALS` to a service account key file.

For Gemini on Vertex AI (`provider: google` with `extra_kwargs.vertexai: true`), authenticate the same way and set `GOOGLE_CLOUD_PROJECT` and `GOOGLE_CLOUD_LOCATION`, or pass `extra_kwargs.project_id` and `extra_kwargs.location`.
Gemini reads these variables and `GOOGLE_APPLICATION_CREDENTIALS` only from the process environment, not from `.env`.

`OPENAI_BASE_URL` is described in [Configuration — Model Providers](https://docs.mindroom.chat/configuration/#model-providers).

### File-based Secrets

For container environments (Kubernetes, Docker Swarm), supported API-key and secret variables can also use a `_FILE` suffix:

```bash
# Instead of setting the key directly:
ANTHROPIC_API_KEY=sk-ant-...

# Point to a file containing the key:
ANTHROPIC_API_KEY_FILE=/run/secrets/anthropic-api-key
```

This works for all API key environment variables (e.g., `OPENAI_API_KEY_FILE`, `GOOGLE_API_KEY_FILE`, etc.).

## OpenAI API Models

Set `api: responses` or `api: chat_completions` on an `openai` model to choose the API independently of its model ID or endpoint.
When `api` is unset, GPT 5.4 and newer on the first-party OpenAI endpoint use Responses, and every other route, including all custom OpenAI-compatible endpoints, uses Chat Completions.
The endpoint must support the selected API.

```yaml
models:
  astra:
    provider: openai
    id: gpt-6-astra
    api: responses
    extra_kwargs:
      base_url: http://localhost:4000/v1
      reasoning_effort: high
```

[GPT-6 Astra requires Responses for function calling](https://developers.openai.com/api/docs/guides/latest-model), and GPT-6 Sol and Luna support function calling in Chat Completions only with `reasoning_effort: none`.
Through a proxy or custom alias, set `api: responses` for GPT-6 models, or tool calls on Astra, Sol, and Luna fail at their default reasoning effort.
Set `extra_kwargs.reasoning_effort` or `extra_kwargs.reasoning` only when you want to customize reasoning.
Selecting Responses keeps any `extra_kwargs.store: false` setting.
How `api` affects tool search is described in [Native Server-Side Tool Search](https://docs.mindroom.chat/tools/dynamic-tools/#native-server-side-tool-search).

## OrcaRouter via the OpenAI-Compatible Endpoint

[OrcaRouter](https://docs.orcarouter.ai/introduction) works through the `openai` provider with its endpoint and an OrcaRouter API key:

```yaml
models:
  orcarouter:
    provider: openai
    id: orcarouter/auto
    api: chat_completions
    stream_idle_timeout_seconds: 300  # hosted service behind a custom endpoint; see Stalled Streams
    extra_kwargs:
      base_url: https://api.orcarouter.ai/v1
      api_key: your-orcarouter-api-key
```

`orcarouter/auto` lets OrcaRouter choose a model per request; replace it with an ID from its [catalog](https://docs.orcarouter.ai/getting-started/models) to pin one.
Set an agent's `model: orcarouter`, or select it for a thread with `!model orcarouter`.
To keep the key out of YAML, omit `extra_kwargs.api_key` and save the key in this model's **API Key** field in the dashboard's **Models** editor, so OrcaRouter and direct OpenAI models use separate keys.
`ORCAROUTER_API_KEY` is not read automatically.

## Codex Models with ChatGPT Login

Use `provider: codex` to call models through an authenticated local Codex CLI session instead of the billed OpenAI API.
Run `codex login` first so `~/.codex/auth.json` contains ChatGPT OAuth tokens; MindRoom refreshes the access token as needed.
Model access and usage limits depend on the logged-in ChatGPT account, so check the [current Codex model catalog](https://developers.openai.com/codex/models).

| Model | Model ID | Best fit |
|-------|----------|----------|
| GPT-6.1 Sol | `gpt-6.1-sol` | Near-Astra reasoning for long-running work; the starter default |
| GPT-6 Astra | `gpt-6-astra` | The hardest end-to-end reasoning and agentic work |
| GPT-6 Luna | `gpt-6-luna` | Efficient, high-volume work such as summaries and routing |

Older or preview slugs work when the account exposes them, the form `openai-codex/gpt-6.1-sol` is accepted, and the legacy `gpt-5.6` alias maps to `gpt-5.6-sol`.
If Codex state lives outside `~/.codex`, set `CODEX_HOME` in the process environment or `extra_kwargs.codex_home`; `~` prefixes are expanded.
`mindroom config init --provider codex` generates this config (see [CLI](https://docs.mindroom.chat/cli/)):

```yaml
models:
  default:
    provider: codex
    id: gpt-6.1-sol
    display_name: Sol
    context_window: 258000
    extra_kwargs:
      reasoning_effort: medium
  astra:
    provider: codex
    id: gpt-6-astra
    display_name: Astra
    context_window: 258000
    extra_kwargs:
      reasoning_effort: medium
  luna:
    provider: codex
    id: gpt-6-luna
    display_name: Luna
    context_window: 258000
    extra_kwargs:
      reasoning_effort: low

router:
  model: luna

defaults:
  thread_summary_model: luna
```

Use `258000` as the context window: it is the effective budget of the Codex ChatGPT surface (just under 95% of its 272000-token window), not the larger window of the OpenAI API.
Set reasoning effort with `extra_kwargs.reasoning_effort`; GPT-6.1 Sol, GPT-6 Astra, and GPT-6 Luna accept `low`, `medium`, `high`, `xhigh`, and `max`.
Codex clients also offer Ultra for Sol and Astra, but Ultra's Codex-managed subagents are not available through MindRoom.
The provider supports text and image input with text output; transcription, text-to-speech, and realtime speech are not supported.
The adapter depends on the Codex CLI login format and backend, so a Codex CLI update can require a MindRoom update; use `provider: openai` for the public API contract and API billing.

## Kimi Models with Kimi Code Login

Use `provider: kimi` to call Kimi models through an authenticated local Kimi Code CLI session (Kimi Code subscription) instead of the billed Moonshot API.
Run `kimi` and `/login` first so `~/.kimi-code/credentials/kimi-code.json` contains OAuth tokens; MindRoom refreshes the access token as needed and calls `https://api.kimi.com/coding/v1`.

| Model | Model ID | Best fit |
|-------|----------|----------|
| Kimi K3 | `k3` | Flagship reasoning, long-horizon coding, and agent work with up to a 1M-token context |
| Kimi K3 256k | `k3-256k` | The same K3 generation with a 256k-token context |
| Kimi for Coding | `kimi-for-coding` | Coding-tuned tier exposed by the Kimi Code CLI |
| Kimi for Coding Highspeed | `kimi-for-coding-highspeed` | Faster coding tier exposed by the Kimi Code CLI |

[Kimi Code context limits depend on the subscription plan](https://www.kimi.com/code/docs/en/kimi-code/models.html): `k3` supports 1,048,576 tokens on Pro (legacy Allegretto) and higher, while Plus (legacy Moderato) is limited to 262,144.
The starter config and this example require Pro/Allegretto or higher; on Plus/Moderato, and for `k3-256k` on any plan, set `context_window: 262144`.

```yaml
models:
  default:
    provider: kimi
    id: k3
    context_window: 1048576
```

The form `kimi-code/k3` is accepted.
If Kimi Code state lives outside `~/.kimi-code`, set `KIMI_CODE_HOME` or `extra_kwargs.kimi_home`; `~` prefixes are expanded.
Kimi K3 always reasons before replying, so responses include reasoning tokens even for short answers.
The adapter depends on the Kimi Code CLI login format and backend, so a Kimi Code update can require a MindRoom update.

## OpenRouter Provider Routing

OpenRouter routes each request to one of several upstream providers serving the model, and upstream quality varies.
Control routing with OpenRouter [provider preferences](https://openrouter.ai/docs/features/provider-routing) in `extra_kwargs.extra_body`:

```yaml
models:
  deepseek:
    provider: openrouter
    id: deepseek/deepseek-v4.1-flash
    context_window: 1048576
    extra_kwargs:
      extra_body:
        provider:
          sort: price                     # cheapest available endpoint
          # order: [fireworks, together]  # or pin specific upstreams, in order
          # allow_fallbacks: false        # fail instead of using unlisted upstreams
```

Provider slugs are in the `tag` field of `https://openrouter.ai/api/v1/models/<model-id>/endpoints`.
Account-level OpenRouter settings (ignored providers, data-policy filters) still apply, so pinning an upstream your account excludes fails with `No endpoints found`.
Verify a new pin with a direct API request and check the `provider` field in the response.

## Azure OpenAI

Use `provider: azure` for models deployed through Azure OpenAI, with `id` set to your deployment name rather than the upstream model name.
Set the Azure variables listed in [Environment Variables](#environment-variables) in the config-adjacent `.env` file or the exported environment.

```yaml
models:
  default:
    provider: azure
    id: your-azure-openai-deployment
```

Deployment limits vary, so the starter config leaves `context_window` unset; set it to your deployment's limit when you know it.

## Amazon Bedrock Claude

Use `provider: bedrock_claude` to call Anthropic Claude through Amazon Bedrock, with `id` set to a Bedrock model ID or inference profile ID enabled in your AWS account and region.
The `aws_bedrock` optional extra installs automatically on first use unless `MINDROOM_NO_AUTO_INSTALL_TOOLS=1` is set; otherwise install `mindroom[aws_bedrock]`.
AWS settings come from the config-adjacent `.env` file, the exported environment, a local AWS profile, or the runtime IAM role.

```yaml
models:
  default:
    provider: bedrock_claude
    id: anthropic.claude-opus-5-5
    context_window: 1000000
```

Bedrock lists Fable 5.1 and Sonnet 5 as open access, while Opus 5.5 and Sonnet 5.5 access can depend on the AWS account and region.
The Bedrock starter config defaults to Opus 5.5 with commented alternatives, so confirm access, or switch to its `fable` alternative or `id: anthropic.claude-sonnet-5`.

## Built-In Synthetic Model

Use `provider: synthetic` to run normal MindRoom conversations without an API key or model server, for example for load testing.
The model streams a repeatable random amount of Lorem Ipsum at a fixed rate, and when the agent has the `shell` tool it occasionally calls `run_shell_command` with `echo hi` before continuing.
The `extra_kwargs` values below are the defaults:

```yaml
models:
  synthetic:
    provider: synthetic
    id: lorem-ipsum
    extra_kwargs:
      seed: 1
      min_response_chars: 320
      max_response_chars: 960
      chunk_chars: 40
      chars_per_second: 80
      tool_call_probability: 0.2

agents:
  load_test:
    display_name: Load Test
    role: Generate synthetic traffic.
    model: synthetic
    tools: [shell]
    rooms: [lobby]
```

Tag `@load_test` in Lobby to receive a streamed synthetic reply.
Set `tool_call_probability` to `1` to call the shell tool on every turn or `0` to disable tool calls.
Set `chars_per_second` to `0` to stream without pacing delays for maximum throughput.
Changing `seed` changes the response length, split point, and tool-call choice, which stay the same for a given conversation history.

## Extra Kwargs

`extra_kwargs` sets additional parameters on the underlying [Agno](https://docs.agno.com/) model class.
Common options include:

- `base_url` - Custom API endpoint (useful for OpenAI-compatible servers)
- `temperature` - Sampling temperature
- `max_tokens` - Maximum tokens in response
- `extra_body` - Extra JSON body fields for OpenAI-compatible providers (e.g., [OpenRouter provider routing](#openrouter-provider-routing))

Some models reject sampling controls:

- Claude Fable 5.1, Opus 5.5, Sonnet 5.5, Opus 5, and Sonnet 5 reject non-default `temperature`, `top_p`, and `top_k`, so MindRoom omits them on Anthropic, Bedrock, and Vertex requests.
- MindRoom also omits those controls for direct Gemini 3.8 Flash and Gemini 3.5 Flash-Lite requests.
- [GPT-6 Astra does not support `temperature`, `top_p`, or `top_logprobs`](https://developers.openai.com/api/docs/guides/latest-model#update-api-and-model-parameters), and GPT-6 Sol and Luna reject them unless reasoning effort is `none`; leave them out of these models' `extra_kwargs`.
- For Mem0 memory extraction with `provider: openai`, use GPT-6 Luna; MindRoom drops the `temperature` and `top_p` values Mem0 sends, which GPT-6 models reject.

## Prompt Caching

Claude and OpenAI Responses models keep shared agent instructions (identity, role, workspace context files, and tool instructions) separate from the current date and session context, so the shared part can be reused across conversations when the provider cache allows it.
Cache reads and writes appear in usage metrics as cached and cache-write input tokens.

The `anthropic`, `bedrock_claude`, and `vertexai_claude` providers enable prompt caching with a one-hour lifetime by default.
The `openrouter` provider caches the same way for model IDs that route to Anthropic, such as `anthropic/claude-sonnet-5.5` and `~anthropic/claude-sonnet-latest`, using OpenRouter [`cache_control` breakpoints](https://openrouter.ai/docs/guides/best-practices/prompt-caching); other OpenRouter model families cache implicitly.
Set `extra_kwargs.cache_system_prompt: false` to turn off MindRoom's Claude cache breakpoints, or `extra_kwargs.extended_cache_time: false` to use the five-minute lifetime.

The `openai` Responses API and `codex` providers rely on provider-managed caching; on the public OpenAI API, GPT-5.6 and later also get an explicit cache breakpoint after the shared instructions.
See [OpenAI's prompt caching guide](https://developers.openai.com/api/docs/guides/prompt-caching) for eligibility and accounting.
Set `extra_kwargs.cache_system_prompt: false` on a Responses model to send the instructions as one undivided system message.
Chat Completions providers other than OpenRouter Claude routes keep their usual request format.

Kimi Code caches repeated request prefixes automatically and reports them as `cached_tokens`.
For `codex` and `kimi`, conversations share a `prompt_cache_key` per agent and requester within one MindRoom storage path, account, and channel, while different agents and requesters stay separate.
Set `extra_kwargs.prompt_cache_key` to choose your own cache group, for example to separate deployments that share a storage path and provider account, or set it to `null` to send no key.
A shared key still needs identical prefixes and tools, and provider routing can produce cache misses.

## Stalled Streams

A provider sometimes accepts a streamed request and then sends nothing more, which would leave the agent busy and queue every follow-up message behind it.
MindRoom ends a streamed request when the provider sends no event for `stream_idle_timeout_seconds`.
If nothing was streamed yet, MindRoom sends the request once more; a second silent attempt, or a stall after text already appeared, ends the turn with "⚠️ Model provider temporarily unavailable after automatic retries. Please try again shortly."
The limit measures the gap between provider events, so long replies and slow tool calls are unaffected.
It applies only to streamed responses (`defaults.enable_streaming: true`, the default).

By default the limit is 300 seconds for hosted providers on their built-in endpoint.
Local servers can stay silent for minutes while a request waits in a queue or a model loads, so `ollama`, `llama_cpp`, and any model with a configured endpoint get no limit unless you set one.
A configured endpoint is a `base_url` anywhere in `extra_kwargs` (such as `extra_kwargs.base_url` or `extra_kwargs.client_params.base_url`) or one read from `OPENAI_BASE_URL` or `ANTHROPIC_VERTEX_BASE_URL`.
Set the limit explicitly for a hosted service behind a custom endpoint, such as OrcaRouter or the Z.ai coding endpoint.
Endpoint variables that only a provider SDK reads, such as `ANTHROPIC_BASE_URL`, are not detected; set `0` when one points at a local server.
For a local model, pick a value above its slowest queue wait plus model load; set `0` to turn the limit off for a hosted model:

```yaml
models:
  local:
    provider: openai
    id: qwen3-coder
    extra_kwargs:
      base_url: http://localhost:8080/v1
    stream_idle_timeout_seconds: 1800  # longest wait for a queued request plus model load
  deep_thinker:
    provider: openrouter
    id: openai/gpt-6-astra
    stream_idle_timeout_seconds: 0  # never cut off long silent reasoning
```

## Hot Reload

After a successful config reload, changes to a model definition, including its provider options, model ID, and `context_window`, apply from the next agent, team, or router response without restarting the bot.
Agents with voice calls enabled can still restart, because their call runtime keeps its configuration between responses.

## Model Overrides in Chat

Each agent, team, and the router normally answers with its own configured `model` (`router.model` for the router).
Users can change that per thread or per room, and the most specific choice wins:

1. An explicit model for one run, such as a delegated child run.
2. The thread override set with `!model`, the `thread_model` tool, or a client's model picker.
3. The runtime room default set with `!room_model`.
4. The authored room default in `room_models`.
5. The entity's configured model.

Thread and room overrides name models from the `models:` section and survive restarts.
A Matrix client's model picker lists the configured models and changes the thread override like `!model`.
The picker works in encrypted and unencrypted rooms when the router and at least one agent you may address are joined; teams alone do not qualify.
It shows each model's key, display name, provider, model ID, and icon, never API keys, hosts, local paths, or `extra_kwargs`.
Catalogs with more than 256 models or over 64 KiB are not offered to the picker; `!model` still works.
The client protocol is described in [Model Selection Protocol](https://docs.mindroom.chat/architecture/matrix/#model-selection-protocol).

### `!model`

Show or switch the model that the agents, teams, and router you may address use in the current thread.

```
!model
!model list
!model opus
!model reset
```

`!model` and `!model list` show each thread override with the entities it applies to, plus the available model names.
The override applies from the next message in the thread, and only to the entities whose `access` admits the user who set it in this room; every other entity keeps its own thread override or room-level model.
During a configured team's turn, the team's thread override also applies to its member agents, because the team's `access` reaches them.
`!model reset` removes the override only for the entities you may address.
Other threads and rooms are unaffected.

### `!room_model`

Show or switch the default model for every agent, team, and the router in the current room.

```
!room_model
!room_model list
!room_model opus
!room_model reset
```

`!room_model` and `!room_model list` show the current runtime override and available model names to authorized room members.
Setting and resetting require a Matrix room admin; other users get `❌ Room admin only.`
`!room_model opus` stores a room override without modifying `config.yaml`, keyed by Matrix room ID, so it survives restarts and room-alias changes.
The new default applies to later turns; a turn in progress or waiting for approval keeps the model it started with.
`!room_model reset` removes the runtime override so `room_models` or each entity's configured model applies again.

To set an authored room default in `config.yaml`, map managed room names to model names with `room_models` (default `{}`):

```yaml
room_models:
  lobby: haiku
  dev: opus
```

### [`thread_model`]

The `thread_model` tool lets an agent list models and show, switch, or reset the current thread's model override, like `!model`.
It has no tool-specific configuration.

```yaml
agents:
  assistant:
    tools:
      - thread_model
```

- `list_models()` returns every configured model name with its provider and model ID and works outside a thread.
- `get_thread_model()` returns an `overrides` map from each entity with an active thread override to its model, plus the available model names; an override naming a model since removed from `models` is ignored and reported under `stale_overrides`.
- `switch_thread_model(model_name, when)` accepts a configured model name and rejects unknown names with the available list.
  `when` is `next-turn` (default), which keeps the current response on its model, or `after-toolcall`, which continues the current response with the new model after the tool call.
- `reset_thread_model()` removes the thread override so room-level selection applies again.

All functions except `list_models` require an active thread and return an error outside one.
Overrides set by the tool follow the same entity scope and team rules as [`!model`](#model) and are stored per thread root and entity in `mindroom_data/tracking/thread_models.json`.

# Team Configuration

A team lets several agents answer one request together, either as a named team under `teams:` that has its own Matrix account, or as an ad hoc team formed when a message involves several agents.

<video controls playsinline preload="metadata" aria-label="Mentioning two agents forms a team that answers together" style="width: 100%">
  <source src="https://github.com/user-attachments/assets/b6ea7dff-8542-409f-823a-0cd3590a9555#t=0.1" type="video/mp4" media="(prefers-color-scheme: dark)">
  <source src="https://github.com/user-attachments/assets/3c28159d-4d3e-4e47-bc5e-07e4e0c5eed1#t=0.1" type="video/mp4">
</video>

## Team Modes

| Mode | What happens | Use when | Example |
|------|--------------|----------|---------|
| `coordinate` (default) | The team model splits the task into subtasks, assigns each to the member whose role fits, runs them in sequence or in parallel depending on their dependencies, and synthesizes the results | Members need to do different parts of the work | "Get weather and news": one agent fetches weather, another fetches news |
| `collaborate` | Every member works on the same task independently, and the team model synthesizes their answers | You want several perspectives on one question | "What do you think about X?": every agent answers, then the views are combined |

A team reply opens with a `🤝 Team Response (<members>)` header, shows each member's contribution under its name, and ends with a **Team Consensus** section; without a consensus it notes that only the individual responses are shown.

## Named Teams

```yaml
teams:
  dev_team:
    display_name: Dev Team
    role: Development team for building features
    agents: [architect, coder, reviewer]
    mode: coordinate
    rooms: [dev]
    model: sonnet

  research_team:
    display_name: Research Team
    role: Research team for comprehensive analysis
    agents: [researcher, analyst, writer]
    mode: collaborate
    num_history_runs: 8
    compaction:
      enabled: true
      threshold_percent: 0.8
```

| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `display_name` | string | *required* | Name shown in Matrix |
| `role` | string | *required* | Description of the team's purpose |
| `agents` | list | *required* | Names of agents under `agents:` that form the team; at least one, no duplicates |
| `mode` | string | `coordinate` | `coordinate` or `collaborate` |
| `rooms` | list | `[]` | Room keys, aliases, or Matrix room IDs the team joins and responds in |
| `accept_invites` | bool or list | `true` | `true` accepts every room invitation, `false` or `[]` accepts none, and a list accepts only inviters matching an exact or wildcard Matrix user ID; this is separate from the members' own settings. Joining a room never grants its members access; see [Invitations](https://docs.mindroom.chat/authorization/#invitations) |
| `access` | object | `null` | Who may converse with the team: `current_room_members`, `members_of_rooms`, and `users`. When omitted, members of the team's own managed `rooms` have access. See [Responder access](https://docs.mindroom.chat/authorization/#responder-access) |
| `model` | string | `"default"` | Key under `models:` used for coordination and synthesis |
| `max_tool_calls_per_turn` | int, >= 1 | `defaults.max_tool_calls_per_turn` | Tool calls the team may execute in one turn, delegations to members included; each member keeps its own budget, and the end-of-budget behavior matches [agents](https://docs.mindroom.chat/configuration/agents/#configuration-options) |
| `num_history_runs`, `num_history_messages`, `max_tool_calls_from_history`, `compaction` | | | History replay and compaction for the team's shared history; see [Team History and Compaction](https://docs.mindroom.chat/configuration/history/#team-history-and-compaction) |

Team keys follow the agent [naming rules](https://docs.mindroom.chat/configuration/agents/#naming-rules), and a key cannot be used under both `agents:` and `teams:`.
Private agents, and agents that can reach a private agent through `delegate_to`, cannot be team members; see [Private Instances](https://docs.mindroom.chat/configuration/agents/#private-instances).

## Ad Hoc Teams

MindRoom forms a team without any `teams:` entry when:

1. **Several agents are tagged in one message**, for example `@code @research analyze this`.
2. **An untagged follow-up in a thread** where several agents were mentioned earlier or several agents have replied.
3. **An untagged message in the main timeline of a DM room** that contains several agents.

Tagging exactly one agent gets a reply from that agent alone.
Threads where two or more people have posted never form a team from earlier thread context; tag the agents again in the current message (see [Multi-Human Thread Protection](https://docs.mindroom.chat/configuration/threads/#multi-human-thread-protection)).

If any tagged agent cannot take part, for example because it is not in the room or not available to you, MindRoom replies with the reason instead of forming a partial team, such as `Team request includes agent 'code' that is not available in this room.`
For teams formed from thread context or a DM room, unavailable agents are left out, and if only one remains it answers alone.

The `default` model picks the mode for each ad hoc team from the request: `coordinate` when the agents have different subtasks, `collaborate` for opinions or brainstorming, and `collaborate` when the choice fails.
Ad hoc teams take their history and compaction settings from `defaults`; see [Team History and Compaction](https://docs.mindroom.chat/configuration/history/#team-history-and-compaction).

# Router Configuration

The router is a built-in MindRoom account that is always present, cannot be disabled, and joins every room with configured agents or teams.
It picks which agent or team answers a message that mentions no one, accepts room invitations, and sends welcome messages.
Configure it under `router:` to choose its routing model, who may invite it into rooms, who may interact with it, and optional System One responder selection.

See also [Threads, Replies & Participation](https://docs.mindroom.chat/configuration/threads/), [Access Control](https://docs.mindroom.chat/authorization/), and [Logs & Monitoring](https://docs.mindroom.chat/deployment/operational-log-events/).

## Configuration

```yaml
router:
  model: haiku          # Must exist under models
  accept_invites: true
```

| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `model` | string | `"default"` | Model alias used for routing decisions |
| `judgment` | object or null | `null` | Optional System One responder selection before LLM routing; see [Responder Selection Judgments](#responder-selection-judgments) |
| `access` | object or null | `null` | Who may interact with the router; see [Access Control](https://docs.mindroom.chat/authorization/) |
| `accept_invites` | bool or list[string] | `true` | `true` accepts every room invitation, `false` or `[]` accepts none, and a list accepts only inviters matching an exact or wildcard Matrix user ID |

Point `model` at a cheap, fast alias to keep routing inexpensive.
Room and thread model overrides also apply to routing.

### Invitations and access

Inviter patterns are matched after [identity alias](https://docs.mindroom.chat/authorization/#requester-identity-and-private-state) resolution and use the same case-sensitive wildcard rules as `access.users`.
Rooms joined through an invitation are kept across restarts and survive room cleanup.
Accepting an invitation only joins the room; every interaction afterwards still requires the router's `access` policy.
Router `access` defaults to `current_room_members: true`, which still applies when you set only `access.users`; set `current_room_members: false` to remove it.

## How Routing Works

When a message mentions no agent or team, MindRoom first finds the responders the sender may address in that room.

- In configured rooms, candidates are the agents and teams that list the room in `rooms`.
- In ad-hoc rooms the router joined by invitation, candidates are the MindRoom agents and teams currently joined to the room.
- Either way, candidates the sender is not allowed to address are removed.

Then:

1. If the thread requires explicit targeting, the router stays silent; eligible individual agents already in the thread may still use opt-in [adaptive participation](https://docs.mindroom.chat/configuration/threads/#adaptive-participation).
2. If exactly one eligible responder remains, it answers directly without an AI routing call.
3. If several remain, the router reads the message and up to three previous thread messages (each cut to 100 characters) and picks the best match from the candidates' roles, tools, and instructions.
4. The router posts a message mentioning the chosen agent or team (for example "@code could you help with this?"), and that entity answers in the thread.

If routing fails, for example because of a model error or an invalid choice, the router replies "Please try mentioning an agent or team directly with @ or rephrase your request."
Mentioning an agent or team with `@name` always bypasses routing, but the entity still answers only if it is in the room, listed for that room in its `rooms` when the room is configured, and allowed by its `access`.
See [Multi-Human Thread Protection](https://docs.mindroom.chat/configuration/threads/#multi-human-thread-protection) for when threads require explicit tags.

### Mentioning the router

The router is not a conversational agent.
A message that mentions only the router gets a short rules-of-engagement reply instead of an answer.
Mention a specific agent or team to get an answer, or several agents for an ad-hoc collaboration.

## Responder Selection Judgments

Set `router.judgment` to let System One (TypeSafe) choose among the eligible agents and teams before ordinary LLM routing.
It is off by default, even when `TYPESAFE_API_KEY` is set.
Explicit mentions, thread participation rules, access control, and the single-candidate shortcut work the same way with or without it.

Set `TYPESAFE_API_KEY` in the process environment or the `.env` next to `config.yaml`, then enable it:

```yaml
router:
  model: default
  judgment:
    provider: typesafe
    threshold: 0.8
    timeout_seconds: 1.5
```

| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `provider` | string | Required | Must be `typesafe` |
| `threshold` | float | `0.8` | From `0` to `1`; both the chosen option's probability and System One's confidence must reach it |
| `timeout_seconds` | float | `1.5` | Positive deadline of at most `30` seconds |

Unknown fields are rejected.

System One receives each candidate's role, tools, delegation targets, and brief instructions, the current message, and the last three visible messages with senders replaced by aliases.
It does not receive system prompts, tool results, attachment contents, or the rest of the conversation.
It runs only with 2 to 253 candidates and a request of at most 16,000 bytes; messages are never shortened, so long input or input that looks like a secret uses ordinary routing instead.

| Outcome | Result |
|---------|--------|
| A candidate is chosen | The router mentions that agent or team as usual |
| `no_fit` | The router asks the user to mention a responder or rephrase; OpenAI-compatible [`auto`](https://docs.mindroom.chat/openai-api/#auto-routing) requests get HTTP 400 with code `no_suitable_responder` |
| `multiple` | Ordinary LLM routing picks one responder; several agents are never launched |
| Tie, low confidence, abstention, missing key, full judgment limit, timeout, or error | Ordinary LLM routing with `router.model` |

The router's prompt overrides apply only to LLM routing, not to System One.
Router judgments share the process-wide limit and logging described in [Judgment Backends](https://docs.mindroom.chat/configuration/threads/#judgment-backends), with at most one router judgment at a time.

## Welcome Messages

After accepting an invitation into a room with no message history, the router posts a welcome if the inviter has router access, listing the agents and teams the inviter may address, how to mention them, and a quick command reference.
At startup, empty configured rooms get a welcome listing their configured responders.
Send [`!hi`](https://docs.mindroom.chat/chat-commands/#hi) to see the welcome again, limited to responders visible to you.

The router's other duties are documented on their own pages: [command handling](https://docs.mindroom.chat/chat-commands/#command-handling), [room management](https://docs.mindroom.chat/rooms/#room-management), [voice message processing](https://docs.mindroom.chat/voice/#voice-message-processing), [configuration confirmations](https://docs.mindroom.chat/chat-commands/#configuration-confirmations), and [scheduled task restoration](https://docs.mindroom.chat/scheduling/#scheduled-task-restoration).

# Threads, Replies & Participation

This page covers how agents group replies into threads or one room conversation, when agents answer in threads with several people, how agents can opt into judging untagged messages, what happens to messages sent while an agent is still working, and how edits and restarts affect replies.
See also [Routing & Responder Selection](https://docs.mindroom.chat/configuration/router/), [Access Control](https://docs.mindroom.chat/authorization/), and [Logs & Monitoring](https://docs.mindroom.chat/deployment/operational-log-events/).

## Thread Mode Resolution

Each agent's `thread_mode` and `room_thread_modes` fields (see [Agent Configuration](https://docs.mindroom.chat/configuration/agents/)) choose between Matrix threads and one continuous conversation per room.
A room override set with [`!thread_mode`](#thread_mode) takes precedence for every agent, team, and the router in that room.
Without one, an agent uses the `room_thread_modes` entry matching the current room by Matrix room ID, room key, or alias, and otherwise its `thread_mode`.

A team uses its members' mode for the room when all members agree, and `thread` when they differ.
The router uses the mode shared by all agents relevant to the room, including agents that join through `teams.<name>.rooms`, and `thread` when they differ.

### `!thread_mode`

Show or switch how future agent replies are grouped in the current Matrix room.

```
!thread_mode
!thread_mode show
!thread_mode room
!thread_mode thread
!thread_mode reset
```

`!thread_mode` and `!thread_mode show` (also `list` or `status`) show the current room override.
`!thread_mode room` uses one continuous conversation for the whole room.
Room-mode conversations are not included in [thread exports](https://docs.mindroom.chat/thread-exports/).
`!thread_mode thread` uses Matrix threads for separate conversations in this room.
`!thread_mode reset` (also `clear`) removes the room override so agents use their configured `thread_mode` and `room_thread_modes` again.
Only Matrix room admins can set or reset the override; others get `Room admin only.`
The override is stored in MindRoom's runtime state under `mindroom_data/tracking`, not in `config.yaml`, so it works when config is static or read-only.

## Threading (MSC3440)

Agents reply in [MSC3440](https://github.com/matrix-org/matrix-spec-proposals/blob/main/proposals/3440-threading-via-relations.md) threads.
In `thread` mode, an agent answering a room-level message starts a new thread on it.
Clients and bridges that send plain replies instead of thread replies still work: a plain reply whose reply chain reaches a thread joins that thread, and one that never reaches a thread stays room-level.
Edits, reactions, and redactions belong to the same thread as the message they target.

## Multi-Human Thread Protection

When two or more people have posted in a thread, agents answer only when explicitly mentioned, so they do not interrupt a human conversation.
The router stays silent in such threads, and they never automatically form ad hoc teams.
Agents that already replied there can opt into [Adaptive Participation](#adaptive-participation) to judge untagged messages.

The rules are:

1. **Mentioned eligible agents or teams respond**: an explicit `@agent` or `@team` bypasses AI routing, but room configuration and reply permissions still apply.
2. **Non-thread messages**: a single eligible agent or team can auto-respond, regardless of how many humans are present.
3. **Threads with one human**: normal auto-response behavior applies, so the agent or team continues the conversation.
4. **Threads with two or more humans**: explicit targeting is required, except for agents using adaptive participation.
5. **Mentioning another joined room participant**: if a message tags only joined users who are neither agents, teams, nor configured bot accounts, agents and teams stay silent.

MindRoom reads a message's mentions from `m.mentions.user_ids`, or when that is empty from HTML mention pills in `formatted_body`, or otherwise from plain `@name` text in the body, so bridged messages without mention metadata still count.
Mentions of users who are not in the room or only invited do not suppress normal responses.
Mentioning a configured agent that has not joined the room still counts as an explicit mention for the other agents.

A thread too long to read at once, more than 2,000 messages or 16 MiB of stored content, is not continued from an untagged message when the room has more than one possible responder, unless an agent is still answering there; mention the agent or team to continue it.

### Bot accounts

Any Matrix user that is not a MindRoom agent or team counts as a human for these rules, including bridge bots (Telegram, Slack, etc.) and other non-MindRoom bots.
A bridge bot relaying messages into a thread therefore looks like a second human and makes agents require mentions.
List such accounts in the top-level [`bot_accounts`](https://docs.mindroom.chat/authorization/#bot-accounts) field so their messages and mentions do not count toward multi-human detection.

## Adaptive Participation

Set `agents.<name>.participation` to let an agent that already replied in a multi-human thread decide whether to answer an untagged message or stay silent.
`participation: {}` enables it with the defaults below; omitting it or setting it to `null` keeps the mention requirement.
The setting applies in every room where the agent may reply, including ad hoc rooms, and has no per-room override.

| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `debounce_seconds` | number | `3.0` | Quiet window before deciding, from `0` to `30` seconds; a burst of messages from one sender is judged as one turn |
| `instructions` | string | `""` | Additional guidance for deciding whether the agent should participate |
| `decline_reaction` | string or null | `null` | Reaction to a deliberate decline, such as `"👀"`; nonblank and at most 64 characters; `null` keeps declines invisible |
| `judgment` | object or null | `null` | Separate [judgment backend](#judgment-backends); `null` lets the agent's reply model decide |

```yaml
agents:
  assistant:
    display_name: Assistant
    participation:
      debounce_seconds: 3.0
      instructions: "Reply when you can help; leave human conversation uninterrupted."
      decline_reaction: "👀"
```

An agent judges a message only when all of these hold:

- The message is text in a thread with at least two human participants, including the sender.
- The message mentions neither an agent nor another human; mentioned messages follow the normal rules.
- This agent has already replied in the thread and may still reply to the sender.

Configured human aliases count as one participant, while agents, the internal service account, and `bot_accounts` do not count as humans.
Agents merely present in the room never join: with 50 agents in a room and two already in the thread, only those two can judge, and only if each opted in.
Each eligible agent decides separately.
Commands and scheduled work never use adaptive participation.

An agent that declines sends no reply; with `decline_reaction` set, it reacts once to the latest message of the turn.
Choose the emoji with care, because 👍 can read as agreement with the message.
A failed check never reacts and the agent stays quiet.

Without `judgment`, the agent's own reply model decides with tools disabled.
Gemini explicit context caches, OpenAI Chat search-only requests, OpenRouter automatic web search, and Groq Compound systems cannot decide this way, so agents using them stay quiet unless a separate judgment backend approves.
After a judgment backend approves, the agent replies normally with its own model, including those provider modes.

### Judgment Backends

`participation.judgment` and `mid_turn.judgment` select a separate decision model, while the agent's configured model still writes the reply.

| Field | Backend | Default | Description |
|-------|---------|---------|-------------|
| `provider` | both | Required | `llm` for a configured model alias, or `typesafe` for System One |
| `model` | `llm` | Required | Existing alias under `models`, which can be cheaper than the reply model |
| `threshold` | `typesafe` | `0.8` | Minimum probability from `0` to `1`; rejected for `llm` |
| `timeout_seconds` | both | `5` for `llm`, `1.5` for `typesafe` | Positive deadline of at most `30` seconds; for participation it starts after `debounce_seconds` |

Unknown fields are rejected.

```yaml
agents:
  assistant:
    display_name: Assistant
    participation:
      instructions: "Reply when you can help; leave human conversation uninterrupted."
      judgment:
        provider: llm
        model: fast
        timeout_seconds: 5
```

To use System One instead, set `TYPESAFE_API_KEY` in the process environment or config-adjacent `.env` and change the judgment:

```yaml
      judgment:
        provider: typesafe
        threshold: 0.8
        timeout_seconds: 1.5
```

The LLM backend uses the alias's normal provider credentials and receives no tools, agent system prompt, or agent memory.
A model alias whose provider adds native tools that cannot be disabled is refused.
For participation, a TypeSafe probability at or above `threshold` approves; the default `0.8` has not been calibrated on representative conversations.
Both backends get the same question and context, but their judgments can differ.

At most eight judgments run at once across the process, including [router judgments](https://docs.mindroom.chat/configuration/router/#responder-selection-judgments), and one per agent; a judgment that finds the limit full is not queued and is treated as failed.
Judgment outcome logs record backend, model, decision, latency, token usage, input size, and failure category, plus probability and threshold for TypeSafe, without request text or credentials.

#### Participation Context and Fallback

A participation judgment sends the agent's guidance and up to eight recent user and assistant messages, with Matrix identities replaced by aliases.
System prompts, memory, tool definitions and results, and media are not sent, but message text can still contain private information.
When the context contains media, attachment references, detected secrets, or more than 16 KB of text, the backend is skipped and the reply model decides instead.
Missing credentials, a full limit, a timeout, a provider error, malformed output, or an abstention also fall back to the reply model's decision, and the agent stays quiet if that fails too.

## Mid-Turn Coalescing

When another human message arrives while an agent is responding, MindRoom normally sends the agent a wrap-up notice after its current tool batch, asking it to stop making new tool calls and summarize its progress.
Set `agents.<name>.mid_turn` to let a judge decide whether the queued messages can wait until the active task finishes.
The setting applies in every room where the agent may reply, including ad hoc rooms, and has no per-room override.
Omitting it or setting it to `null` keeps the wrap-up notice, and teams do not inherit it from their members.

| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `judgment` | object | Required | LLM model alias or TypeSafe backend; see [Judgment Backends](#judgment-backends) |
| `instructions` | string | `""` | Extra guidance for the finish-or-wrap-up decision |
| `defer_reaction` | string or null | `null` | Reaction such as `"👀"` on a queued message that can wait; nonblank and at most 64 characters |

```yaml
agents:
  helper:
    display_name: Helper
    mid_turn:
      instructions: Continue for acknowledgements; wrap up for corrections or changed requirements.
      defer_reaction: "👀"
      judgment:
        provider: llm
        model: fast  # An existing alias under models
        timeout_seconds: 5
```

The judge asks whether any queued message requires an immediate change, pause, or stop.
Acknowledgements, praise, thanks, "continue", and "do not interrupt" let the task continue, while corrections and relevant changes take priority even alongside praise.
Unrelated requests wait for a later turn unless the user asks for an immediate switch.
With TypeSafe, the task continues when `1 - P(interrupt) >= threshold`, so the default `0.8` tolerates interruption probabilities up to `0.2`.
With the LLM backend, an explicit `false` answer lets the task continue.
Abstentions, timeouts, missing credentials, a full judgment limit, and backend errors keep the wrap-up behavior.

The check runs between completed tool batches and never interrupts a running tool.
The judge sees the active request, earlier public conversation, up to eight queued human messages, the configured guidance, and the agent's visible reply text as published when each message was queued.
Private tool results and arguments, system prompts, memory, and attachment contents are not sent, but message text can still contain private information.
Within the judge's 16 KB limit, the active request and queued messages are always sent whole, while older conversation drops out first and long earlier messages are shortened.
The wrap-up notice is sent without a judgment when conversation history is missing, incomplete, or contains media, when the active request and queued messages alone exceed the limit, when a message contains attachments or text that looks like a credential, and for `thread_mode: room` turns.
Each skipped judgment logs `Mid-turn judgment skipped` with a reason such as `history_unavailable` or `essential_input_too_large`, and judge timeouts and backend errors log `Mid-turn continuation evaluated` with a `failure`.

Queued messages stay queued and are handled after the active response finishes.
A finish decision covers only the messages it saw, so a later message needs a new decision, and a wrap-up notice once sent is not reversed.
The wrap-up notice asks the model to hand off; it does not cancel tools, abort the response, or show the model the queued text, and stopping responses and tool approval work as usual.
With `defer_reaction` set, each message that can wait gets the reaction once; wrap-up decisions and failed judgments never react.

## Message Edits

When a user edits a message that already received an agent response, the agent regenerates its reply for the updated content and edits its previous reply in place.
Edits by agents never trigger regeneration.
When another agent's reply finishes with a mention of this agent, it reaches this agent as a new message.

# History & Compaction

This page covers how much earlier conversation an agent or team sees on each reply, how a model's `context_window` keeps that history within the provider limit, and how compaction summarizes old history so long conversations keep working.
Use it to tune replay depth, set up or disable compaction, or explain why an agent forgot earlier context or posted a `Compacting history...` notice.

## History Settings

Set these fields in `defaults` or on an agent; teams use the same fields except `compress_tool_results` (see [Team History and Compaction](#team-history-and-compaction)).

| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `num_history_runs` | int, >= 1 | `null` | Number of prior runs to replay as history (`null` = all). Mutually exclusive with `num_history_messages` |
| `num_history_messages` | int, >= 1 | `null` | Maximum messages replayed from history (`null` = no limit). Mutually exclusive with `num_history_runs` |
| `compress_tool_results` | bool | `null` | Compress tool results in history to save context. Inherits `defaults.compress_tool_results` (default `false`). On Anthropic and Vertex AI Claude models, `true` can invalidate prompt caching |
| `max_tool_calls_from_history` | int, >= 0 | `null` | Maximum tool-call messages replayed from history. `null` inherits `defaults.max_tool_calls_from_history`; no limit when that is also `null` |
| `compaction` | object | `defaults.compaction` | Compaction overrides; see [Agent Compaction Settings](#agent-compaction-settings) |

An agent or team that leaves both `num_history_runs` and `num_history_messages` at `null` inherits both from `defaults`; setting either one replaces both defaults.
History replay is unlimited only when the inherited values are also `null`.

## Context Window

Set `context_window` on each model to the provider's actual limit (see [Models](https://docs.mindroom.chat/configuration/models/)).
When the active model has a known `context_window`, MindRoom fits replayed history to it before each reply, reducing raw replay, replaying only the saved summary, or skipping saved history for that run when needed.
Your configured `num_history_runs` stays unchanged; the reduction applies only to the run that needs it.
Without a known `context_window` (and without `compaction.replay_window_tokens`), history is not fitted and automatic text compaction is unavailable.

On `vertexai_claude` models, a known `context_window` also checks every provider request, including follow-ups after tool results.
A request that would exceed the window drops its oldest replayed history turns for that request only and logs a warning.
When the current turn alone cannot fit, the request fails with a provider error instead of being sent oversized.

```yaml
models:
  default:
    provider: anthropic
    id: claude-sonnet-5-5
    context_window: 1000000  # 1M tokens

defaults:
  compaction:
    replay_window_tokens: 200000  # Keep replayed history around a smaller operational window
```

## Agent Compaction Settings

Compaction is enabled by default through `defaults.compaction`.
It summarizes older history so later replies keep useful context instead of losing it to replay fitting.
Supported OpenAI and Claude routes use [native compaction](#native-compaction) inside ordinary requests; every other route uses portable text compaction.
Text compaction runs before a reply only when history exceeds the hard replay budget, or when [requested manually](#manual-compaction).
While it runs, the agent posts `Compacting history...` and edits that notice with the result.
If compaction fails, the notice reads `Compaction failed; continuing with trimmed history.` with the reason, and the reply continues with fitted history.

Set these fields under `defaults.compaction`, an agent's `compaction`, or a team's `compaction`:

| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `enabled` | bool | `true` | `false` disables automatic native and pre-reply text compaction for the scope |
| `threshold_tokens` | int, >= 1 | `null` | Native provider trigger in tokens; on text-only routes a soft planning threshold that does not compact by itself while history still fits. Mutually exclusive with `threshold_percent` |
| `threshold_percent` | float, > 0 and < 1 | `null` | The same trigger as a fraction of the effective replay window. When neither threshold is set, the trigger is 80% of that window |
| `replay_window_tokens` | int, >= 1 | `null` | Cap replayed history and compaction planning below the model's real `context_window` without lowering the provider request limit. The smaller of the two applies; when the model window is unknown, this value alone sets the replay window |
| `reserve_tokens` | int, >= 0 | `16384` | Headroom kept free for the current prompt, tool definitions, and output |
| `model` | string | `null` | Model config name used to write summaries; `null` uses the active model. Must define its own `context_window` |
| `fallback_model` | string | `null` | Different model config retried once when the summary model refuses for safeguards; after success it writes the remaining summaries for that compaction. Must define its own `context_window` |
| `timeout_seconds` | float, > 0 | `600` | Maximum seconds for each summary request; a shorter provider timeout still applies |

An agent or team `compaction` block overrides `defaults.compaction` field by field, and setting a field to `null` clears the inherited value.
Setting either threshold in an override replaces an inherited value of the other.
A `compaction` block that sets any field enables compaction for that scope, even when `defaults.compaction` disables it, unless it sets `enabled` to `false` or `null`.
A block that only clears `model` or `fallback_model` with `null` keeps the inherited enabled state.
Set `defaults.compaction: null` or `enabled: false` to turn compaction off everywhere that does not override it.

`model` and `fallback_model` must name entries under `models:` that define `context_window`; otherwise config validation fails with `Compaction model references unknown models: ...` or `Explicit compaction model references require a model with context_window: ...`.
A `fallback_model` that resolves to the same provider and model ID as the summary model is ignored.

Text compaction needs more than 2,000 tokens of summary input after `reserve_tokens` and prompt overhead are subtracted from the summary model's `context_window`.
With the default `reserve_tokens`, a summary model with a context window of roughly 10,000 tokens or less cannot compact; lower `reserve_tokens` to make it work.

### Compacted history and redaction

Text compaction moves older runs into an archive in the conversation database and replays a merged summary in their place.
Compaction deletes no stored history; only Matrix redaction and conversation deletion remove archived runs.
Redacting a Matrix event that a compacted run consumed rolls compaction back to just before that run, removing that run and everything after it, instead of clearing the whole conversation.

If a reply fails with `Saved conversation summary exceeds the available history budget`, the saved summary no longer fits beside the current prompt.
This can happen after switching to a model with a smaller context window or when system instructions and the current message leave too little room.
Choose a model with a larger context window or shorten the prompt; MindRoom never silently drops or truncates the saved summary.

### Native compaction

MindRoom supports automatic [OpenAI Responses compaction](https://developers.openai.com/api/docs/guides/compaction) and [Claude compaction](https://platform.claude.com/docs/en/build-with-claude/compaction).

- **OpenAI**: GPT-5.3 Codex, GPT-5.4, and GPT-6 model families on the official Responses endpoint or the Codex login backend.
  An explicit `store: true`, background mode, an alternate OpenAI endpoint, or a custom context-management request keeps the model on text compaction.
- **Claude**: direct Anthropic and Vertex AI Claude on Sonnet 4.6 and later, Opus 4.6 and later, Fable, and Mythos models.
  This is a provider beta and needs a trigger of at least 50,000 tokens.
  An authored `compact_20260112` context-management policy keeps its own trigger and instructions, and `pause_after_compaction: true` is rejected because MindRoom needs the provider to continue its response.
- Gemini, Chat Completions, and all other providers use text compaction.

Native compaction also requires compaction to be enabled, replay of all history (no `num_history_runs` or `num_history_messages`), no `max_tool_calls_from_history`, and a trigger above the current prompt size and below the hard request limit.
An explicit `compaction.model`, a [scheduled task](https://docs.mindroom.chat/scheduling/) with a `history_limit`, or a request already over the hard budget uses text compaction instead.
Native checkpoints survive restarts and are tied to their provider, model, and endpoint; after a model switch, the conversation is rebuilt from its full stored history.

### Manual compaction

Add the `compact_context` tool to an agent's `tools` to let it request compaction itself.
The request runs before the next reply in the same conversation and always uses text compaction.
It requires a summary model with a `context_window` and the same 2,000-token summary input budget; otherwise the tool returns `Error: Compaction is unavailable for this scope because ...` with the reason.

## Team History and Compaction

Named teams accept `num_history_runs`, `num_history_messages`, `max_tool_calls_from_history`, and `compaction`, with the same meaning, limits, and mutual exclusion as for agents.
The team's shared history uses the team's own settings, never a member agent's, and the team model's `context_window` sets its replay budget.

[Ad hoc teams](https://docs.mindroom.chat/configuration/teams/#ad-hoc-teams) have no `teams:` entry, so their history and compaction settings come from `defaults`, not from any participating agent.

# Memory System

Memory lets agents remember facts, preferences, and context across conversations.
This page covers choosing a memory backend, configuring embeddings and extraction, file-based memory, the `memory` tool, and Agno Learning.

| Backend | What it does | Use it when |
|---|---|---|
| `mem0` (default) | Vector memory: relevant memories are retrieved before each reply and new ones are extracted automatically after each turn | You want hands-off semantic memory |
| `file` | Markdown files (`MEMORY.md` plus dated notes in `memory/`) that are the source of truth | You want memory you can read and edit, or an OpenClaw-style workspace |
| `none` | No built-in memory | The agent should keep no memories ([Agno Learning](#agno-learning) stays on unless disabled) |

<video controls playsinline preload="metadata" aria-label="The agent remembers each person's diet and plans the week's dinners" style="width: 100%">
  <source src="https://github.com/user-attachments/assets/75a32c85-7528-4b72-b268-0de8ec17f794#t=0.1" type="video/mp4" media="(prefers-color-scheme: dark)">
  <source src="https://github.com/user-attachments/assets/c4b6f6e3-03a9-40fd-808b-0d18abdaf263#t=0.1" type="video/mp4">
</video>

## Choosing a Backend

Set the global backend with `memory.backend` (`mem0`, `file`, or `none`; default `mem0`), and override it for one agent with `agents.<name>.memory_backend`.
`memory: none` is shorthand for `memory: {backend: none}`.

```yaml
memory:
  backend: mem0

agents:
  coder:
    display_name: Coder
    role: Write and review code
    memory_backend: file
  scratch:
    display_name: Scratch
    role: Stateless scratchpad agent
    memory_backend: none
```

With `none`, the agent gets no memories in its prompt, stores none after its turns, and does not receive the `memory` tool.
`none` does not turn off [Agno Learning](#agno-learning); set `learning: false` as well to disable all built-in memory and learning.
Conversation history is separate and stays in the session; see [History & Compaction](https://docs.mindroom.chat/configuration/history/).
OpenClaw-compatible agents use this same backend selection.

## Memory Scopes

| Scope | ID | Contents |
|---|---|---|
| Agent | `agent_<name>` | The agent's preferences and durable user context |
| Team | `team_<agent1>+<agent2>+...` (member names sorted) | Shared memory from team conversations |

An agent's memory reads cover its own scope and the scopes of the configured teams it belongs to, searched through the agent's own backend.
A team reads its own team scope; set `memory.team_reads_member_memory: true` (default `false`) to let it also read its members' agent scopes.

Team memory picks one backend from the members' effective backends:

- All members use `file`: the team uses file memory.
- All members use `none`: team memory is disabled.
- Any other combination, including `file` plus `none`, uses Mem0 with the configured `memory.embedder` and `memory.llm`.

A member's `none` setting disables only its own memory, so team conversations it takes part in can still be remembered in the team scope.
A `file` member of a team that uses Mem0 does not see that team's memory in its own conversations.

## Backend: `mem0`

Before each reply, Mem0 retrieves memories relevant to the message and adds them to the prompt.
After each turn, the [memory LLM](#memory-llm) extracts durable facts into a Chroma vector store under the agent's storage directory.

### Embedder

`memory.embedder` sets the embedding model used by Mem0, semantic file-memory search, and [knowledge bases](https://docs.mindroom.chat/knowledge/#embedder-configuration).
Mem0 supports the `openai` (default), `ollama`, `huggingface`, and `sentence_transformers` providers; semantic file memory and knowledge bases support the [narrower set](https://docs.mindroom.chat/knowledge/#embedder-configuration).

| Field | Default | Description |
|---|---|---|
| `provider` | `openai` | Embedding provider |
| `config.model` | `text-embedding-3-small` | Embedding model name |
| `config.host` | `null` | Base URL for OpenAI-compatible endpoints, or the Ollama host; for `ollama`, a configured `OLLAMA_HOST` takes precedence, and the fallback is `http://localhost:11434` |
| `config.dimensions` | `null` | Embedding dimension override (>= 1) for OpenAI-compatible providers |
| `config.api_key` | `null` | Explicit API key for the `openai` provider |
| `config.credentials_service` | `null` | Credential service that supplies the `openai` provider's key |

```yaml
memory:
  backend: mem0
  embedder:
    provider: openai
    config:
      model: text-embedding-3-small
      credentials_service: embedder # Optional strict credential binding
      dimensions: null              # Optional, for example 256
```

Fully local embedders:

```yaml
memory:
  embedder:
    provider: sentence_transformers
    config:
      model: sentence-transformers/all-MiniLM-L6-v2
```

```yaml
memory:
  embedder:
    provider: ollama
    config:
      model: nomic-embed-text
      host: http://localhost:11434
```

MindRoom installs the optional `sentence_transformers` extra the first time that provider is used.
Changing the embedder provider, model, host, or dimensions switches Mem0 to a separate collection, so memories stored with the previous embedder are no longer retrieved.

The `openai` embedder takes its API key from the first of these that is set:

1. `memory.embedder.config.api_key`.
2. The service named by `memory.embedder.config.credentials_service`; when it has no key, MindRoom does not fall back to another key.
3. When no service is named, the dedicated `embedder` credential (seeded from `EMBEDDER_API_KEY` or `EMBEDDER_API_KEY_FILE`), then the shared `openai` provider key.

Use `credentials_service` when the embedding endpoint needs a different key than chat, voice, or other OpenAI-compatible models.
With no key at all, keyless local OpenAI-compatible endpoints still work, while a keyed endpoint fails with an authentication error.
When the embedder rejects its credential, semantic memory search reports the cause in `memory` tool output and in the agent's prompt instead of returning empty results, and `/api/health` includes an `embedder` block until a request succeeds again.

### Memory LLM

`memory.llm` sets the model Mem0 uses to extract memories.
Supported providers are `ollama`, `openai`, `openrouter`, and `anthropic`.
When `memory.llm` is omitted, MindRoom uses `gemma4` on the Ollama server set by `OLLAMA_HOST` (default `http://localhost:11434`).
With `provider: ollama`, `config.host` points extraction at a different Ollama server, but a configured `OLLAMA_HOST` takes precedence; other providers ignore `host`.
With `provider: openai`, use `gpt-6-luna`; MindRoom drops the `temperature` and `top_p` values Mem0 sends, which GPT-6 models reject.

```yaml
memory:
  llm:
    provider: ollama
    config:
      model: gemma4
```

OpenRouter-only example, where both the memory LLM and the embeddings use the `openrouter` key:

```yaml
memory:
  embedder:
    provider: openai
    config:
      model: openai/text-embedding-3-small
      host: https://openrouter.ai/api/v1
      credentials_service: openrouter
      dimensions: 1536
  llm:
    provider: openrouter
    config:
      model: openai/gpt-6-luna
```

The LLM uses `memory.llm.config.api_key` when set and not blank, and otherwise the provider's credential (for example `openrouter`, or a dashboard credential stored as `OPENROUTER_API_KEY`).
When `OPENROUTER_API_KEY` is set in the process environment, the `openai` and `openrouter` memory LLMs send that key to OpenRouter, overriding `memory.llm.config.api_key`.

## Backend: `file`

File memory keeps `MEMORY.md` and dated notes under `memory/` in the agent's workspace, and those files are the source of truth.

```yaml
memory:
  backend: file
  file:
    max_entrypoint_lines: 200
  search:
    mode: keyword
    include:
      - memory/**/*.md
    include_entrypoint: false
  auto_flush:
    enabled: true
```

For a single agent, the file backend writes memory only when the agent uses the [`memory` tool](#memory) or edits the files, or when [auto-flush](#file-auto-flush-worker) is enabled; it does not extract memories after every turn like `mem0`.
A team whose members all use `file` appends each message it answers to the team's `MEMORY.md`, even without auto-flush.
Before each reply, MindRoom inlines the start of `MEMORY.md` into the prompt under a header with its path, so the agent does not need to re-read it, and adds memories that match the message.
`memory.file.max_entrypoint_lines` (default `200`, minimum `1`) caps the inlined lines; when it truncates, a marker reports the included and total line counts and the path to read for the rest.
`memory.file.path` (default unset, relative to the config directory) is a fallback root for team file memory and never moves agent file memory out of the workspace.

### File layout

Agent file memory lives in the agent workspace, `agents/<agent>/workspace/` in the storage directory:

- `MEMORY.md`, where `add_memory` writes
- `memory/YYYY-MM-DD.md`, where auto-flush writes

Team file memory is mirrored under each member's storage directory:

- `agents/<agent>/memory_files/team_<sorted_members>/MEMORY.md`
- `agents/<agent>/memory_files/team_<sorted_members>/memory/YYYY-MM-DD.md`

Memory files must be regular files; symlinks, FIFOs, and device nodes are ignored when reading and rejected when writing.
Each file is read up to 1 MiB, cut at its last complete line, and updating a truncated or non-UTF-8 file fails so the rest of it is preserved.

### Searching file memory

`memory.search` controls how `search_memories` and per-turn retrieval search file memory, and `agents.<name>.memory_search` overrides it per agent, with omitted fields inherited.

| Field | Default | Description |
|---|---|---|
| `mode` | `keyword` | `keyword` or `semantic` |
| `include` | `[memory/**/*.md]` | Glob patterns relative to the file-memory root that semantic search indexes; must be non-empty |
| `include_entrypoint` | `false` | Also index `MEMORY.md` for semantic search |

In `keyword` mode, `search_memories` matches structured entries with IDs in `MEMORY.md`, plus entries and plain-text lines in `memory/**/*.md`.
Ordinary prose in `MEMORY.md` reaches the agent through the prompt preload, not keyword search; to reach lines beyond the preload cap, read the file directly or use semantic search with `include_entrypoint: true`.

In `semantic` mode, MindRoom builds a vector index of the `include` files with `memory.embedder` on first use and stores it under `<storage-root>/knowledge_db/` (see [Knowledge storage](https://docs.mindroom.chat/knowledge/#storage)).
Until the index is ready, or when embeddings fail, search falls back to keyword results.
Writes through the `memory` tool and auto-flush refresh the index, but a ready index does not notice direct file edits until a later such write refreshes it.
Semantic mode covers the agent's own memory; team file memory is always keyword searched.

```yaml
agents:
  coder:
    display_name: Coder
    role: Write and review code
    memory_backend: file
    memory_search:
      mode: semantic
```

Once its semantic index is ready, an agent's file memory also appears as a read-only source in `search_knowledge_base`.
That source is semantic-only, without keyword fallback, team memory, or memory IDs, so use `search_memories` when you need those.

### Per-requester file memory

To give each requester their own file memory from one agent definition, combine `memory_backend: file` with [`private`](https://docs.mindroom.chat/configuration/agents/#private-instances); the requester's private root then becomes the file-memory root.

## File Auto-Flush Worker

Auto-flush periodically turns recent conversation in file-backed agents into durable memories.
It applies only to agents whose effective backend is `file`.

1. Each turn marks its session as having unsaved conversation.
2. A background worker picks eligible sessions in bounded batches.
3. The agent's own model reads the new messages, plus existing memories to avoid duplicates, and writes durable facts.
4. When the model answers with the `no_reply_token`, nothing is written.
5. Results are appended to `memory/YYYY-MM-DD.md`.

```yaml
memory:
  backend: file
  auto_flush:
    enabled: true
    flush_interval_seconds: 1800
    idle_seconds: 120
    max_dirty_age_seconds: 600
    batch:
      max_sessions_per_cycle: 10
    extractor:
      max_messages_per_flush: 20
```

| Field | Default | Description |
|---|---|---|
| `enabled` | `false` | Turn on auto-flush |
| `flush_interval_seconds` | `1800` (min 5) | Time between worker cycles |
| `idle_seconds` | `120` (min 0) | Session idle time before it becomes eligible |
| `max_dirty_age_seconds` | `600` (min 1) | Make a session eligible after this long with unsaved conversation, even if still active |
| `stale_ttl_seconds` | `86400` (min 60) | Forget pending sessions older than this |
| `max_cross_session_reprioritize` | `5` (min 0) | Other pending sessions of the same agent moved up the queue when a new message arrives |
| `retry_cooldown_seconds` | `30` (min 1) | Wait before retrying a failed extraction |
| `max_retry_cooldown_seconds` | `300` (min 1) | Upper bound for the growing retry wait |
| `batch.max_sessions_per_cycle` | `10` (min 1) | Sessions processed per cycle |
| `batch.max_sessions_per_agent_per_cycle` | `3` (min 1) | Sessions per agent processed per cycle |
| `extractor.no_reply_token` | `NO_REPLY` | Reply meaning nothing should be saved |
| `extractor.max_messages_per_flush` | `20` (min 1) | Messages considered per extraction |
| `extractor.max_chars_per_flush` | `12000` (min 1) | Message characters considered per extraction |
| `extractor.max_extraction_seconds` | `30` (min 1) | Timeout for one extraction, retried in a later cycle |
| `extractor.include_memory_context.memory_snippets` | `5` (min 0) | Existing memories shown to the model to avoid duplicates |
| `extractor.include_memory_context.snippet_max_chars` | `400` (min 1) | Characters per existing memory shown |

## [`memory`]

The `memory` tool lets an agent deliberately remember, look up, correct, or forget something, alongside automatic memory.
It has no tool-specific configuration; it always uses the agent's effective backend and the `memory.search` settings, and agents with `memory_backend: none` do not receive it.

```yaml
agents:
  assistant:
    tools: [memory]
```

```python
add_memory("The user prefers terse release notes.")
search_memories("release notes", limit=3)   # default limit 5
list_memories(limit=20)                     # default limit 50
get_memory("abc123")
update_memory("abc123", "The user prefers terse release notes with dates.")
delete_memory("abc123")
```

The tool reaches every agent and team scope visible to the agent, as described in [Memory Scopes](#memory-scopes).
Search and list results include an ID: Mem0 memory IDs and file-backed `file:<path>:<line>` IDs work with `get_memory`, `update_memory`, and `delete_memory`.
Semantic file-memory matches use read-only `semantic:<source_file>:<rank>` locators that these functions do not accept.
Result metadata includes `search_mode` (`keyword` or `semantic`), showing which search produced each result.
Failures are returned as readable error messages rather than raised into the conversation.

## UI Configuration

The Dashboard **Memory** page edits the `memory` section: backend, `team_reads_member_memory`, embedder provider, model, credential service, and host, file settings (`path`, `max_entrypoint_lines`), search settings, and all auto-flush settings.
The remaining fields, such as `llm`, are under **More settings**.
Save from the Memory page to write the changes to `config.yaml`.

## Agno Learning

Agno Learning builds a persistent profile of user preferences from conversations, separately from the memory backends above.
It is on by default for every agent, and `memory_backend: none` does not disable it.

```yaml
defaults:
  learning: true          # default
  learning_mode: always   # learn after every turn; agentic lets the agent decide via a tool

agents:
  assistant:
    learning: false       # disable for one agent
  research:
    learning_mode: agentic
```

See [Agent fields](https://docs.mindroom.chat/configuration/agents/#defaults) for how `learning` and `learning_mode` inherit from `defaults`.
Disabled agents create and update no learning data.
Learning data is stored in `learning/<agent>.db` under the agent's state root (`agents/<agent>/` in the storage directory for shared agents), so it survives container restarts when the storage directory is mounted.

# Knowledge Bases

Knowledge bases give agents access to your own documents.
Point a knowledge base at a folder or Git repository and assign it to agents.
In `semantic` mode (the default), MindRoom indexes the files with an embedder into a vector database, and assigned agents get a `search_knowledge_base` tool.
In `files` mode, MindRoom skips embeddings and agents inspect the files directly with their file and shell tools.

## Quick Start

Add a knowledge base and assign it to an agent:

```yaml
knowledge_bases:
  docs:
    description: Product documentation, support notes, and internal operating procedures
    mode: semantic
    path: ./knowledge_docs
    watch: false
    chunk_size: 5000
    chunk_overlap: 0

agents:
  assistant:
    display_name: Assistant
    role: A helpful assistant with access to our docs
    knowledge_bases: [docs]
```

Place files in `./knowledge_docs/`, then trigger a reindex from the dashboard or API, or set `watch: true` so MindRoom picks up file changes automatically.
Knowledge base IDs are the keys under `knowledge_bases`.
An ID must be a non-empty single path component such as `docs` or `company_docs`; `.`, `..`, and names containing `/`, `\`, or line breaks are rejected.

### Files-Only Knowledge Base

Use `mode: files` when agents should search, grep, list, and read the source files directly without paying for embeddings.

```yaml
knowledge_bases:
  source_docs:
    description: Source documents the agent should inspect directly
    mode: files
    path: ${MINDROOM_STORAGE_PATH}/agents/assistant/workspace/source_docs

agents:
  assistant:
    display_name: Assistant
    role: A helpful assistant with direct file access to source docs
    memory_backend: file
    tools: [file, shell]
    knowledge_bases: [source_docs]
```

A files-mode base has no vector index, does not use the embedder, and does not provide `search_knowledge_base`.
The agent needs a workspace, which `memory_backend: file` creates at `${MINDROOM_STORAGE_PATH}/agents/<agent>/workspace` (private agents use their private root).
When the base's path is inside that workspace, MindRoom exposes it as `knowledge/<base_id>` and tells the agent to use file tools on it.
Bases outside the workspace are not linked, and with the default [`file_access: workspace`](https://docs.mindroom.chat/configuration/agents/#configuration-options) the agent's file tools cannot reach them.

## Configuration

```yaml
knowledge_bases:
  my_docs:
    description: Product documentation, support notes, and internal operating procedures
    mode: semantic                    # "semantic" builds a vector search index; "files" skips embeddings
    path: ./knowledge_docs/my_docs   # Folder containing documents
    watch: false                      # Direct external edits require reindex; dashboard/API changes still refresh
    require_content_before_publish: false
    chunk_size: 5000
    chunk_overlap: 0
```

| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `description` | string | `""` | What the base contains; shown to agents in the `search_knowledge_base` tool description and in files-mode instructions so they know when to use it |
| `mode` | `semantic` or `files` | `semantic` | `semantic` builds an embedding-backed search index; `files` skips embeddings and exposes the files to workspace-aware agents |
| `path` | string | `./knowledge_docs` | Folder path, relative to the config file directory or absolute |
| `watch` | bool | `true` | Watch the local folder and refresh in the background when files change. When `false`, direct external edits require an explicit reindex; dashboard/API uploads and deletes still trigger a refresh |
| `require_content_before_publish` | bool | `false` | Keep a new semantic index in the initializing state until at least one source file exists, instead of publishing an empty index |
| `chunk_size` | int | `5000` | Maximum characters per chunk for plain-text and Markdown files (minimum `128`); other formats such as JSON and PDF use their reader's own chunking; semantic mode only |
| `chunk_overlap` | int | `0` | Overlap characters between adjacent plain-text and Markdown chunks; must be smaller than `chunk_size`; semantic mode only |
| `include_patterns` | list | `[]` | Root-anchored glob patterns a file must match to be included |
| `exclude_patterns` | list | `[]` | Root-anchored glob patterns removed after include filtering |
| `include_extensions` | list | `null` | Exact set of extensions to index instead of the default text-like set (semantic mode only) |
| `extra_extensions` | list | `[]` | Extensions to index in addition to the default set (semantic mode only) |
| `exclude_extensions` | list | `[]` | Extensions to exclude, applied last (semantic mode only) |
| `skip_hidden` | bool | `true` | Skip files and folders whose names start with `.`, such as temp files from editors that write in place. Git-backed bases use `git.skip_hidden` instead |
| `git` | object | `null` | Sync the folder from a Git repository; see [Git-Backed Knowledge Bases](#git-backed-knowledge-bases) |

Set `skip_hidden: false` if the folder intentionally contains dot-prefixed files that should be indexed.
Use a smaller `chunk_size` when your embedding server has lower token or batch limits; chunks that are too large make indexing fail with embedder 500 errors.

### Keeping Indexes Fresh

Chat never waits for indexing or Git sync.
Agents search the last successfully published index, and a missing, stale, or failed base schedules a background refresh while the current request continues.
A successful refresh replaces the published index; a failed refresh keeps the previous one and shows the error in the base's status.

A refresh reuses the stored vectors of unchanged files and only embeds files that were added or edited.
Changing `chunk_size`, `chunk_overlap`, the embedder, or any file filter re-embeds the whole base on the next refresh.
An explicit reindex from the dashboard or API rebuilds every vector, which is how you replace an index you no longer trust.
An interrupted or failed semantic build continues where it stopped on the next refresh instead of starting over.
Temporary embedding failures (timeouts, connection errors, HTTP 408, 429, and 5xx) are retried, while permanent failures such as authentication errors stop the refresh until you fix the cause.

Knowledge base configuration hot-reloads.
After a `chunk_size` or `chunk_overlap` change, agents keep searching the previous index until the rebuild succeeds.
After a change to `path`, `mode`, the embedder, the Git source, or any file filter, the base is not searchable until its new index is built.

### Indexing Performance

| Environment variable | Default | Description |
|----------------------|---------|-------------|
| `MINDROOM_KNOWLEDGE_FILE_INDEX_CONCURRENCY` | `4` | Files indexed concurrently within one semantic refresh, from `1` through `128`; invalid values are rejected at startup |
| `MINDROOM_KNOWLEDGE_REFRESH_CONCURRENCY` | `1` | Knowledge base refreshes that run at the same time; must be at least `1` |
| `MINDROOM_KNOWLEDGE_REFRESH_SUBPROCESS_TIMEOUT_SECONDS` | `3600` | Maximum seconds for one background refresh; must be a finite positive number. Raise it for very large corpora |

Local `sentence_transformers` embedding indexes one file at a time regardless of `MINDROOM_KNOWLEDGE_FILE_INDEX_CONCURRENCY`.

### File Type Filtering

In semantic mode, MindRoom indexes a default set of text-like extensions covering Markdown, plain text, source code, and structured text formats such as `.json`, `.yaml`, and `.csv`.

```yaml
knowledge_bases:
  reports:
    path: ./knowledge_docs/reports
    extra_extensions: [".pdf", ".docx"]  # Default text-like set plus PDF and Word
    exclude_extensions: [".csv"]         # Drop CSV files from the default set
```

- `extra_extensions` adds extensions on top of the default set.
- `include_extensions` replaces the default set entirely, and `extra_extensions` still adds on top of it.
- `exclude_extensions` removes extensions last and wins over both.

The allowed set depends only on this configuration, never on the embedder or installed packages.
Formats such as `.pdf`, `.docx`, or `.pptx` need their reader package (for example `pypdf`, `python-docx`, or `python-pptx`) installed in the MindRoom environment.
When a reader package is missing, the refresh indexes the other files, logs a warning naming the file and the missing package, and reports an indexing error instead of publishing an index that silently lacks those files.
Extension filtering does not apply in `files` mode.
Semantic indexing skips symlinked files and folders inside a base, so copy linked documents into the base instead.
Files larger than 64 MiB are left out of every knowledge base with a logged warning, and the next refresh removes any vectors they already had.

A `.json` file is split into one searchable document per top-level JSON value.
If the file is not valid JSON, its raw text is indexed like plain text and a warning names the file and the line and column of the parse error; fix that error to get per-value splitting back.

### Multiple Knowledge Bases

Define several knowledge bases and assign each agent the ones it needs:

```yaml
knowledge_bases:
  engineering:
    path: ./knowledge_docs/engineering
  product:
    path: ./knowledge_docs/product
  legal:
    path: ./knowledge_docs/legal
    chunk_size: 1000
    chunk_overlap: 100

agents:
  developer:
    display_name: Developer
    role: Engineering assistant
    knowledge_bases: [engineering]

  pm:
    display_name: Product Manager
    role: Product planning assistant
    knowledge_bases: [product, engineering]

  compliance:
    display_name: Compliance
    role: Legal and compliance reviewer
    knowledge_bases: [legal]
```

When an agent searches several semantic bases, results are interleaved so no single base dominates the top results.
Knowledge base paths must not be nested inside one another.
Several bases may use exactly the same path to expose different filtered views of one folder, as long as their Git settings (`repo_url`, `branch`, `credentials_service`, and `lfs`) match or none of them uses Git; see [File Filtering with Patterns](#file-filtering-with-patterns).

### Private Agent Knowledge

Use `agents.<name>.private.knowledge` when each requester of a [private agent](https://docs.mindroom.chat/configuration/agents/#private-instances) should have their own knowledge index built from their private root.

```yaml
knowledge_bases:
  company_docs:
    description: Shared company policies, project notes, and operating procedures
    path: ./company_docs
    watch: false

agents:
  mind:
    display_name: Mind
    role: A persistent personal AI companion
    model: sonnet
    private:
      per: user
      root: mind_data
      template_dir: ./mind_template
      knowledge:
        description: Requester-private notes, preferences, and working memory for this agent
        path: memory
        watch: false
    knowledge_bases: [company_docs]
```

Each requester's private knowledge path becomes `<their private root>/memory`, and their index is never shared with another requester.
The fields are listed under [Private Fields](https://docs.mindroom.chat/configuration/agents/#private-fields).
`private.knowledge.path` is required unless `private.knowledge.enabled` is `false`; it must be relative to the private root (`.` allowed) and cannot be absolute or escape with `..`.
Private knowledge has no background file watcher: with `watch: true` or `git`, it refreshes when the agent uses it, and with `watch: false` and no `git`, external edits are not picked up automatically.
An agent can combine private knowledge with shared top-level `knowledge_bases`, which stay the mechanism for documents shared across agents or users.
Private knowledge is available in normal agent conversations, not through the OpenAI-compatible `/v1` API.
With `private.knowledge.git`, use a dedicated subtree such as `kb_repo`; MindRoom rejects `.` and any path that overlaps private file memory (`memory/`, `MEMORY.md`) or content copied from `template_dir`.

## Git-Backed Knowledge Bases

A knowledge base can sync its folder from a Git repository.
MindRoom refreshes a shared Git-backed base every `poll_interval_seconds`, starting one interval after startup, and agents keep using the last published index while a refresh runs.

```yaml
knowledge_bases:
  pipefunc_docs:
    path: ./knowledge_docs/pipefunc
    chunk_size: 1200
    chunk_overlap: 120
    git:
      repo_url: https://github.com/pipefunc/pipefunc
      branch: main
      poll_interval_seconds: 300
      lfs: false
      skip_hidden: true
      include_patterns:
        - "docs/**"
```

### Git Configuration Fields

| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `repo_url` | string | *required* | Repository URL to clone and fetch; see the accepted forms below |
| `branch` | string | `main` | Branch to track |
| `poll_interval_seconds` | int | `300` | Interval between background Git refreshes (minimum `5`) |
| `credentials_service` | string | `null` | Credential service for private repositories; see [Private Repository Authentication](#private-repository-authentication) |
| `lfs` | bool | `false` | Download Git LFS files after each sync. Requires `git-lfs` on the machine running MindRoom; bundled container images include it |
| `sync_timeout_seconds` | int | `3600` | Abort a single Git command after this many seconds (minimum `5`) |
| `skip_hidden` | bool | `true` | Skip files and folders whose names start with `.` |
| `include_patterns` | list | `[]` | Root-anchored glob patterns to include |
| `exclude_patterns` | list | `[]` | Root-anchored glob patterns to exclude |

#### Accepted `repo_url` forms

| Form | Example |
|------|---------|
| URL with a host | `https://github.com/org/repo.git`, `ssh://git@host/org/repo.git` |
| scp-style SSH | `git@github.com:org/repo.git`, `github.com:org/repo.git` |
| Absolute local path | `/srv/repos/repo.git` |
| `file:` URL | `file:///srv/repos/repo.git` |

Network forms must have a resolvable host.
MindRoom refuses, with an error naming the reason, relative and home-relative paths (`./repo.git`, `../repo.git`, `~/repo.git`, `repo.git`), and URLs with an empty host (`https:///org/repo.git`).

**Do not embed credentials in `repo_url`.**
A password in a well-formed URL is kept out of the checkout's Git configuration, but it stays in your config file and is passed to Git on every command.
A credential MindRoom cannot reliably separate from the URL, such as `oauth2:TOKEN@gitlab.com:org/repo.git` or `x-access-token:TOKEN@github.com/org/repo.git`, makes the sync fail.
Use `credentials_service` instead; those credentials reach Git only for the duration of each command and are never written to disk.

### Sync Behavior

- An explicit reindex or sync from the dashboard or API syncs Git first, then rebuilds the semantic index or, in files mode, publishes the new file list.
- The checkout is owned by MindRoom: when the branch moves, sync discards local edits to tracked files and restores deleted ones, so edit the source repository and sync instead.
- Dashboard and API file uploads and deletes are rejected for Git-backed bases.
- When `lfs: true`, MindRoom downloads all LFS files after each sync, even ones the indexing filters exclude.
- LFS downloads use the endpoint derived from `repo_url`; an LFS URL set in the repository's `.lfsconfig` is ignored.

### File Filtering with Patterns

Patterns are matched from the repository or folder root.
`*` matches one path segment and `**` matches zero or more segments.

```yaml
knowledge_bases:
  project_docs:
    path: ./knowledge_docs/project
    git:
      repo_url: https://github.com/org/project
      include_patterns:
        - "docs/**"                    # All files under docs/
        - "README.md"                  # Root README only
        - "content/posts/*/index.md"   # Specific nested files
      exclude_patterns:
        - "docs/internal/**"           # Exclude internal docs
```

- If `include_patterns` is empty, all non-hidden files are eligible.
- If `include_patterns` is set, a file must match at least one pattern.
- `exclude_patterns` are applied last and remove matching files.
- On a Git-backed base, a file must pass both the top-level patterns and the `git` patterns.

Pointing several bases at the same path is the preferred way to expose separate views of a large repository without cloning it more than once:

```yaml
knowledge_bases:
  project_docs:
    path: ./knowledge_docs/project
    git:
      repo_url: https://github.com/org/project
      branch: main
      include_patterns: ["docs/**"]
  project_source:
    path: ./knowledge_docs/project
    git:
      repo_url: https://github.com/org/project
      branch: main
      include_patterns: ["src/**"]
```

### Checkout Layout

The knowledge folder holds only the checked-out files.
The Git data lives at `<storage>/knowledge_git/<folder>_<path digest>`, and bases that share a folder share it.
A `.git` written into the knowledge folder is ignored and never indexed.
The folder must be empty when MindRoom first clones into it.
Changing a base's `path` starts a new clone in the new folder; delete the old directory under `<storage>/knowledge_git/` to reclaim its space.
Deleting only the knowledge folder restores its files from the Git data on the next sync; delete both to clone afresh.

Knowledge Git commands ignore system and global Git configuration, repository hooks, and credential helpers, and they do not receive MindRoom's secrets.
Configure proxies, CA bundles, and SSH through environment variables or `~/.ssh/config`, and credentials through `credentials_service`.

### Private Repository Authentication

For private HTTPS repositories, store credentials and reference them in the config.

**Step 1:** Store credentials through the dashboard **Credentials** tab or the API:

```bash
curl -X POST http://localhost:8765/api/credentials/github_private \
  -H "Content-Type: application/json" \
  -d '{"credentials":{"username":"x-access-token","token":"ghp_your_token_here"}}'
```

**Step 2:** Reference the service name in the knowledge base config:

```yaml
knowledge_bases:
  private_docs:
    path: ./knowledge_docs/private
    git:
      repo_url: https://github.com/org/private-repo
      credentials_service: github_private
```

| Fields | Notes |
|--------|-------|
| `username` + `token` | Standard GitHub or GitLab access token |
| `username` + `password` | Basic HTTP auth; the password wins if a token is also set |
| `token` or `api_key` alone | Authenticates as `x-access-token` |

For unattended GitHub access, a GitHub App installation can replace a personal access token.
Store this object under the service named by `credentials_service`:

```json
{
  "auth_type": "github_app",
  "app_id": 12345,
  "installation_id": 67890,
  "private_key_file": "/var/run/secrets/github-app/private-key.pem"
}
```

`private_key_file` must be an absolute path, ideally a read-only secret mount; do not put the PEM contents in the credential object.
`repo_url` must use the canonical `https://github.com/<owner>/<repository>` form.
MindRoom requests short-lived installation tokens limited to that repository with read-only Contents permission, so the App needs Contents read access, and the tokens are never written to disk or logs.

## Embedder Configuration

Semantic knowledge bases use the embedder configured under `memory.embedder`; files-mode bases use none.
Semantic knowledge and file-memory indexes support the `openai`, `ollama`, and `sentence_transformers` providers, while the Mem0 memory backend also supports `huggingface`.

| Provider | Model example | Notes |
|----------|---------------|-------|
| `openai` | `text-embedding-3-small` | Also works with keyless OpenAI-compatible local endpoints |
| `ollama` | `nomic-embed-text` | Self-hosted; set `host` or `OLLAMA_HOST` |
| `sentence_transformers` | `sentence-transformers/all-MiniLM-L6-v2` | Runs locally in MindRoom; the optional extra installs on first use |

See [Memory](https://docs.mindroom.chat/memory/#embedder) for the embedder fields, API key resolution, and how embedder failures are reported.

## Storage

Semantic indexes are stored under `<storage_path>/knowledge_db/<base_id>_<hash>/`, where the hash comes from the resolved knowledge path.
For private agent knowledge, the requester's private root is part of that path, so each requester gets a separate index.
Files-mode bases need no vector database.
The storage path defaults to `mindroom_data/` next to `config.yaml` and can be changed with `MINDROOM_STORAGE_PATH`.

## Search Limits

A search times out after 30 seconds, and at most four searches run at once; a search that finds no free slot can fail with `Knowledge reader is busy; try again shortly`.

## Dashboard and API

Manage knowledge bases, files, reindexing, and Git sync from the dashboard **Knowledge** tab or the API; see [Dashboard](https://docs.mindroom.chat/dashboard/#knowledge) for the tab and the [knowledge API endpoints](https://docs.mindroom.chat/dashboard/#knowledge-api).

# Skills

Skills are instruction packs: a `SKILL.md` file with optional scripts and reference documents that guide an agent through a task without adding new code capabilities.
MindRoom uses Agno's skills system with OpenClaw-compatible metadata.
This page covers writing, installing, and allowlisting skills, skills agents keep in their own workspace, and opt-in automatic skill learning.

## Skill directory structure

A skill is a directory containing:

```
my-skill/
├── SKILL.md         # Required: instructions; YAML frontmatter is recommended
├── scripts/         # Optional: executable scripts
│   └── audit.sh
└── references/      # Optional: reference documents
    └── examples.md
```

## SKILL.md format (OpenClaw compatible)

```markdown
---
name: repo-quick-audit
description: Quick repository audit checklist
metadata: '{openclaw:{requires:{bins:["git"], env:["GITHUB_TOKEN"]}}}'
---

# Repo Quick Audit

1. Check CI status
2. Review open issues
```

| Field | Type | Description |
| --- | --- | --- |
| `name` | string | Unique skill identifier; defaults to the skill directory name |
| `description` | string | Brief summary shown to users and models; defaults to the skill name when omitted or blank |
| `metadata` | mapping or JSON5 string | OpenClaw metadata (see [Eligibility gating](#eligibility-gating-openclaw-metadata)) and custom fields |
| `license` | string | Informational only; accepted but not used by the runtime |
| `compatibility` | string | Informational only; accepted but not used by the runtime |
| `allowed-tools` | list | Reserved; accepted but not enforced by the runtime |

A skill without any frontmatter still loads with the name and description fallbacks, but frontmatter gives clearer listings.
Keep each skill focused on one capability, give it a descriptive name such as `code-review`, and declare its dependencies with `metadata.openclaw.requires`.

Workspace skill frontmatter must not use YAML aliases or unusually large or deeply nested structures.
A workspace skill that breaks these limits is skipped with a warning, while bundled, plugin, and `~/.mindroom/skills` skills are parsed like any YAML.

## Installing and managing skills

Install a user skill at `~/.mindroom/skills/<name>/SKILL.md`, or use the dashboard Skills page to create, view, edit, and delete user skills.
Bundled and plugin-provided skills are visible but read-only in the dashboard.

An agent can use a bundled, plugin, or user skill only after you add its name to the agent's `skills:` allowlist in `config.yaml`:

```yaml
agents:
  developer:
    display_name: Developer
    role: A coding assistant
    model: sonnet
    skills:
      - repo-quick-audit
      - code-review
```

If `skills` is empty or unset, the agent gets no bundled, plugin, or user skills.
Skills in the agent's own workspace load without being listed; see [Workspace skills](#workspace-skills).

## Skill locations and precedence

MindRoom resolves skills for each agent from these locations, in this order:

1. Bundled skills shipped with MindRoom
2. Plugin-provided skill directories (see [Plugins](https://docs.mindroom.chat/plugins/))
3. User skills: `~/.mindroom/skills/`
4. Agent workspace skills: `<resolved workspace>/skills/`

For agents without `private`, the workspace skills directory is `<storage>/agents/<agent>/workspace/skills/`.
Private instances use `<storage>/private_instances/<scope-directory>/<agent>/<private.root>/skills/`; see [Private Instances](https://docs.mindroom.chat/configuration/agents/#private-instances).

If several skills share a name, the later location wins: agent workspace over user, user over plugin, and plugin over bundled.

## Workspace skills

Workspace skills are available only to the owning agent or private instance.
They do not appear in the dashboard Skills page or the skills API, because those views are not agent-scoped.
Hidden entries such as `.usage.json`, `.history/`, and `.archive/` are never loaded as skills.

Agents never need write access to a global skill root to create skills, so the bundled, plugin, and user roots can stay read-only, for example in container or Kubernetes deployments.
An agent with a workspace and workspace-rooted file or shell tools can write a skill to `<workspace>/skills/<skill-name>/SKILL.md`, which its file tools address as `skills/<skill-name>/SKILL.md`.
Such agents are told how in their system prompt through the `WORKSPACE_SKILL_AUTHORING_PROMPT` built-in prompt, which you can change through [Built-In Prompt Overrides](https://docs.mindroom.chat/configuration/#built-in-prompt-overrides).
A new or edited workspace skill is picked up on the agent's next run without a config change.

Being able to write workspace skills does not make an agent create them.
To have agents save reusable workflows on their own, add that guidance to the agent's instructions, or enable [automatic skill learning](#automatic-skill-learning).

MindRoom loads at most 256 workspace skills per agent, skips a skill whose `SKILL.md` exceeds 1 MiB or whose name exceeds 64 characters, and truncates descriptions to 1,024 characters, logging a warning in each case.

## Eligibility gating (OpenClaw metadata)

If `metadata.openclaw` is present, MindRoom loads the skill only when these rules pass:

- `os: ["linux", "darwin", "windows"]`: the current OS must be listed
- `always: true` bypasses `requires`, but not an OS mismatch
- `requires.env`: each environment variable is set, or a stored credential with that key exists
- `requires.config`: each config path is truthy, for example `agents.code.tools`
- `requires.bins`: all binaries exist in `PATH`
- `requires.anyBins`: at least one binary exists in `PATH`

Skills without `metadata.openclaw` are always eligible.

## Using skills at runtime

Agents see their available skills in the system prompt and load details with these tools:

- `get_skill_instructions(skill_name)` loads the full instructions for a skill
- `get_skill_reference(skill_name, reference_path)` reads a reference document
- `get_skill_script(skill_name, script_path, execute=False, args=None, timeout=30)` reads or executes a script

Workspace skill scripts can be read with `get_skill_script`, but not executed through it.
Agents with shell or file execution tools can still run workspace files through those tools.

Added, removed, or edited `SKILL.md` files take effect on the agent's next request, within about a second.
A workspace skill created during a turn becomes available on the next agent run, not in the same response.

## Automatic skill learning

Automatic skill learning follows the self-improvement loop of [Hermes Agent](https://github.com/NousResearch/hermes-agent).
After enough work in a conversation, a background review maintains a small library of class-level skills in the agent's workspace, and unused learned skills are archived.
It is opt-in for each agent:

```yaml
agents:
  assistant:
    display_name: Assistant
    skill_learning:
      enabled: true
      model: null  # null reviews on the response's own model and reuses its prompt cache
      review_interval: 10
      timeout_seconds: 120
      notify: true
      archive_after_days: 30
```

Hosted MindRoom instances enable it for the agents of a newly provisioned instance; set `agents.<name>.skill_learning.enabled: false` to turn it off.
Self-hosted configurations from `mindroom config init` leave it off.
Reviews incur additional model usage, reported under `kind: skill_learning` in [usage tracking](https://docs.mindroom.chat/usage/#token-usage).
Learned skills are generated from conversation content, so review them before relying on them for sensitive work.

### Settings

All `agents.<name>.skill_learning` fields are optional, and unknown fields are rejected.

| Field | Type | Default | Bounds and behavior |
|---|---|---|---|
| `enabled` | boolean | `false` | Count completed standalone responses, review conversations that reach the interval, and offer the `skill_manage` tool. |
| `model` | string or null | `null` | Review model alias from `models`; null reviews on the model the response used and reuses its prompt cache, while another model replays a digest of the conversation. |
| `review_interval` | integer | `10` | 1–1000 model replies per conversation between reviews, counting each tool-calling step. |
| `timeout_seconds` | integer | `120` | 10–900 seconds per review. |
| `notify` | boolean | `true` | Post an `m.notice` in the conversation when a review changes skills. |
| `archive_after_days` | integer | `30` | 0–3650 days with no use, creation, or `skill_manage` edit before a learned skill is archived; `0` keeps learned skills indefinitely. |

### When reviews run

Each conversation keeps a count of model replies: one for each tool-calling step and one for the final answer.
Only successful standalone-agent responses to a person in Matrix count.
Team responses, responses another agent asked for, scheduled, hook, and external-trigger responses, responses that resume after an approval, and runs resumed after a restart never count.
A response that calls `skill_manage` restarts the count from its last call, except in minimal mode, where `skill_manage` runs through the command line.
Once the count reaches `review_interval`, it restarts and the review starts in the background, so it never delays a reply.
A new response in the same conversation stops a running review.
Counts are per agent or private instance and conversation, not per requester, so a thread shared by several people is reviewed once.
Counts live in memory, so a restart starts every count over, and a review that fails or is stopped is not retried.

### What a review sees

By default, the review resends the final model request of the response that made the conversation due, on the same model with the review prompt appended.
The provider can serve it from its prompt cache, and the review sees the conversation verbatim, including tool calls and results.
This request is sent unredacted, because it is the request the same provider just received.

The review instead replays a digest of the stored conversation on a separate request when `skill_learning.model` names another model, the agent runs in minimal mode, or the final request cannot be reused or is too large for the review's input budget.
A digest shows the newest 24 messages verbatim and older messages shortened, leaves out older tool results, and redacts credential-like values on a best-effort basis.
Without its own `model`, a digest replay runs on the model the response used.

A review's input budget scales with the review model's `context_window`.
Set `context_window` on the agent's model so long conversations can still be reviewed verbatim; without it, a final request over about 30,000 input tokens is replayed as a digest.
A review makes a bounded number of tool calls and stops after `timeout_seconds`.

Only the skill tools run during a review; any other tool the agent has answers that it is not available, and scripts never run.
Provider-hosted tools, such as a provider's web search, stay available because the provider runs them.
The review prompt asks for class-level skills that capture lessons rather than logs, treats user corrections as first-class signals, prefers patches over rewrites, and excludes environment-specific failures, transient errors, one-off narratives, and unresolved attempts.
Override it through the `SKILL_REVIEW_PROMPT` [built-in prompt override](https://docs.mindroom.chat/configuration/#built-in-prompt-overrides).

### The skill_manage tool

Agents with skill learning enabled get a `skill_manage` tool, and the review writes skills with the same tool.
Other agents can list `skill_manage` in `tools` to save skills from chat.
Its actions are `create` a skill from a full `SKILL.md`, `patch` text in `SKILL.md` or a support file, `edit` to replace `SKILL.md`, and `write_file` or `remove_file` for one support file directly under `references/` or `scripts/`.
It cannot delete a skill; unused learned skills are archived, and you remove any skill by deleting its directory.

In chat, `skill_manage` can change any workspace skill, and a skill it creates belongs to the person, not the learner.
Configured bundled, plugin, and user skills are read-only.
Tool approval rules apply to chat calls as to any tool.
`skill_manage` works only on skills in their own `skills/<name>/` directories, so a workspace whose `skills/SKILL.md` makes the whole directory one skill gets no new skills or edits until that file moves into `skills/<name>/`.

A new skill needs a lowercase hyphenated name matching its directory and a description of at most 60 characters.
Files that contain a likely literal credential are refused with the offending line named: a quoted API key, token, secret, or password of at least 20 characters that does not name an environment variable, a private key header, or a GitHub, OpenAI, Anthropic, AWS, or GitLab token.
This check is a heuristic for common formats, not a guarantee.

### Ownership

The review edits only learned skills, never bundled, plugin, or user skills or workspace skills someone else wrote, and its new skills cannot reuse their names.
Ownership is recorded in `skills/.usage.json`, so a learned skill stays learner-owned when the agent or a person later rewrites it.
Skills the review creates also carry a visible marker in their frontmatter:

```yaml
metadata:
  mindroom:
    learned: true
```

Add this marker to a skill you wrote to hand it to the learner.
Add `pinned: true` under `metadata.mindroom` to any skill to keep the review and archival from ever editing or archiving it.
Private agents learn only from and into the requester's private workspace, and shared agents use `<storage>/agents/<agent>/workspace/skills/`.

### History, archive, and notices

Before `skill_manage` replaces or removes a file, in chat or in a review, it saves the previous version under `skills/.history/<skill>/`, keeping the ten newest versions.
Copy a saved version back to restore it.

Before each review, learned skills with no use, creation, or `skill_manage` edit for `archive_after_days` days move to `skills/.archive/<skill>--<timestamp>`; nothing is deleted.
Move a directory back to `skills/<skill>/` to restore it.
Archiving a skill forgets its ownership record, and a deleted skill's record is forgotten at the next review, so a skill restored or recreated after that starts a new inactivity period.
A restored learned skill still carries `learned: true` and stays learner-owned; add `pinned: true` to keep the learner from editing or archiving it.
Loading a workspace skill or one of its files through the skill tools counts as a use, also for agents without skill learning.
Minimal-mode agents read skills through their command line, which records no use, so a learned skill used only in minimal mode is archived after `archive_after_days`.
Archival is logged rather than announced, because other conversations may share the workspace.

With `notify: true`, a review that changed skills posts an `m.notice` in the conversation naming those skills, such as ``💾 Skill review: created `deploy-checks` ``.
The notice is also posted when a new response stopped the review after its changes landed, and it is left out of later model context.
Review log lines carry `kind: skill_learning`, so you can filter logs for them.

# Scheduling

Schedule agents or teams to do one-time or recurring work, such as reminders, daily reports, or periodic checks, using natural language.
Create schedules with the `!schedule` chat command or let an agent create them with the `scheduler` tool.

<video controls playsinline preload="metadata" aria-label="A scheduled task posts a morning brief" style="width: 100%">
  <source src="https://github.com/user-attachments/assets/cfbbac8e-6942-4bac-a920-ac3946951174#t=0.1" type="video/mp4" media="(prefers-color-scheme: dark)">
  <source src="https://github.com/user-attachments/assets/a3bacdb7-7d59-42d2-8d0f-33f391c412b2#t=0.1" type="video/mp4">
</video>

## Commands

Use `!help schedule` for inline help on the scheduling commands.

### `!schedule`

Schedule a one-time or recurring task.

```
!schedule <natural-language-request>
```

```
!schedule in 5 minutes Check the deployment
!schedule tomorrow at 3pm Send the weekly report
!schedule Every hour, @shell check server status
!schedule Daily at 9am, @finance market report
!schedule Weekly on Friday, @analyst prepare weekly summary
```

Include `@agent_name` or `@team_name` to choose who answers each run.
MindRoom checks that mentioned agents and teams are available in the room before it creates the task.

A task runs where it was created: in the room timeline for a room-level schedule, or in the current thread for a threaded schedule.
The task text never runs as a chat command, even when it starts with `!`; agents receive it as an ordinary message.

Conditional requests such as `if` or `when` become recurring polling schedules, not event subscriptions.
The condition is part of the task message, so the responder checks it on each run.
Include an explicit polling cadence for predictable behavior.

```
!schedule Every 5 minutes, check if I got an email about "urgent"; if so, @phone_agent call me
!schedule Every 10 minutes, check whether Bitcoin dropped below $40k; if so, @crypto_agent notify me
```

Add phrases to the request to change how each run behaves:

| Phrase | Effect |
| --- | --- |
| `with no history`, `without context`, `context-free` | Each run sees no prior messages; see [History limits](#history-limits) |
| `with only the last 5 messages of context` | Each run sees only recent messages |
| `silently`, `quietly` | Hide the trigger and post only findings; see [Silent delivery](#silent-delivery) |

### `!edit_schedule`

Replace an existing task's timing and content.

```
!edit_schedule <task-id> <new-task-description>
```

MindRoom re-parses the description to update timing and content.
Omitted settings stay unchanged, including the history limit, silent mode, and model.
A task cannot switch between one-time and recurring; cancel it and create a new one instead.

```
!edit_schedule task42 keep the same schedule but restore full history
!edit_schedule task42 every weekday at 8am check build status with no history
!edit_schedule task42 keep the same schedule but make it silent
```

**Aliases:** `!editschedule`, `!edit-schedule`

### `!list_schedules`

List pending tasks in the current room, or in the current thread when used inside one.
Each entry shows its task ID, timing, mode (`Silent` or `Visible`), and selected model if any.

```
!list_schedules
```

**Aliases:** `!listschedules`, `!list-schedules`, `!list_schedule`, `!listschedule`, `!list-schedule`, `!inspect_schedules`, `!inspectschedules`, `!inspect-schedules`, `!inspect_schedule`, `!inspectschedule`, `!inspect-schedule`

### `!cancel_schedule`

Cancel one task, or every task in the room.

```
!cancel_schedule <task-id>
!cancel_schedule all
```

Use `!list_schedules` to find task IDs.

**Aliases:** `!cancelschedule`, `!cancel-schedule`

## [`scheduler`]

<video controls playsinline preload="metadata" aria-label="A check asked for in conversation becomes a weekly scheduled task" style="width: 100%">
  <source src="https://github.com/user-attachments/assets/41986747-dfb3-41cd-b3c6-b60f8eabdab8#t=0.1" type="video/mp4" media="(prefers-color-scheme: dark)">
  <source src="https://github.com/user-attachments/assets/d8729fbc-7377-4b80-b3c2-c39394eb19a4#t=0.1" type="video/mp4">
</video>

The `scheduler` tool lets an agent create, edit, list, and cancel the same tasks as the chat commands.
It is included in `defaults.tools` by default, needs no setup, and has no tool-specific configuration fields.
It only works from a live room or thread; elsewhere it returns `Scheduler tool is unavailable in this context.`

```yaml
agents:
  assistant:
    tools:
      - scheduler
```

| Function | Arguments |
| --- | --- |
| `schedule(request, new_thread, history_limit=None, silent=None, model=None)` | `new_thread` is required |
| `edit_schedule(task_id, request, history_limit=None, silent=None, model=None)` | Omitted arguments keep the task's current settings |
| `list_schedules()` | |
| `cancel_schedule(task_id)` | |

- `new_thread=False` posts each run in the current room or thread.
  `new_thread=True` posts each run as a new room-level message, and the responder answers in a new thread under it with a fresh session.
- `history_limit` caps how many recent messages each run sees: `0` for none, a positive integer for that many, unset for full history.
- `silent=True` turns on [silent delivery](#silent-delivery); unset infers the mode from the request text.
- `model` picks a model alias for each run; see [Model selection](#model-selection).

```python
schedule("tomorrow at 9am @ops check the deployment", new_thread=False)
schedule("every weekday at 8am post the on-call handoff summary", new_thread=True)
schedule("every hour @ops check deployment health", new_thread=False, history_limit=0, model="cheap")
schedule("every 5 minutes check the inbox for urgent mail", new_thread=False, history_limit=0, silent=True)
list_schedules()
edit_schedule("a1b2c3d4", "tomorrow at 10am @ops check the deployment", history_limit=5, silent=False)
cancel_schedule("a1b2c3d4")
```

## History Limits

Scheduled runs normally use the responder's configured conversation history.
Use `with no history`, `without context`, or `context-free` when each run should see no prior room or thread messages; the system prompt and the task message remain available.
Use phrases such as `with only the last 5 messages of context` or `include the last 5 messages` to cap each run to recent context.
In an edit, say `restore full history` or `use unlimited history` to remove a limit.
Agents set the same limit with the tool's `history_limit` argument.

## Silent Delivery

Schedules are visible by default.
Add `silently` or `quietly` to a request when the trigger and routine no-report results should stay out of the room timeline.

```
!schedule Every 5 minutes, quietly check the inbox for urgent messages and report only when one arrives
!schedule Daily at 9am, silently check whether the backup failed and report failures
```

A silent schedule does not show its trigger in the room.
A successful run that returns nothing, whitespace, or only `NO_REPLY` (any case) posts no message.
Findings, failures, and messages that tools send explicitly remain visible.
Silent runs also show no typing indicator, progress placeholder, stop control, or streaming updates.
With `new_thread=True`, a silent schedule posts any finding or failure as a new room-level message.
Say `make this schedule silent` or `make this schedule visible` in `!edit_schedule` to change the mode.

Silent delivery only hides messages from the room; the task text still travels through Matrix and is subject to homeserver retention.

Each silent run writes a JSON receipt to `<agent-workspace>/.mindroom/scheduled_runs/`, so you can check what a silent run did.
A receipt records the prompt, room, thread, `status` (`started` or `completed`), `result` (`reported`, `no_report`, or `suppressed`), the final response text, and UTC start and completion times.
A receipt still in `started` means the run began but never reached a final response.
Team runs write a receipt to each member agent's workspace, and private agents write to the requester's workspace.
Workspace knowledge indexing skips these receipts.

## Model Selection

Pass `model` to `schedule()` to run each scheduled response with a model alias from `models:` in `config.yaml`, for example a cheaper model for simple recurring checks.

```python
schedule("every hour @ops check deployment health", new_thread=False, history_limit=0, model="cheap")
edit_schedule("task42", "keep the same schedule and task", model="cheap")
edit_schedule("task42", "keep the same schedule and task", model="")
```

The model applies to the whole scheduled response, including a team's coordinator and members.
It takes precedence over room and thread model settings without changing them for later messages.
Omitting `model` on creation uses normal model selection; omitting it on an edit keeps the saved choice, and an empty string restores normal model selection.
Parsing the scheduling request itself always uses the default model.

## Timezone

The top-level `timezone` setting (default `UTC`) controls how MindRoom interprets times in requests and displays scheduled times.

```yaml
timezone: America/Los_Angeles
```

Recurring schedules run on UTC cron times, so their local clock time shifts when the timezone's UTC offset changes, such as at daylight-saving transitions.
Edit or recreate a recurring schedule after an offset change if it must keep the same local time.

## Permissions and Automatic Cancellation

Schedules belong to the room, not to their creator.
Any authorized participant in the room can edit or cancel any of its schedules by task ID, and listing inside a thread only filters the view.
Each run acts as the schedule's creator, as if the creator had sent the message, so it uses that person's access, per-user credentials, and private-agent workspace.
Editing a schedule with `!edit_schedule` or the `scheduler` tool makes the editor its new creator.

MindRoom cancels a schedule automatically once neither its creator nor any of the creator's [bridge aliases](https://docs.mindroom.chat/authorization/#bridge-aliases) is joined to the room.
Bot accounts and managed identities do not count.
A run waits rather than proceeding when membership cannot be confirmed.

## Persistence and Missed Runs

Schedules are stored in Matrix room state and survive restarts.
MindRoom only acts on schedule state written by its own router, agent, or team accounts; schedule state written by any other account, including a room admin, is ignored and logged.
The homeserver must support `GET /_matrix/client/v3/rooms/{roomId}/state/{eventType}/{stateKey}?format=event` (Matrix spec v1.16), as Synapse, Tuwunel, and Dendrite do; otherwise reading tasks fails.

### Scheduled Task Restoration

The router restores a room's schedules and pending configuration changes when it joins the room after startup.
A one-time task missed by less than 24 hours runs once Matrix sync is ready; an older one is marked failed instead.

### Recurring task recovery

After a restart or a late wake-up, a recurring task runs its latest missed occurrence if it is within the catch-up window, and multiple missed occurrences run once.
Occurrences outside the window are skipped and logged, and later runs keep the normal cadence.

```yaml
scheduler_catch_up_grace_seconds: 3600  # default; 0 disables catch-up
```

Recurring progress is kept in `tracking/recurring_schedules/` under the storage directory, which must persist across restarts.
Without it, or after a schedule edit, MindRoom resumes from the next future occurrence without replaying past ones.
Restarts do not post the same trigger twice.
For `schedule:fired` hooks that run more than once for the same occurrence, see [Hooks](https://docs.mindroom.chat/hooks/#event-notes).

# Chat Commands

Chat commands are messages starting with `!` that you type in any Matrix room where MindRoom agents or teams are present.
Use them to get help, manage schedules, switch models or modes, manage room encryption, and, when enabled, change configuration from chat.
This page lists every command and fully covers `!help`, `!hi`, and `!config`; the other commands are documented on the linked pages.

## Command List

| Command | Description | Details |
|---------|-------------|---------|
| `!help [topic]` | Get help on commands or a specific topic | [`!help`](#help) |
| `!hi` | Show the welcome message again | [`!hi`](#hi) |
| `!schedule <task>` | Schedule a task or reminder | [Scheduling](https://docs.mindroom.chat/scheduling/#schedule) |
| `!list_schedules` | List pending scheduled tasks | [Scheduling](https://docs.mindroom.chat/scheduling/#list_schedules) |
| `!cancel_schedule <id>` | Cancel a scheduled task | [Scheduling](https://docs.mindroom.chat/scheduling/#cancel_schedule) |
| `!edit_schedule <id> <task>` | Edit an existing scheduled task | [Scheduling](https://docs.mindroom.chat/scheduling/#edit_schedule) |
| `!desktop [setup\|status\|confirm\|rotate\|disconnect]` | Manage your Desktop target for one agent | [Desktop](https://docs.mindroom.chat/tools/desktop/#desktop) |
| `!mode <agent> minimal\|standard\|show\|reset` | Switch one agent between standard tools and a Bash-only interface | [Minimal Agent Mode](https://docs.mindroom.chat/tools/agent-cli/#mode) |
| `!model [name\|list\|reset]` | Show or switch the model used in the current thread | [Models](https://docs.mindroom.chat/configuration/models/#model) |
| `!room_model [name\|list\|reset]` | Show the room model default or switch it (set/reset require a room admin) | [Models](https://docs.mindroom.chat/configuration/models/#room_model) |
| `!thread_mode [room\|thread\|reset\|show]` | Show or switch the thread mode used in the current room (set/reset require a room admin) | [Threads](https://docs.mindroom.chat/configuration/threads/#thread_mode) |
| `!encrypt [confirm]` | Enable end-to-end encryption for this room (irreversible, room admin only) | [Matrix](https://docs.mindroom.chat/matrix/#encrypt) |
| `!e2ee` | Show encryption diagnostics for this room | [Matrix](https://docs.mindroom.chat/matrix/#e2ee) |
| `!config <operation>` | View and modify configuration (disabled by default, platform administrators only) | [`!config`](#config) |
| `!reload-plugins` | Force-reload all configured plugins (platform administrators only) | [Plugins](https://docs.mindroom.chat/plugins/#reload-plugins) |

Command names are case-insensitive.
Any other message starting with `!` gets `❌ Unknown command. Try !help for available commands.`

## Command Handling

The router answers commands, in the main room and in threads, even in rooms with a single agent.
The exception is `!desktop`, which a private agent may answer itself when the room contains only you and that agent; see [`!desktop`](https://docs.mindroom.chat/tools/desktop/#desktop) for its room requirements.

Commands follow the same [authorization](https://docs.mindroom.chat/authorization/) rules as ordinary messages.
"Room admin" means a Matrix room administrator, while "platform administrator" means a user listed in `administrators`.

Commands must be typed as text; voice messages are never turned into commands.

An agent can send `!help`, `!mode`, `!model`, and the schedule commands on your behalf, and they run with your permissions.
Any other command sent by an agent for you is refused with `❌ Agents cannot run this command for you. Send it yourself.`

## `!help`

`!help` lists all commands, and `!help <topic>` shows detailed usage and examples for one topic.

```
!help
!help schedule
!help config
```

**Topics:** `schedule`, `list_schedules`, `inspect_schedules`, `cancel`, `cancel_schedule`, `edit`, `edit_schedule`, `config`, `mode`, `model`, `room_model`, `room-model`, `roommodel`, `thread_mode`, `thread-mode`, `threadmode`, `reload-plugins`, `reload_plugins`, `encrypt`, `e2ee`, `encryption`

Any other topic shows the general command list.

## `!hi`

`!hi` shows the room's welcome message again: the agents and teams you can address, their roles and tools, and quick-start instructions.

## `!config`

`!config` views and changes `config.yaml` from chat.
It is disabled by default; set `authorization.config_command_enabled: true` (boolean, default `false`) to enable it.
Even when enabled, only platform administrators can use it.
Otherwise it replies `❌ Config command disabled.` or `❌ Admin only.`

```
!config show
!config get agents
!config get agents.analyst.display_name
!config set agents.analyst.display_name "Research Expert"
!config set models.default.id gpt-6-astra
!config set defaults.markdown false
!config set agents.analyst.tools [shell, file]
```

`!config` alone is the same as `!config show`.
Paths use dot notation, such as `agents.analyst.role`, and list items use indexes, such as `agents.analyst.tools.0` for the first tool.
Values are parsed as YAML, so `false`, `42`, and `[shell, file]` become a boolean, a number, and a list.
Quote a value to keep its exact spacing.

Shown values are redacted like `config_manager` output; see [Access, Redaction, and Saving](https://docs.mindroom.chat/tools/agent-orchestration/#access-redaction-and-saving).
`!config set` refuses a value containing the `***redacted***` marker or a masked URL password such as `user:***@host`, so copying shown values back cannot overwrite a hidden secret; set the field to its real value instead.

### Configuration Confirmations

`!config set` validates the change against the full configuration and replies with an error ending in `Changes were NOT applied.` when it is invalid.
A valid change gets a preview of the current and new values from the router, with ✅ and ❌ reactions.
Only the user who requested the change can confirm or cancel it, and that user must still be a platform administrator.
Unconfirmed changes expire after 24 hours.

Pending changes usually survive a MindRoom restart; if one cannot, such as a secret value, the confirmation asks you to run `!config set` again.

On confirmation, the change is saved to `config.yaml` and applies to new agent interactions, except [event journal](https://docs.mindroom.chat/deployment/storage/#event-journal) changes, which apply after MindRoom restarts.

## Stop Button

Cancel an in-progress response by reacting with 🛑 on the message being generated.
When `defaults.show_stop_button` is `true` (the default), MindRoom adds that 🛑 reaction to the message for you to click.
Setting `show_stop_button: false` hides only that reaction; a 🛑 reaction you add yourself still cancels.

The stop button only works on messages still being generated, and 🛑 reactions from agents are ignored.
See [Cancellation and Errors](https://docs.mindroom.chat/streaming/#cancellation-and-errors) for how a cancelled response is finalized.

# Streaming Responses

MindRoom streams agent responses to Matrix by progressively editing a single message, so users see text appear as the model generates it.
This page covers when streaming is used, how to tune or disable it, how streamed messages show tool calls, completion, cancellation, and errors, and how oversized responses are delivered.

<video controls playsinline preload="metadata" aria-label="Three colleagues and one agent in a shared thread, each answer streaming live" style="width: 100%">
  <source src="https://github.com/user-attachments/assets/b5772e74-de93-4559-8f4f-b045add92caa#t=0.1" type="video/mp4" media="(prefers-color-scheme: dark)">
  <source src="https://github.com/user-attachments/assets/ec8dc609-e4b5-4db4-b806-d8c93fee52a0#t=0.1" type="video/mp4">
</video>



## How It Works

1. MindRoom sends an initial message showing a `Thinking...` placeholder until text arrives.
2. As text arrives, MindRoom edits the same message with the accumulated content.
3. When the response finishes, a final edit holds the complete text.

When a response is not streamed, the placeholder is replaced with the complete response in a single edit.
Agents also show a typing indicator while they work on a response.

## Configuration

Streaming is enabled by default.
These settings are global-only under `defaults` and cannot be overridden per agent.

```yaml
defaults:
  enable_streaming: true         # Set false to disable progressive edits
  streaming:
    update_interval: 5.0
    min_update_interval: 0.5
    interval_ramp_seconds: 15.0
    max_idle: 2.0
```

| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `enable_streaming` | bool | `true` | Stream responses via progressive message edits. When `false`, the placeholder is replaced with the complete response in a single edit. |
| `streaming.update_interval` | float, > 0 | `5.0` | Steady-state seconds between edits. |
| `streaming.min_update_interval` | float, > 0 | `0.5` | Seconds between edits at the start of a response. |
| `streaming.interval_ramp_seconds` | float, >= 0 | `15.0` | Seconds over which the interval ramps from `min_update_interval` to `update_interval`. Set `0` to use `update_interval` from the start. |
| `streaming.max_idle` | float, > 0 | `2.0` | When no new text has arrived for this many seconds, the buffered text is shown with the next streaming update. |

Edits also come sooner than the interval once enough new text has accumulated, and a tool-call marker appears as soon as the tool starts.
Raise `update_interval` to reduce load on the homeserver, or lower it for smoother updates.

## Presence-Based Streaming

Even when streaming is enabled, MindRoom streams a response only when the user who sent the message is `online` or `unavailable`; for an `offline` requester, the placeholder is replaced with the complete response.
Everyone in the room sees the same edits.
If the presence check fails, for example because the homeserver has presence disabled, MindRoom does not stream.
A reply that continues after a restart always streams, even when streaming is off or the requester is offline, so the part it already showed stays in the message.
When no requester can be identified, MindRoom streams.

## Presence

Running agents appear `online` (or `unavailable` when idle) and `offline` when stopped or disconnected.
Their presence status message shows the model, role, and tool count, for example `🤖 Model: anthropic/claude-sonnet-5-5 | 💼 Code assistant | 🔧 5 tools available`.

## Stream Status

Each streamed message carries its progress in the Matrix event content field `io.mindroom.stream_status`.

| Value | Meaning |
|-------|---------|
| `pending` | Initial message of the response |
| `streaming` | Response is still being generated |
| `approval_pending` | Response is paused until someone approves or denies a tool call (see [Tool Approval](https://docs.mindroom.chat/tool-approval/)) |
| `completed` | Response finished successfully |
| `cancelled` | User stopped the response |
| `error` | Response failed or was otherwise interrupted |

To confirm a response finished, inspect the latest `io.mindroom.stream_status` in a client or event view that shows event content.
MindRoom never appends an ellipsis to generated text, and clients may draw their own progress indicators, so a trailing ellipsis is not a progress signal.

## Tool Calls During Streaming

When an agent calls tools, MindRoom shows inline markers in the message text:

```
🔧 `web_search` [1] ⏳       ← tool call started
🔧 `web_search` [1]          ← tool call completed
```

The number in brackets counts tool calls within the message, and `io.mindroom.tool_trace.events[N-1]` in the event content holds the tool name, argument preview, and result preview for call `[N]`.
Tools that start in quick succession appear together in one edit.
Markers and tool-trace metadata are shown by default; to hide them, set `show_tool_calls: false` on one agent or under `defaults` for every agent that leaves it unset (see [Agent Configuration](https://docs.mindroom.chat/configuration/agents/#defaults)).
With tool calls hidden, the agent still shows typing activity, and a tool that needs an isolated worker may show generic progress such as `Preparing isolated worker... 17s elapsed.` without naming the tool.

## Cancellation and Errors

Users can cancel an in-progress response by reacting with 🛑 on the message being generated (see [Stop Button](https://docs.mindroom.chat/chat-commands/#stop-button)).
Tool calls running in a [worker](https://docs.mindroom.chat/deployment/sandbox-proxy/), such as `run_shell_command` or `run_python_code`, stop with it, apart from the exceptions listed there.
The partial text stays in the message, followed by a note explaining how it ended:

| Note | Cause |
|------|-------|
| `**[Response cancelled by user]**` | The user stopped the response. |
| `**[Response interrupted by service restart]**` | MindRoom restarted while the response was being generated; the reply continues below the note once MindRoom is back, or ends there if nothing is left to continue. |
| `**[Response interrupted]**` | The response was interrupted for another reason. |
| `**[Response interrupted by an error: <error description>]**` | Generation failed; the description says why. |

A crash, an orderly shutdown, or an agent restarted by a configuration or MCP change, including during an approved tool call, keeps the partial reply and its tool calls, and the agent continues it in the same message once MindRoom is back.
The agent is told which visible tool calls already finished so it does not repeat side effects; calls hidden by `show_tool_calls: false` cannot be passed on, so it is only warned that they may have run.

## Large Messages

A Matrix event holds at most about 64 KB, so `defaults.large_message_strategy` controls how a larger response is delivered.

| Value | Delivery |
|-------|----------|
| `sidecar` (default) | A preview with as much text as fits, plus the full message content attached as `message-content.json` (`message-content.json.enc`, encrypted, in encrypted rooms). |
| `split` | Several complete rich-text messages: the first replaces the streaming placeholder and the rest follow it in the same thread or room. |

The `sidecar` strategy applies to messages over about 55 KB and edits over about 27 KB, and the preview omits inline tool-trace metadata.
With `split`, each segment renders as standalone Markdown, text is cut at paragraph or line boundaries and never inside a fenced code block, and the segments together reproduce the full response exactly.
Even with `split`, non-text payloads, metadata that alone exceeds the size budget, and a single code block larger than one event use the sidecar.

# Voice Messages

This page covers Matrix voice messages sent to agents and teams: speech-to-text (STT) setup, transcript cleanup, what appears in the room, and what happens without STT.
For live spoken conversations in Matrix calls, see [Voice Calls](https://docs.mindroom.chat/voice-calls/).

<video controls playsinline preload="metadata" aria-label="A voice message becomes a reminder" style="width: 100%">
  <source src="https://github.com/user-attachments/assets/8abba58e-790f-4c80-a57d-62ab683ea18c#t=0.1" type="video/mp4" media="(prefers-color-scheme: dark)">
  <source src="https://github.com/user-attachments/assets/7bf1bb2f-31c7-4ef4-ac07-4ec1b22b2da1#t=0.1" type="video/mp4">
</video>

With voice enabled, MindRoom transcribes each voice message, cleans up the transcript, and handles it like a text message prefixed with `🎤`.
The responder also receives the original audio as an attachment.
Without STT, voice messages still reach responders as raw audio; see [Voice Fallback](#voice-fallback-no-stt-available).

## Setup

Configure voice in `config.yaml` or in the dashboard's Voice tab.

### OpenAI transcription

```yaml
voice:
  enabled: true
  stt:
    provider: openai
    model: gpt-transcribe
  intelligence:
    model: default  # Model used for mention normalization and light ASR cleanup
```

Cloud OpenAI transcription uses the `openai` credential service by default, which resolves a stored OpenAI credential or `OPENAI_API_KEY`.
Set `voice.stt.api_key` or a different `voice.stt.credentials_service` to use another key.

### Self-hosted Whisper

Any service that implements the OpenAI-compatible `/v1/audio/transcriptions` endpoint works, such as [faster-whisper-server](https://github.com/fedirz/faster-whisper-server):

```yaml
voice:
  enabled: true
  stt:
    provider: openai_compatible
    model: whisper-1
    host: http://localhost:8080
    api_key: your-custom-api-key  # Optional
```

`host` may be the service root or its `/v1` base URL.
Without `api_key` or `credentials_service`, MindRoom sends a non-secret placeholder key, so keyless local servers work and your OpenAI key is never sent to them.

## Configuration Reference

| Field | Default | Description |
|-------|---------|-------------|
| `voice.enabled` | `false` | Transcribe voice messages; when `false`, voice messages use the [raw-audio fallback](#voice-fallback-no-stt-available). |
| `voice.visible_router_echo` | `true` | Let the router show transcription progress and the transcript in the room; see [Voice Message Processing](#voice-message-processing). |
| `voice.stt.provider` | `openai` | `openai` or `openai_compatible`. |
| `voice.stt.model` | `gpt-transcribe` | STT model name. |
| `voice.stt.host` | none | Service root or `/v1` base URL; must be an HTTP(S) URL. Required for `openai_compatible`. |
| `voice.stt.api_key` | none | Inline API key. |
| `voice.stt.credentials_service` | `openai` for `provider: openai`, otherwise none | Named credential service holding the API key. |
| `voice.stt.extra_kwargs` | `{}` | Extra form fields sent with each transcription request, such as `language`; must not set `api_key`, `base_url`, `client`, or `model`. |
| `voice.intelligence.model` | `default` | Name of a `models` entry used for transcript cleanup. |

`provider: openai` requires `api_key` or `credentials_service`, and `provider: openai_compatible` requires `host`.

Each transcription request has a 60-second limit and is not retried.
On timeout or service error, the message uses the raw-audio fallback.

## Voice Message Processing

What appears in the room depends on whether the router is present and whether the responder is already clear.
Voice messages follow the same mention, thread, and access rules as text from the original sender.

**Router echo.**
With `voice.enabled: true` and `voice.visible_router_echo: true`, the router immediately posts `Router agent is transcribing…` and then replaces it with the cleaned transcript, or with the fallback text if transcription failed.
With `voice.enabled: false`, the router posts the fallback text directly instead.
The echo is display-only: agents answer the original voice message, not the echo.
The echo appears only when the router is in the room and allowed to reply to the sender.
Set `voice.visible_router_echo: false` to hide it; this does not change who answers.

**Responder already clear.**
If only one agent or team can respond, the transcript or caption mentions one, or thread context picks one, that responder answers the voice message directly without a router handoff.

**Router must choose.**
If several agents or teams can respond and none is targeted, the router routes on the transcript and posts a normal handoff such as `@home could you help with this?`.
The chosen responder answers that handoff and still receives the original audio attachment.

**No router, or router cannot reply to the sender.**
Agents and teams handle the voice message directly with the same rules as text, and no router echo appears.
If several responders remain and none is targeted, nobody answers until the user mentions an agent or team.

## Transcript Normalization

The `voice.intelligence.model` cleans up each raw transcript before dispatch:

- Spoken agent or team names become `@agent` or `@team` mentions, using display names such as "HomeAssistant" to produce `@home`.
- Mentions of agents or teams not available to the sender in the room lose their `@`, so they do not trigger a response.
- Spoken text is never rewritten into `!command` syntax.
- Light speech-recognition errors are corrected.

If cleanup fails or times out, MindRoom uses the raw transcript.
Choose a faster or stronger model in `voice.intelligence.model` to trade cleanup speed against accuracy.

## Voice Fallback (No STT Available)

When voice is disabled, STT is unavailable, or transcription fails, the voice message is dispatched as `🎤 [Attached voice message]`, or as `🎤` followed by the message caption when it has one.
The audio is registered as an attachment, so responders with audio-capable models receive it directly and tool-using responders can fetch it with the `attachments` tool.
Attachment IDs follow the context-scoping rules in [File & Video Attachments](https://docs.mindroom.chat/attachments/).
If the audio cannot be downloaded from Matrix, the fallback text is dispatched without an attachment.

Without a transcript, routing has little text to work with, so use explicit `@mentions` or reply in an existing thread in rooms with several responders.

## Limitations and Tips

- Only OpenAI-compatible STT APIs are supported.
- Audio quality and background noise affect transcription accuracy.
- Say the agent or team name first, for example "Hey assistant, what's the weather?"

## Text-to-Speech

Agents can also send spoken replies, which is separate from voice message transcription.
The `matrix_voice_message` tool sends a playable Matrix voice note to the current room or thread; see [Matrix & Attachments tools](https://docs.mindroom.chat/tools/matrix-and-attachments/).
The `openai`, `eleven_labs`, `cartesia`, and `groq` tools generate speech audio; see [AI & Generation tools](https://docs.mindroom.chat/tools/ai-and-generation/).

# Voice Calls

MindRoom agents can join Element Call voice calls (MatrixRTC, also used by Element X and recent Cinny releases) in their rooms and talk with the caller in real time.
Use this page to set up calls, choose a voice backend, and find out why an agent did or did not join a call.
Voice messages (recorded audio clips) are covered on [Voice Messages](https://docs.mindroom.chat/voice/).

Each calls-enabled agent uses one of three backends:

| Backend | What it does |
|---------|--------------|
| `realtime` | OpenAI realtime speech-to-speech runs the conversation with the agent's prompt and tools. |
| `live` | OpenAI Live handles speech and delegates substantive requests to the normal MindRoom agent. |
| `cascaded` | Separate speech-to-text and text-to-speech services wrap the agent's normal MindRoom response, so any model provider works, including fully local setups. |

## Setup

1. Install the `matrix_calls` extra (`pip install "mindroom[matrix_calls]"` or `uv sync --extra matrix_calls`).
2. Make sure your Matrix deployment has a MatrixRTC backend; see [Server requirements](#server-requirements).
3. Define call profiles and assign one to each calls-enabled agent:

```yaml
calls:
  enabled: true
  profiles:
    openai-realtime:
      backend: realtime
      model: gpt-realtime-2.1
      credentials_service: openai-voice
      voice: marin
  agents:
    assistant: openai-realtime
  # livekit_service_url: https://rtc.example.org
```

Calls can also be configured in the dashboard's [Voice tab](https://docs.mindroom.chat/dashboard/#voice).
A calls-enabled agent shows `📞 Voice calls` in its Matrix presence status when the extra is installed and its profile's credentials resolve, so clients can offer the call action only where it will be answered.

## Calls configuration

| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `calls.enabled` | bool | `false` | Lets agents join voice calls. |
| `calls.profiles` | map of name to profile | `{}` | Reusable, complete voice pipelines; each has a `backend` and the fields below. |
| `calls.agents` | map of agent name to profile name | `{}` | Each calls-enabled agent uses exactly one profile. |
| `calls.livekit_service_url` | string | discovered | MatrixRTC authorization service URL to use instead of discovering it from the agent homeserver's `.well-known/matrix/client`. |

`enabled` and `livekit_service_url` are global and cannot be set per agent.
Profiles have no inherited defaults and credentials never fall back to another key, so a missing credential service leaves calls unavailable rather than using a different OpenAI key.
Set a dedicated credential service when voice and chat use different OpenAI credentials.

Configuration loading fails with one of these errors when references are wrong:

- `calls.agents references unknown profile(s): ...`
- `calls.agents references unknown agent(s): ...`
- `calls.agents configures multiple agents for room(s): ...`, because a room can have at most one calls-enabled agent.
- `calls.profiles references unknown cascaded model(s): ...` or `calls.profiles references unknown Live agent model(s): ...`, when a profile's model alias is not in the top-level `models` mapping.

### Realtime profile fields

| Field | Required | Description |
|-------|----------|-------------|
| `backend` | yes | `realtime` |
| `model` | yes | OpenAI realtime speech-to-speech model ID. |
| `credentials_service` | yes | Named credential service holding the OpenAI API key. |
| `voice` | yes | Realtime voice preset. |

### Live profile fields

| Field | Required | Description |
|-------|----------|-------------|
| `backend` | yes | `live` |
| `model` | yes | OpenAI Live speech model ID, independent of `agent_model`. |
| `credentials_service` | yes | Named credential service holding the OpenAI API key; Live needs no separate STT or TTS credentials. |
| `voice` | yes | Live voice preset. |
| `agent_model` | no | Name of a top-level `models` entry used for delegated agent turns; omitted keeps normal room and agent model resolution. |

### Cascaded profile fields

| Field | Required | Description |
|-------|----------|-------------|
| `backend` | yes | `cascaded` |
| `model` | no | Name of a top-level `models` entry for call turns; it takes precedence over room and agent models, and omitting it keeps normal model resolution. |
| `stt` | yes | Speech-to-text service. |
| `tts` | yes | Text-to-speech service. |

Each speech service (`stt` and `tts`) has these fields:

| Field | Default | Description |
|-------|---------|-------------|
| `provider` | `openai` | `openai` or `openai_compatible`. |
| `model` | required | Speech model name. |
| `credentials_service` | none | Named credential service holding the API key. |
| `api_key` | none | Inline API key. |
| `host` | none | Service root or its `/v1` base URL; must be an HTTP(S) URL. |
| `extra_kwargs` | `{}` | Provider options such as `language` for STT or `voice` for TTS; must not set `api_key`, `base_url`, `client`, or `model`. |

`openai` services require `credentials_service` or `api_key`.
`openai_compatible` services require `host` and may omit credentials, in which case MindRoom sends a non-secret placeholder key.
STT services must expose `/v1/audio/transcriptions` and TTS services `/v1/audio/speech`.
The two legs may use the same credential service or different ones.

## Choosing a backend

### Realtime

The realtime model runs the whole conversation itself, using the agent's rendered prompt and its tools.
See the example in [Setup](#setup).

### Live with agent delegation

Live answers from the agent's full rendered system prompt, including its context files and caller-specific workspace, and delegates requests that need tools, research, more memory, or actions to the normal MindRoom agent, then relays the result conversationally.
If the agent's prompt plus voice guidance exceeds about 16,000 tokens (a local estimate, not a provider limit), Live instead delegates every substantive request, including questions about identity and preferences, to the normal agent.
Delegated turns keep their history while the call lasts, and each new call starts a fresh delegate session.

This example uses GPT-Live for speech and the `chat` model for delegated turns, with a voice credential independent of the agent's model provider:

```yaml
models:
  chat:
    provider: anthropic
    id: claude-opus-5-5

agents:
  assistant:
    display_name: Assistant
    model: chat

calls:
  enabled: true
  profiles:
    openai-live:
      backend: live
      model: gpt-live-1
      credentials_service: openai-live
      voice: marin
      agent_model: chat
  agents:
    assistant: openai-live
```

### Cascaded

Each finalized transcript goes through the agent's normal response path, with the same model resolution, system prompt, instructions, knowledge, skills, hooks, tools, requester identity, and history.
The profile's `model` changes only the LLM; speech services, prompts, history, memory, and tools stay the same.
Turn ends are detected locally, and the caller can interrupt the agent by speaking.

This example uses OpenAI speech services and a faster model for call turns than for text chat:

```yaml
models:
  chat:
    provider: anthropic
    id: claude-opus-5-5
  call_fast:
    provider: anthropic
    id: claude-haiku-4-5

agents:
  assistant:
    display_name: Assistant
    model: chat

calls:
  enabled: true
  profiles:
    fast-cascaded:
      backend: cascaded
      model: call_fast
      stt:
        provider: openai
        model: gpt-transcribe
        credentials_service: openai-voice
        extra_kwargs:
          language: en
      tts:
        provider: openai
        model: gpt-4o-mini-tts
        credentials_service: openai-voice
        extra_kwargs:
          voice: ash
  agents:
    assistant: fast-cascaded
```

OpenAI documents [`gpt-transcribe`](https://developers.openai.com/api/docs/models/gpt-transcribe) for transcription and [`gpt-4o-mini-tts`](https://developers.openai.com/api/docs/models/gpt-4o-mini-tts) for text-to-speech.

### Completely local cascaded setup

This configuration sends STT, LLM, and TTS requests only to localhost services and needs no cloud API key:

```yaml
models:
  default:
    provider: openai
    id: local-chat-model
    extra_kwargs:
      api_key: sk-no-key-required
      base_url: http://127.0.0.1:9292/v1

memory: none

agents:
  assistant:
    display_name: Assistant
    role: Help the caller.
    rooms: [lobby]

calls:
  enabled: true
  profiles:
    local-cascaded:
      backend: cascaded
      stt:
        provider: openai_compatible
        model: whisper-large-v3
        host: http://127.0.0.1:9000
        extra_kwargs:
          language: en
      tts:
        provider: openai_compatible
        model: kokoro
        host: http://127.0.0.1:9001
        extra_kwargs:
          voice: af_heart
  agents:
    assistant: local-cascaded
```

Different agents can use different profiles, for example one agent on `realtime` and another on this local profile.

## When an agent joins a call

An agent joins a call when it starts in one of the agent's configured rooms, or in an ad-hoc room whose invitation the agent accepted under its [`accept_invites`](https://docs.mindroom.chat/configuration/agents/) setting.
Ad-hoc rooms let a Matrix client create a private voice room and invite one agent without adding the room to `config.yaml`.
A configured room assignment wins over invites; if several calls-enabled agents were only invited to the same room, none of them answers calls there.

The agent stays in a call only while one Matrix user is in it (that user may join from several devices) and only while that user passes the normal room and per-agent reply permissions.
That user is the requester for tools, and requester-private agents use that user's workspace, memory, credentials, history, and knowledge.

Tools that need confirmation, user input, external execution, or [`tool_approval`](https://docs.mindroom.chat/tool-approval/) are hidden during calls in every backend, because a call has no approval UI.
[Deferred tools](https://docs.mindroom.chat/tools/dynamic-tools/) are available from the start of a call in every backend, with no search or loading step.

The agent leaves the call when the caller leaves, a second Matrix user joins, the voice session ends, or MindRoom shuts down.
A restart, or a configuration reload that restarts the call agent, also drops it from the call.

If the agent joins but cannot hear or speak, look for a `Voice call error:` notice in the room, which names the failing service and how to fix it.
For a rejected credential, update the credential MindRoom uses, restart MindRoom, then leave and rejoin the call; retrying with the same credential does not help.

## Transcripts and memory

Every call writes a markdown transcript as it goes.

| Agent | Transcript location |
|-------|---------------------|
| Shared, file memory | `calls/` in the agent's workspace, readable later by its file tools |
| Shared, other memory | `<storage>/calls/<agent>/` |
| Requester-private, file memory | `calls/` in the requester's workspace |
| Requester-private, other memory | `calls/` in the requester-scoped state directory |

When the call ends, file memory stores a reference to the transcript, Mem0 stores the transcript as recallable context, and `memory: none` keeps only the transcript file.

## Server requirements

Matrix carries only call signaling; audio flows through a LiveKit SFU deployed next to the homeserver.
Your Matrix deployment needs the standard Element Call backend:

- A [LiveKit SFU](https://github.com/livekit/livekit) reachable by call participants.
- The [MatrixRTC authorization service](https://github.com/element-hq/lk-jwt-service) (`lk-jwt-service`).
- A `.well-known/matrix/client` on the Matrix server-name domain that advertises the service:

```json
{
  "org.matrix.msc4143.rtc_foci": [
    { "type": "livekit", "livekit_service_url": "https://rtc.example.org" }
  ]
}
```

On Kubernetes, the [MatrixRTC chart](https://github.com/mindroom-ai/mindroom/tree/main/cluster/k8s/matrixrtc) deploys LiveKit and the authorization service, and the [client chart](https://github.com/mindroom-ai/mindroom/tree/main/cluster/k8s/client) publishes the `.well-known` announcement and proxies `/livekit/jwt` and `/livekit/sfu` to them.
LiveKit media still needs a static public IP and open TCP and UDP media ports, which the chart README describes.
Element's [self-hosting guide](https://github.com/element-hq/element-call/blob/main/docs/self_hosting.md) covers the full setup, and [matrix-docker-ansible-deploy](https://github.com/spantaleev/matrix-docker-ansible-deploy) enables it with `matrix_rtc_enabled: true`.

The agent joins only calls started on your own MatrixRTC backend, meaning the one in `calls.livekit_service_url` or the agent homeserver's `.well-known`; calls started through a different backend are ignored.
[Managed rooms](https://docs.mindroom.chat/rooms/#room-management) already let every member join calls.
In other rooms, such as ad-hoc rooms, the power levels must let members send `org.matrix.msc3401.call.member` state events, which Element Call-capable clients set up when they create rooms.
Calls work in both encrypted and unencrypted rooms.

Synapse supports the full MatrixRTC stack, including automatic cleanup of stale call memberships.
Tuwunel works with Element Call but does not yet support delayed events ([tuwunel#178](https://github.com/matrix-construct/tuwunel/issues/178)), so memberships of crashed clients linger until they expire.

## Limitations

- Audio only: the agent neither sends nor receives video or screen shares.
- One human Matrix user per call, possibly on several devices.
- Cascaded speech services must be OpenAI-compatible transcription and speech endpoints.
- Legacy 1:1 `m.call.*` calls (non-MatrixRTC) are not supported.

# Image Messages

This page covers how agents and teams see images sent in Matrix rooms, which image formats they receive, and what happens when a model cannot accept an image.
Images follow the same rules as other attachments for captions, routing, encryption, size limits, attachment IDs, and retention; see [Attachments](https://docs.mindroom.chat/attachments/).

## Sending Images

Send an image in a room and mention the agent or team in the caption, for example `@assistant What does this diagram show?`.
No configuration is needed; the image is passed to the responder's model as vision input, so that model must support vision (for example Claude or GPT-6 Astra).
A model without vision answers through the [media fallback](#media-fallback).

Captions from bridges trigger a response when they mention the agent through Matrix mention metadata, an HTML mention pill, or plain `@name` text.

## Image Formats

MindRoom recognizes PNG, JPEG, GIF, WebP, BMP, and TIFF images from their content and sends the recognized type to the model, logging a warning when the client declared a different MIME type.
Other image formats are sent with the client's declared MIME type, and the provider may reject them.

## Media Fallback

When a provider rejects a request containing inline media (images, audio, video, or documents), MindRoom retries it once without that media.
The retry adds `[Inline media unavailable for this model]` and tells the agent the content was not inspected, so the agent can still open the file through the [`attachments` tool](https://docs.mindroom.chat/attachments/#the-attachments-tool) or explain the limitation.
If the provider error says the model does not support that kind of input, such as `does not support image input` or `is not a multimodal model`, later requests to the same model leave that media kind out from the start until MindRoom restarts.
Other rejections, such as an image the provider could not decode, drop the media from that one request only.
Once a streamed reply has produced text, tool calls, or reasoning, a later provider error is reported instead of retried without media, and provider safeguard refusals are never retried without media.

# Attachments

This page covers how agents and teams receive files, images, and videos sent in Matrix rooms, how attachment IDs work, how long attachments are kept, and how to use the `attachments` tool to view, save, and register files.
Attachment handling works automatically for agents and teams in direct messages and threads; no configuration is needed.
Image-specific behavior is on [Image Messages](https://docs.mindroom.chat/images/), and audio messages are transcribed as described on [Voice Messages](https://docs.mindroom.chat/voice/).

## Sending Files to an Agent

Send a file, image, or video in a Matrix room and mention the agent or team in the caption:

- **With caption**: `@assistant Summarize this document` uses the caption as the prompt.
- **Without caption**: the responder receives `[Attached file]`, `[Attached image]`, or `[Attached video]` as the prompt.

A media event's `body` counts as a caption only when the event also has a `filename` field with a different value, following [MSC2530](https://github.com/matrix-org/matrix-spec-proposals/pull/2530), so a body that only repeats the filename is not a caption.

The responder answers only when it would answer a text message there (mention, thread participation, or DM).
Without an `@mention`, the router uses the caption to choose among several eligible agents or teams.
Both unencrypted and end-to-end encrypted media are supported.

The responder's model receives the media directly, so it must support that input type (vision for images, file or video input for documents and videos).
Models without that support can still work with the file through the [`attachments` tool](#the-attachments-tool).
When a provider rejects inline media, MindRoom retries without it and tells the agent the content was not inspected; see [Media Fallback](https://docs.mindroom.chat/images/#media-fallback).
Some provider integrations never send certain media kinds, so MindRoom leaves those out from the start with the same note.
Only models using the `google` provider receive video, Claude and OpenAI Responses models (including Codex) also receive no audio, Ollama and Groq models receive only images, and Cerebras models receive no media.

## Attachment IDs

Each received or registered file gets an attachment ID such as `att_abc123`.
Attachments sent with the current message are listed in the prompt with their kind, filename, sender, send time, and event ID:

```
Attachments sent with the current message (use tool calls to inspect or process them by ID):
- att_abc123 (image, "car.jpg", from @user:example.org, sent 2026-06-06 09:00 UTC, event $abc)
```

Only current-message attachments are sent to the model as media.
Earlier attachments appear in the conversation history as annotations, and the agent can open them again by ID with the `attachments` tool:

```text
@user:example.org: check this out
[attachments: att_def456 (image, "house.jpg")]
```

Attachment IDs are scoped to their room and thread, so an ID from one conversation cannot be used in another.
Within a conversation, an authorized response can open files uploaded by any participant.
When a message's file is replaced by an edit, later turns use the new file under a new attachment ID.

## The `attachments` Tool

The optional `attachments` tool lets an agent list, view, save, and register files in the current conversation, including with models that cannot take file input directly.
Add it to the agent's tools; it has no configuration fields, and enabling [`matrix_message`](https://docs.mindroom.chat/tools/matrix-message/) also enables it:

```yaml
agents:
  assistant:
    tools:
      - attachments
```

| Function | What it does |
|----------|--------------|
| `list_attachments(target=None)` | List the attachment IDs available in the current context with their metadata, plus any `missing_attachment_ids`; `target` returns one attachment |
| `get_attachment(attachment_id, mindroom_output_path=None, view=False)` | Return metadata, save the file into the workspace, or show its content to the model |
| `view_file(path=None, attachment_id=None)` | Show an image from the workspace or an attachment to the model |
| `register_attachment(file_path)` | Turn a local file into a new attachment ID for this conversation |

```python
list_attachments()
get_attachment("att_abc123")
get_attachment("att_abc123", view=True)
get_attachment("att_abc123", mindroom_output_path="incoming/plan.pdf")
view_file(path="plots/result.png")
register_attachment("exports/report.csv")
matrix_message(message="Here is the report.", attachments=["att_abc123", "exports/report.csv"])
```

Attachment arguments must be `att_*` IDs available in the current conversation or registered earlier in the same tool run.
Metadata includes the kind, filename, MIME type, size, room ID, thread ID, sender, timestamps, the runtime-local path, and an `available` flag that reports whether the file still exists.
To post files into Matrix, pass attachment IDs or file paths to `matrix_message`; see [`matrix_message` attachments](https://docs.mindroom.chat/tools/matrix-message/#attachments).

### Viewing Content

`get_attachment(attachment_id, view=True)` sends an image, audio, video, or document (including PDF and plain text) to the model, up to 20 MiB.
The model and its provider must support that media type and may have stricter limits.
`view=True` cannot be combined with `mindroom_output_path`.
If the model cannot take the media, the agent is told so and can save the file and use other extraction or transcription tools instead.

`view_file` shows one image to the calling model in one call; supply exactly one of `path` or `attachment_id`, and use `read_file` for ordinary text and code.
Workspace paths resolve inside the agent's worker when it has one, otherwise inside its workspace.
It accepts PNG, JPEG, GIF, and WebP images up to 20 MiB and 40 million pixels.
Images larger than 2048 pixels on the longest edge or 5 MiB are scaled down, animated images show only the first frame, and the result metadata reports these changes.
Image viewing through `get_attachment(view=True)` follows the same limits.
Viewing does not post anything to Matrix; to share the image, pass the returned attachment ID to `matrix_message`.
That ID works within the same tool run; in later turns, view the original path again.
Conversation history keeps up to four recently viewed images within 10 MiB in total, and older ones are replaced by a notice.

### Saving Files to the Workspace

`get_attachment(attachment_id, mindroom_output_path="incoming/file.ext")` saves the file into the agent workspace and returns a receipt with the path, byte count, and SHA-256.
Save attachments this way before processing them with `file`, `coding`, `python`, or `shell` on a worker, because the runtime-local path in the metadata does not exist inside the worker.
The path must be a workspace-relative file path; it cannot be absolute, contain `..`, start with `~`, or contain `$` or `%`.
Saves into a worker are limited by [`MINDROOM_ATTACHMENT_INLINE_SAVE_MAX_BYTES`](https://docs.mindroom.chat/deployment/sandbox-proxy/#environment-variable-reference) (default 16 MiB).
See [Sandbox Proxy](https://docs.mindroom.chat/deployment/sandbox-proxy/) for how the workspace appears as `$MINDROOM_AGENT_WORKSPACE` and `~` inside worker tools.

### Registering Local Files

`register_attachment(file_path)` copies a local file into attachment storage and returns a new attachment ID for the current room and thread, usable by later calls in the same run.
Later changes to the source file do not change the attachment.
Registered files are limited to 64 MiB.
Paths, including the targets of symbolic links, follow the agent's [`file_access`](https://docs.mindroom.chat/architecture/security-posture/#file-access) setting:

- With the default `workspace`, paths resolve from the agent workspace and must stay inside it, so pass workspace-relative paths such as `incoming/file.txt`.
  `~` expands to the MindRoom process home, not the worker workspace, so `~/incoming/file.txt` is rejected.
  Without a configured workspace, path registration is refused.
- With `unrestricted`, any file the MindRoom process can read is accepted, including absolute paths.

## Retention

Attachment bytes are stored under `incoming_media/` and metadata under `attachments/` in the storage directory (`mindroom_data/` by default).
MindRoom prunes attachments older than 30 days; deletion is not immediate at the 30-day mark.
Pruning never deletes workspace files, the source files of registered attachments, or copies on the Matrix homeserver.

## Limits

- **Incoming media size**: received files must be at most 64 MiB (67,108,864 bytes), including after decryption; send a smaller file if one is rejected.
  Homeservers and model providers may set lower limits.
- **Earlier media per turn**: a turn fetches at most 10 earlier files, images, or videos that are not stored yet, newest first, and leaves the rest for later turns.
  An earlier file that fails to download is not tried again for an hour.

# Interactive Q&A

Agents can ask users a multiple-choice question that they answer by clicking an emoji reaction or typing the option number.
Use this when an agent needs the user to pick between a few concrete options before it continues.

<video controls playsinline preload="metadata" aria-label="The agent asks a multiple-choice question before it decides" style="width: 100%">
  <source src="https://github.com/user-attachments/assets/14da1dfa-9892-4ce8-a2ed-ad67673ea4b5#t=0.1" type="video/mp4" media="(prefers-color-scheme: dark)">
  <source src="https://github.com/user-attachments/assets/9cef359e-15d0-4f0a-810a-ec34811cf561#t=0.1" type="video/mp4">
</video>

## Asking a Question

Any agent can ask a question without extra tools or configuration by including an `interactive` code block with JSON in its response:

````markdown
```interactive
{
    "question": "What approach would you prefer?",
    "options": [
        {"emoji": "🚀", "label": "Fast and automated", "value": "fast"},
        {"emoji": "🔍", "label": "Careful and manual", "value": "careful"}
    ]
}
```
````

MindRoom replaces the block with a numbered list and adds each option's emoji as a reaction button:

```
What approach would you prefer?

1. 🚀 Fast and automated
2. 🔍 Careful and manual

React with an emoji or type the number to respond.
```

While a response streams, the question appears once its code block is complete.

To make an agent use questions, describe the format in its `instructions` or `role`:

```yaml
agents:
  assistant:
    display_name: Assistant
    role: A helpful assistant
    instructions:
      - >
        When the user needs to choose between options, present them using
        an interactive code block with JSON containing question and options
        (each with emoji, label, and value fields).
```

### Fields

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `question` | string | No | Question text shown above the options. Defaults to `"Please choose an option:"`. |
| `options` | array | Yes | Option objects; options beyond the fifth are dropped without warning. |
| `options[].emoji` | string | No | Reaction button for the option. Defaults to `"❓"`. |
| `options[].label` | string | No | Text shown for the option. Defaults to `"Option"`. |
| `options[].value` | string | No | Value passed back to the agent. Defaults to the label in lowercase. |

Give every option a different emoji; reacting with an emoji that several options share, including the default `❓`, selects the last of them, so the others can only be chosen by number.

## Answering a Question

Users answer where the question was asked, in its thread or in the main room, in either of two ways:

- **Reaction**: click one of the option emojis on the question.
- **Text**: send just the option number, a single digit from `1` to `5`.
  When several questions there are unanswered, a number answers the oldest question from each agent that asked one; react to answer a specific question.

Either way, the agent that asked receives the question text and the selected option's label and value, and continues the conversation there.
Each question accepts one answer, and questions stay answerable across restarts.
Only human users can answer; reactions and messages from agents are ignored.

## Limitations

- Only the first valid question in a response gets reaction buttons and numeric answers; later ones are shown as plain text.
- A very large question, roughly a few thousand characters of question text, option labels, and option values, is shown without reaction buttons and cannot be answered by number; shorten the question, labels, or values to keep it answerable.
- Questions only work in an agent's normal responses; sending or editing one with the `matrix_message` tool fails with `Interactive prompts are only supported in normal agent responses.`

# Interactive Canvases

An agent can show the user a web page it wrote, such as a dashboard, report, slide deck, form, or picker, in the Canvas panel beside the conversation in MindRoom Chat.
The user can work in the page and send an answer, which reaches the agent as the user's next message.
Use a canvas when seeing or clicking beats reading or typing; for a quick choice between a few options, a plain reply or an [interactive question](https://docs.mindroom.chat/interactive/) is simpler.

=== "Overview · 27 s"

    A narrated film of all four: a scheduling grid, a data fit, a slide deck, and a trip planner.

    <video class="only-light" controls playsinline preload="none" poster="https://github.com/user-attachments/assets/77e36a87-9fec-41f9-b626-73b950003845" aria-label="Canvas film: four agent canvases beside the conversation" style="width: 100%">
      <source src="https://github.com/user-attachments/assets/381f7395-4f1b-4167-8749-7f85080da8ce" type="video/mp4">
    </video>
    <video class="only-dark" controls playsinline preload="none" poster="https://github.com/user-attachments/assets/d2b7a4a6-0723-4d22-af2a-6b2ee7213602" aria-label="Canvas film: four agent canvases beside the conversation" style="width: 100%">
      <source src="https://github.com/user-attachments/assets/1a820f71-dd19-415f-9301-611310c3d510" type="video/mp4">
    </video>

=== "Scheduling"

    A week grid of four people's availability; the pick goes back with **Send**, and the agent books the meeting after approval.

    <video controls playsinline preload="metadata" aria-label="An agent shows a week grid; the user picks a slot and sends it, and the agent books it" style="width: 100%">
      <source src="https://github.com/user-attachments/assets/12aed795-793e-4de2-96fa-979f04d9545f#t=0.1" type="video/mp4" media="(prefers-color-scheme: dark)">
      <source src="https://github.com/user-attachments/assets/8fcc4a9d-73f6-4fd6-9b13-c5f456397cbc#t=0.1" type="video/mp4">
    </video>

=== "Data"

    An interactive fit drawn in inline SVG with the theme variables; leaving out an outlier refits the curve live.

    <video controls playsinline preload="metadata" aria-label="An agent shows a data fit; the user leaves out an outlier and sends back the fit" style="width: 100%">
      <source src="https://github.com/user-attachments/assets/28b4b4a2-fc73-4521-b93e-ff6dd8b35aa5#t=0.1" type="video/mp4" media="(prefers-color-scheme: dark)">
      <source src="https://github.com/user-attachments/assets/6abc66b1-73ae-4692-9c7c-0bdb986bac73#t=0.1" type="video/mp4">
    </video>

=== "Slides"

    A deck the agent writes to a workspace file and shows by `path`; after a change request it rewrites the file and the open deck updates in place.

    <video controls playsinline preload="metadata" aria-label="An agent shows a slide deck from its workspace and updates it in place after a change request" style="width: 100%">
      <source src="https://github.com/user-attachments/assets/15d64a9a-cf64-4f60-89ea-9249ea35bee0#t=0.1" type="video/mp4" media="(prefers-color-scheme: dark)">
      <source src="https://github.com/user-attachments/assets/bfe89d42-9d05-4554-b0dc-d731d73f9da3#t=0.1" type="video/mp4">
    </video>

=== "Trip planner"

    One canvas, two steps: pick a stay, then plan the days against a live budget, updated in place with `canvas_event_id`.

    <video controls playsinline preload="metadata" aria-label="One canvas, two steps: pick a stay, then plan the days against a live budget" style="width: 100%">
      <source src="https://github.com/user-attachments/assets/02f40dbd-6e39-463a-831d-d941ef32ecf9#t=0.1" type="video/mp4" media="(prefers-color-scheme: dark)">
      <source src="https://github.com/user-attachments/assets/e9c04cf1-74c2-48de-866b-b43ac529146d#t=0.1" type="video/mp4">
    </video>

## Turn canvases on

Canvases need two switches, and both are off by default.

First, give the agent the [`chat_ui`](https://docs.mindroom.chat/tools/chat-ui/) toolkit with `enable_show_canvas: true`:

```yaml
agents:
  planner:
    display_name: Planner
    role: Plan trips and compare the options visually.
    model: default
    tools:
      - chat_ui:
          enable_show_canvas: true
```

`enable_show_canvas` (boolean, default `false`) adds the `show_canvas` function; the rest of `chat_ui` works without it.
It can also be set in the `chat_ui` tool's saved settings for every agent that uses `chat_ui`; an agent's own entry wins, as described in [How Settings Combine](https://docs.mindroom.chat/tools/#how-settings-combine).

Second, enable canvases in the MindRoom Chat deployment's runtime `config.json`:

```json
{
  "mindroom": {
    "canvas": {
      "enabled": true
    }
  }
}
```

The shipped MindRoom Chat configuration sets `enabled` to `false`, so only whoever deploys Chat can turn canvases on.

## What the user sees

Each canvas appears in the conversation as a notice with an **Open panel** button.
Chat opens the panel by itself under the same conditions as other [Chat UI actions](https://docs.mindroom.chat/tools/chat-ui/#what-the-user-sees); otherwise the user chooses **Open panel**.
The panel names the agent that made the page, and the user can resize it or expand it to the full width of the conversation.

When the panel cannot open, the **Open panel** button is disabled and a message below it says why:

| Message | Cause |
|---|---|
| Interactive panels are turned off in this app. | The Chat deployment has not enabled canvases. |
| Interactive panels are not available in the app yet. Open this conversation in a browser. | The native mobile app does not show canvases; Chat in a browser does. |
| End the call to open interactive panels. | Chat does not run canvases during a call. |

Other people in the room, and other Matrix clients, see only the notice text, `Interactive panel: <title>. Open it in MindRoom Chat to respond.`

## Answering a canvas

Typing, toggling, and moving between steps inside the page send nothing.
When the page submits an answer, Chat shows the user exactly what will be sent, and nothing is sent until the user chooses **Send**.
To change a pending answer, the user chooses **Discard** and answers again.

The answer is an ordinary message from the user in the canvas's thread; it mentions the agent and starts the agent's next turn:

```text
@mindroom_planner:example.org Canvas response ($canvas-event, revision $canvas-event): Pro plan
{"plan":"pro"}
```

The first ID is the canvas and the second is the version the user answered; when that is not the latest version, the user answered an earlier page.
MindRoom Chat shows the answer as a one-line receipt that expands to the exact data.
An answer can carry up to 512 KB of JSON data, enough for a long document the user edited in the canvas; larger or non-JSON data is ignored, so no **Send** prompt appears.

## Update a canvas

`show_canvas` returns the canvas `event_id`.
Calling `show_canvas` again with `canvas_event_id` set to that ID replaces the page in place, for the next step of a flow or a new version of a file the agent edited, and the conversation keeps one card.
An open panel switches to the new page at once, unless the user has been working in it; then Chat shows "*agent* updated this panel." with a **Load update** button, so unsent work is not lost.
An agent can update only canvases it showed to the same user in the same room and thread, or at room level; otherwise the call fails and the agent shows a new canvas instead.

## Write a page

Pass the page as `html`, or as a `path` to an HTML file, which suits pages the agent builds and refines, such as slides.
Paths follow the same rules as [registering local files](https://docs.mindroom.chat/attachments/#registering-local-files); prefer workspace-relative paths such as `slides/deck.html`.

```python
chat_ui.show_canvas(
    title="Choose a plan",
    html="""
<style>
  .plans { display: grid; gap: 12px; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); }
  .plan { background: var(--mr-surface); border: 1px solid var(--mr-border);
          border-radius: var(--mr-radius); padding: 16px; }
  button { background: var(--mr-accent); color: var(--mr-accent-text); border: 0;
           border-radius: 8px; padding: 8px 14px; cursor: pointer; }
</style>
<div class="plans">
  <div class="plan"><h3>Basic</h3><p>For trying things out.</p>
    <button onclick="mindroom.submit({plan: 'basic'}, {label: 'Basic plan'})">Choose Basic</button></div>
  <div class="plan"><h3>Pro</h3><p>For daily work.</p>
    <button onclick="mindroom.submit({plan: 'pro'}, {label: 'Pro plan'})">Choose Pro</button></div>
</div>
""",
)
```

- **Self-contained:** write inline CSS and JavaScript. The page cannot load or send anything over the network, so draw charts with inline SVG or a `<canvas>` element and embed images as `data:` URLs.
- **Submitting:** call `window.mindroom.submit(data, {label})` with JSON-serializable `data` and a short `label`, or use a `<form>`, whose fields become the data and whose `data-mindroom-label` attribute sets the label.
- **Responsive:** the panel ranges from narrow to the full conversation width.
- **No saved state:** the page starts fresh every time it loads, so anything the user must keep belongs in the answer.
- **Limits:** the `title` is one line of at most 120 characters, and the page is UTF-8 text of at most 4 MB.
  Each update of a page larger than about 24 KB stores a new copy on the homeserver that users cannot delete, so iterate on large pages sparingly.

Chat exposes its current theme as CSS variables, so a page can match light and dark mode:

| Variable | Use |
|---|---|
| `--mr-bg` | Page background (the default body background) |
| `--mr-surface`, `--mr-surface-raised` | Cards and raised elements |
| `--mr-border` | Borders and dividers |
| `--mr-text`, `--mr-text-muted` | Text (the default body color) and secondary text |
| `--mr-accent`, `--mr-accent-text` | Primary actions and the text on them |
| `--mr-success`, `--mr-warning`, `--mr-danger` | Status colors |
| `--mr-radius`, `--mr-font` | Corner radius and font family |

## Privacy and safety

- The page cannot read the user's Matrix account, messages, cookies, or storage, and it cannot open pop-ups or navigate away; a page that tries is stopped, and Chat shows "This panel tried to leave its sandbox and was stopped." with a **Reload panel** button.
- In an encrypted room, the page and the answer are end-to-end encrypted like other messages.
- A canvas is not a safe place for secrets: browsers do not let a page block WebRTC, so a malicious page could leak what the user types into it.
  Chat therefore names the agent that made each canvas and reminds the user that what they enter may leave the panel.
- Agents should show only pages they wrote, never downloaded or untrusted pages, because every canvas appears as the agent's own.

# Thread Exports

Thread exports write Matrix conversation history to YAML files that agents and operators can search with ordinary file tools.
Only Matrix threads are exported; plain room messages outside threads, including conversations with agents using `thread_mode: room`, are not.
Set `thread_exports` on an agent to keep a continuously updated copy inside its workspace, so its `file` and `shell` tools can grep past threads without Matrix access.
Use [`mindroom threads export`](#threads-export) for a one-shot or watched export to any directory.

```yaml
agents:
  code:
    thread_exports: true            # defaults below
  research:
    thread_exports:
      invited_rooms: false          # config rooms only
      private_room_scope: owner     # private agents only
```

| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `invited_rooms` | bool | `true` | Also export user-created rooms the agent joined through invites. Current membership is always required |
| `private_room_scope` | `owner_and_agent` or `owner` | `owner_and_agent` | Private agents only. Both values require the requester to be joined to an exported room; `owner_and_agent` also checks the agent's membership. The agent must be joined either way to read the room |

## Export Layout and Contents

Workspace exports land at `<storage_root>/agents/<agent>/workspace/thread_exports/<urlencoded room key>/<urlencoded thread id>.yaml`, which agent tools see as `$MINDROOM_AGENT_WORKSPACE/thread_exports/`.
`mindroom threads export` writes the same layout under its output directory.
Each thread file holds `version`, `room` metadata, `thread` metadata including the latest thread summary as `thread.summary`, and a `messages` list.
Exported messages match the history agents see in their prompts, including edits, redactions, and long messages.
Each room directory also holds an `index.json` mapping every thread file to its message count, participants, latest summary, and last activity, sorted by most recent activity.
A thread file is rewritten only when its content changed, so `exported_at` reflects the last content-changing export.

Size limits:

- A thread file larger than 64 MiB or holding more than 250,000 YAML nodes, roughly 15,000 messages, is indexed without participants or last activity.
- A thread whose messages together pass 128 MiB is not exported; the export reports it as failed and keeps any previous file for it.
- A room whose thread files together pass 256 MiB gets an index of its most recently written threads only, listing the rest under `unindexed_files`, with a logged warning.

## When Workspace Exports Update

MindRoom re-exports a room within about two seconds of a message, edit, redaction, or membership change in it, and runs a full pass at startup and after every config reload.
A full pass removes exports for threads and rooms that no longer exist or that the agent may no longer read, and clears the export tree of any agent whose `thread_exports` was removed.
Agents may edit or delete their exported files; deleted files return on the next pass that touches the room.

If a pass exports no room at all, MindRoom keeps exports of rooms it did not reach, and if a room returns zero threads, it keeps that room's thread files; both cases log a warning.
Rooms excluded by config, such as invited rooms after `invited_rooms: false`, and rooms the agent or requester has left are still removed.
After such a warning, verify the source state and remove the preserved export manually only when the deletion is confirmed.

## Security and Retention

Shared agents export only rooms where the agent's own Matrix account is currently joined.
Private agents (`private:`) get one export tree per instance under `<storage_root>/private_instances/<scope-key>/<agent>/<private root>/thread_exports/`, limited to the agent's configured and invited rooms and to the requester's current room memberships, so one requester's workspace never holds other users' conversations.
A private instance is exported only after MindRoom has created or used it for its requester.

Thread export settings control what future passes write.
Cleanup is not a revocation or data-erasure boundary: previously exported data may remain, or may already have been copied or committed by the agent.

## Semantic Search Over Exports

With an embedder configured (`memory.embedder`), point a knowledge base at the export directory for semantic search through `search_knowledge_base`.

```yaml
knowledge_bases:
  code_threads:
    path: ./mindroom_data/agents/code/workspace/thread_exports
    description: Exported Matrix conversation history for the code agent
    exclude_patterns: ["*/index.json"]
agents:
  code:
    thread_exports: true
    knowledge_bases: [code_threads]
```

For a private agent, index the private-root-relative path instead:

```yaml
agents:
  secret:
    thread_exports: true
    private:
      per: user
      knowledge:
        path: thread_exports
        description: Your exported conversation history
```

The active thread's file is rewritten on every message, so a watching index re-embeds that thread per message.
This is negligible with a local embedder but costs real money with paid embedding APIs in busy rooms.

## threads export

`mindroom threads export` exports Matrix threads to YAML files through a running MindRoom instance; options are listed in the [CLI reference](https://docs.mindroom.chat/cli/#threads-export).
Keep MindRoom running with its API enabled; there is no offline export mode or separate Matrix login.
The CLI calls `--url`, then `MINDROOM_URL` from the selected runtime environment, then `http://127.0.0.1:8765`.
Set `MINDROOM_API_KEY` when API authentication is enabled; hosted deployments require an authorized bearer token.
With `MINDROOM_API_KEY` set, the CLI refuses a remote `http://` URL with `Use HTTPS when sending MINDROOM_API_KEY to a remote endpoint.`; HTTPS and loopback HTTP work.
The selected `--config` and `--storage-path` must match the running installation, and output paths refer to that runtime's filesystem.
Output defaults to `<storage>/thread_exports`.
Rooms joined through authorized invites are exported with the invited entity's own account unless `--no-invited-rooms` is passed.

A complete pass removes exported room and thread files that are no longer present or authorized; a `--room` pass only cleans up the selected room.
The same zero-room and zero-thread safeguards as [workspace exports](#when-workspace-exports-update) apply.
An interrupted pass keeps completed files; rerun the export to finish it and rebuild indexes.

### Output directory rules

MindRoom claims an empty output directory by writing a `.mindroom-thread-exports` marker file.
A populated directory without that marker is refused, skipped for the whole pass, and reported as a target failure.
To use an existing populated directory, create `.mindroom-thread-exports` inside it containing exactly `{"format":"mindroom-thread-exports","version":1}` followed by a newline.
Cleanup removes only exported room directories and thread files; unrelated entries such as `.DS_Store`, a `.git` directory, or your own notes are never deleted.
Output paths ending in `.`, `..`, or an empty name are rejected, as are symlinked output and room directories.

# Bug Reports

When something goes wrong in a conversation, such as an agent stopping halfway, a reply never arriving, or a tool failing, the person who fixes it needs to know exactly what happened.
Collecting that by hand (screenshots, room and message IDs, app diagnostics, server logs) is slow, and users rarely know what to include.
Bug reports make it one click for the user and give the administrator both sides of the story: what MindRoom Chat saw and what the MindRoom backend did.

This is meant for deployments with an administrator who can already read everything, such as a company or family install.
Users keep their privacy from each other: a report is visible only to the person who sent it and the administrators at the time it was sent, and an administrator removed later keeps the reports they already received.

## How it works

1. In MindRoom Chat, the user opens the menu on any message and chooses **Report a bug**.
2. MindRoom Chat collects the evidence and sends it to a private room shared only with the administrators, then opens that room so the user can add details.
3. The administrators' MindRoom Chat joins that room automatically, so nobody has to accept an invite.
4. The administrator runs [`mindroom debug-report`](https://docs.mindroom.chat/cli/#debug-report) on the attached file to collect the backend side of the same moment.
5. Both files go to whoever fixes the issue, for example a coding agent.

## Enable it

Add the administrators to the homeserver's `/.well-known/matrix/client` file:

```json
{
  "m.homeserver": { "base_url": "https://matrix.example.com" },
  "io.mindroom.bug_reports": { "admins": ["@admin:example.com"] }
}
```

Nothing changes in `config.yaml`.
Without this key, the menu item is called **Download bug report** and saves the report as a file instead.

## What a report contains

- The room, thread, and message it was sent from, with a link.
- The conversation as MindRoom Chat holds it: the thread root and its newest 200 replies, or the last 50 messages when the message is not in a thread, each with its original content, latest edit, and send status.
- App details such as version, platform, browser, and connection state.
- The same data as **Export diagnostics** under **Settings → About**.

Nothing is redacted, and reports from encrypted rooms are stored decrypted in the report room.

## Where reports go

- Each user gets one private room named `Bug reports · <name>`, and each report is a thread in it, so follow-up questions stay next to the report.
- The room is unencrypted so administrator tools such as `matrix-mcp` can read it.
- Administrators are joined automatically only when they run MindRoom Chat and the invite is for a private report room created by someone on their own homeserver; other accounts, such as bots, accept the invite themselves.
- When an administrator is removed from the list, the next report goes to a new room; reports they already received stay with them.

## Collect the backend side

The report carries the IDs the backend uses, so one command on the MindRoom host gathers the agent runs, tool calls, model requests, deliveries, and log lines for that conversation:

```bash
mindroom debug-report mindroom-bug-report-<time>.json --output backend-report.json
```

Enable `debug.log_llm_requests` in advance if you also want full model requests and successful tool calls in it.
See [`mindroom debug-report`](https://docs.mindroom.chat/cli/#debug-report) for every option and data source.

# Usage Tracking

MindRoom records content-free token usage for agent and team replies and the [helper and internal AI work](#token-usage) listed below, plus call duration for GPT-Live voice calls.
View it in the dashboard [Usage tab](#dashboard-usage-tab), fetch it over [HTTP](#usage-report-api) for reporting or cost tools, or let agents read it with the [`usage_stats`](#usage_stats) tool.
Reports show retained usage, not estimated spend or a billing ledger.

## Dashboard Usage Tab

The **Usage** tab shows organization-wide token usage from the [usage report](#usage-report-api) and uses normal dashboard authentication.

- All-time recorded totals for input, output, and cache tokens
- Daily activity with a UTC date range and an expandable data table
- Searchable agent/team, model, and requester breakdowns, sorted by the selected token counter
- Agent/team detail with requesters, cumulative models, and daily activity
- Requester detail across agents, models, and daily activity, including usage of shared agents
- Model detail across agents, requesters, and daily activity, keeping providers separate

The token selector also offers reasoning and audio counters when the provider reports them.
Select a row in any breakdown to open its detail panel.
Date ranges apply only to daily activity; agent and model totals stay cumulative.
Daily and requester detail can be lower than cumulative totals when older attribution is missing, and undated runs appear only in totals.
A run that used several models counts once in each model's run count, so do not add model run counts together.
Cache tokens may already be included in input counts, depending on the provider.

The tab waits automatically while a report is prepared.
**Refresh** requests the latest report; completed reports can be up to one minute old.

## Token Usage

This section describes what every usage view counts.

**Counters.** Each total has nine counters: `input_tokens`, `output_tokens`, `total_tokens`, `cache_read_tokens`, `cache_write_tokens`, `reasoning_tokens`, `audio_input_tokens`, `audio_output_tokens`, and `audio_total_tokens`.
Missing counters are reported as zero, so a zero does not prove a provider used none of that category.
Models are grouped by provider and model; usage that cannot be attributed to a model appears under model `unknown`.

**Runs.** `run_count` counts retained top-level AI replies with usable token metrics, not messages or conversations.
Team member tokens count toward model, requester, daily, and request detail without adding to `run_count`, and a team's session totals already include its members.
Helper work adds tokens without adding runs, including retries and rejected outputs:

| Request `kind` | Work |
| --- | --- |
| `run` | Ordinary agent or team reply |
| `compaction_summary` | Conversation compaction summaries |
| `memory_auto_flush` | Background memory auto-flush extraction |
| `dynamic_workflow` | Embedded Dynamic Workflow participants |
| `skill_learning` | Automatic [skill reviews](https://docs.mindroom.chat/skills/) |
| `routing`, `room_topic`, `schedule_parse`, `thread_summary`, `voice_normalization`, `voice_transcription` | Internal AI work, reported under the entity `system:internal` |

Helpers are attributed to the conversation and requester that triggered them, and stay unattributed when no requester is known.
`system:internal` usage has no human requester, appears only in organization reports, and transcription is counted only when the provider reports tokens.
Embedding calls and calls to separately configured judgment backends, such as participation or mid-turn judges, are not counted.

**Requesters.** Usage is grouped by canonical Matrix user ID, with known aliases combined; `user_id: null` holds unattributed usage.
Shared and private instances of the same agent are combined in entity rows; `private_agent_breakdown` shows the private part separately.

**Retention.** Recorded usage survives compaction, message edits, and regeneration, and a regenerated reply counts as a separate run.
Erasing a whole session removes its usage; deleted sessions are unavailable.
Reports cover only agents in the current configuration, so removing an agent drops its usage from reports even though its sessions stay stored; removed and ad hoc teams stay included.
Usage from session history that predates usage tracking is imported once at startup; history already lost cannot be reconstructed, and old usage is never given invented dates or requesters.

**Coverage.** Each breakdown has a matching coverage object (`coverage`, `model_coverage`, `user_coverage`, `cumulative_model_coverage`, `daily_coverage`, `request_coverage`, `voice_coverage`, `private_agent_coverage`) with `scanned_sources`, `unavailable_sources`, and an explanatory `note`.
`unavailable_sources` counts sources with missing, unreadable, or incomplete data; it is not a missing-token percentage.
Because of these gaps, model, user, and daily rows do not necessarily sum to the top-level totals.
Totals support cost estimates, but provider-specific charging rules and calls outside retained storage are not captured.

## Usage Report API

`GET /api/usage` returns the organization-wide report under standard dashboard authentication, like other administrator APIs.
A collector service can fetch the same report from `GET /api/usage/export` with its own credential; see [Usage Export Service](#usage-export-service).

Both routes accept two optional query parameters, each defaulting to `false`:

| Parameter | Adds |
| --- | --- |
| `include_daily=true` | `daily_breakdown` and `daily_coverage`, plus a `daily_breakdown` inside every requester and private-agent row |
| `include_requests=true` | `request_breakdown` and `request_coverage` |

### Polling for a Report

The first request for a report returns `202` with `{"status":"pending"}` and `Retry-After: 5`.
Poll the same URL with the same parameters after that delay; once ready, the route returns `200` with the report.
Completed reports are cached for 60 seconds, and configuration changes discard cached reports.
A `503` means the report could not be prepared, for example because the committed configuration is unavailable; retry after a few seconds.
All responses use `Cache-Control: no-store` and contain no conversation content.

### Report Fields

Every completed report includes `schema_version: 1` and `generated_at`, the UTC ISO 8601 time the scan finished; cached responses keep that time.

| Field | Contents |
| --- | --- |
| `totals`, `session_count` | All retained usage |
| `breakdown` | One row per agent or team |
| `model_breakdown` | Retained runs by provider and model, with `run_count` |
| `cumulative_model_breakdown` | Session totals by provider and model, with `session_count` |
| `user_breakdown` | Per requester: `user_id`, `totals`, `run_count`, and its own `model_breakdown` |
| `private_agent_breakdown` | Private-agent usage per owner and agent; see [Private-Agent Rows](#private-agent-rows) |
| `voice_breakdown` | GPT-Live call duration; see [Voice Call Duration](#voice-call-duration) |

Each `breakdown` row has session `totals`, `session_count`, and `cumulative_model_breakdown`, plus `retained_run_totals`, `run_count`, and a `user_breakdown` showing which requesters used that agent or team.
Requester totals sum to `retained_run_totals`, which can differ from the cumulative session `totals`.

`cumulative_model_breakdown` comes from per-model totals stored with each session, so it includes compacted history still in retained sessions but has no dates or requesters.
A session using several models counts once in each model's `session_count`.

### Daily Rows

Each daily row has a UTC `date` (`YYYY-MM-DD`), `totals`, `run_count`, and a `model_breakdown`, sorted oldest first.
Dates come from individual request timestamps, falling back to the run's creation date when request detail is missing, which can shift usage across days.
Each run counts once on its first request date, so a later day can show tokens with `run_count: 0`.
Usage with no usable date is left out of daily rows but stays in all-time totals, so a user with only undated runs has an empty `daily_breakdown`.

### Request Rows

`include_requests=true` is available only on the two organization routes.
Each request row has `entity`, `user_id`, `provider`, `model`, `kind`, epoch-seconds `created_at`, and all nine counters in `totals`, sorted by time.
Rows keep individual provider calls apart so a consumer can apply per-request pricing, such as context-length tiers.
Rows contain no prices, prompts, responses, session IDs, or run IDs.
Only requests whose counters match their run's recorded totals are exported; missing detail marks `request_coverage` incomplete while aggregate totals stay available.

### Voice Call Duration

Each `voice_breakdown` row is one GPT-Live provider session, with `entity`, `user_id`, `provider`, `model`, epoch-seconds `created_at`, `duration_seconds`, and `finalized`.
Reconnecting starts a new row, even within the same call, and duration is not split across UTC days.
`finalized: false` means the duration is the last running total the provider reported, for example after a dropped connection.
Sum `duration_seconds` to group voice use by caller, agent, or model, and price it separately from the tokens of agents the call delegated to.
Voice duration does not add to token totals, request rows, or run counts, and rows contain no audio, transcripts, or session IDs.

### Private-Agent Rows

Each `private_agent_breakdown` row has `agent_name`, `user_id`, session `totals`, `session_count`, `cumulative_model_breakdown`, `retained_run_totals`, `run_count`, and `model_breakdown`.
Session totals are attributed to the private instance's owner, while retained runs keep the user who made the request.
Session totals can include older compacted history that has no model or daily detail.

To attribute usage by ownership instead of by requester, compute this per user and counter over the union of users in both breakdowns, summing a user's private-agent rows first and treating missing rows as zero:

```text
user_breakdown.totals - private_agent_breakdown.retained_run_totals + private_agent_breakdown.totals
```

For example, 60 retained tokens including 20 private-agent tokens, plus a private-agent session total of 50, gives `60 - 20 + 50 = 90`.
This view assigns private-instance usage to its owner: if Bob requested 20 tokens from Alice's private instance with 100 session tokens, Alice gets 100 and Bob gets none.
Keep `user_breakdown` unchanged when reporting who made requests.

### Consuming Reports

Tolerate additional response fields, and keep session totals and retained-run breakdowns distinct.
Treat each report as a snapshot; do not add successive all-time totals together.
Counter values can change between reports as coverage improves.

### Personal Private-Agent Usage

`GET /api/usage/me/private-agents` returns the signed-in user's usage across their own private agents, with the same rows as `get_my_private_usage()` but without `user_id` or `user_breakdown`.
The only accepted query parameter is `include_daily` (default `false`); any other parameter returns `400` `Usage target overrides are not accepted`.

The endpoint requires [strict JWT mode](https://docs.mindroom.chat/deployment/trusted-upstream-auth/#strict-jwt-mode) with a verified Matrix identity, like the [Connections portal](https://docs.mindroom.chat/deployment/trusted-upstream-auth/#connections-portal).
Without it, requests fail with `403` `Personal APIs require trusted signed authentication` or `Personal APIs require a verified Matrix identity`; an API key alone cannot select a user.
Any signed-in user can call it, and it grants no administrator or export access.

## Usage Export Service

Strict JWT deployments can let a collector service call `GET /api/usage/export` without giving it a browser or administrator identity.
The route returns the same report, parameters, and polling behavior as [`GET /api/usage`](#usage-report-api).

Configure [trusted-upstream strict JWT mode](https://docs.mindroom.chat/deployment/trusted-upstream-auth/#strict-jwt-mode), including `MINDROOM_TRUSTED_UPSTREAM_USER_ID_HEADER`, then add a dedicated service audience and client ID:

```bash
MINDROOM_USAGE_SERVICE_JWT_AUDIENCE=mindroom-usage-export
MINDROOM_USAGE_SERVICE_CLIENT_ID=usage-export-client.example.org
```

The service sends its assertion in the trusted-upstream JWT header on every request, including polls.
The assertion must be signed with `RS256` by the trusted-upstream issuer and JWKS, and contain valid `exp`, `iat`, `iss`, and `aud` claims, where `aud` is `MINDROOM_USAGE_SERVICE_JWT_AUDIENCE` rather than the browser audience.
It must also contain `type=app`, `common_name` exactly equal to `MINDROOM_USAGE_SERVICE_CLIENT_ID`, and an empty `sub`.

| Status | Meaning |
| --- | --- |
| `503` `Usage export service authentication is not configured` | Trusted-upstream auth, a strict-JWT setting, or a service setting is missing |
| `401` `Invalid usage service JWT` | Missing or invalid assertion |

A service assertion authorizes only `/api/usage/export`, not `/api/usage`, personal usage, configuration, or other administrator routes.

## `usage_stats`

The `usage_stats` tool lets an agent answer usage questions in chat; it is read-only.

| Function | Reports | Availability |
| --- | --- | --- |
| `get_my_usage()` | The caller's usage of the current agent; for a private agent, the caller's own instance | Always |
| `get_my_private_usage()` | Usage across all the caller's currently configured private agents | Always |
| `get_all_usage()` | Organization-wide usage of currently configured agents, all stored teams, and `system:internal` | Requires `admin_scope: true` and a caller listed in `administrators` |

Give an agent the self-service functions by adding the tool:

```yaml
agents:
  usage_assistant:
    display_name: Usage Assistant
    role: Report the caller's retained MindRoom usage
    tools:
      - usage_stats
```

Enable `get_all_usage()` with the per-agent `admin_scope` option (boolean, default `false`), which cannot be set in `defaults.tools`:

```yaml
administrators:
  - "@usage-admin:example.com"

agents:
  usage_admin:
    display_name: Usage Administrator
    role: Report retained MindRoom usage across configured entities
    tools:
      - usage_stats:
          admin_scope: true
```

All three functions accept `include_daily` (default `false`), which adds [daily rows](#daily-rows) and `daily_coverage`.

### Tool Responses

Responses are JSON with `status` and `tool` fields, plus `scope`, `totals`, `session_count`, `breakdown`, `coverage`, `model_breakdown`, and `model_coverage`.
`get_all_usage()` returns the organization report fields described in [Report Fields](#report-fields), except request rows.
Self reports leave `breakdown` empty.
`get_my_private_usage()` adds `cumulative_model_breakdown` and `private_agent_breakdown` without user IDs.
`get_my_usage()` omits cumulative model and private-agent fields and excludes the in-progress tool call.
On a shared agent it counts only the caller's retained runs, so it can miss usage after compaction.
On a private agent it reports the whole session totals, which include compacted usage and can exceed the retained model or daily detail.

Errors use the same envelope with a `code` and `message`:

| Code | Cause |
| --- | --- |
| `authorization_error` | `get_all_usage()` called without `admin_scope`, from another agent, or by a non-administrator |
| `context_unavailable` | No active requester or agent context, such as outside a conversation |

# Matrix Integration

This page covers how MindRoom appears on Matrix: the accounts it creates, mentions, end-to-end encryption, managed avatars, TLS trust, and sync tuning.
Room creation and room policy are covered in [Rooms](https://docs.mindroom.chat/rooms/), and Matrix environment variables in [Configuration](https://docs.mindroom.chat/configuration/#matrix).

## Why Matrix?

Every agent is an ordinary Matrix user, so you can reach it from any Matrix client, on any federated homeserver, in end-to-end encrypted rooms, and from Discord, Slack, Telegram, and other networks through [bridges](https://docs.mindroom.chat/deployment/bridges/).

## Agent Users

Each agent, team, and the router has its own Matrix user, created automatically at startup.
Account credentials are stored in `matrix_state.yaml` in the storage directory (default `mindroom_data/`).

In chat, address an agent or team by its configured key, such as `@assistant`.
MindRoom requests localparts such as `mindroom_assistant`, but the account recorded in `matrix_state.yaml` is authoritative, so an agent's actual Matrix ID (for example `@assistant_live:example.com`) can differ from the requested one.

`MATRIX_MANAGED_ACCOUNT_AUTH` selects how these accounts authenticate:

- `password` (default) generates a separate password for every account and uses normal Matrix registration and login.
- `appservice` registers passwordless accounts inside an exclusive application-service namespace and gives each account its own device token for encryption and sync.
  First register an application service with the homeserver whose exclusive user namespace covers every account MindRoom manages, then supply that registration's `as_token`.
  Provide exactly one of `MATRIX_APPSERVICE_TOKEN` or `MATRIX_APPSERVICE_TOKEN_FILE`; setting either one without `MATRIX_MANAGED_ACCOUNT_AUTH=appservice` stops startup.
  The application-service token is used only to register accounts and log in new devices.
  After switching an existing install to `appservice`, stored passwords are removed from `matrix_state.yaml` once each account logs in.

## Internal User

The optional `mindroom_user` section configures MindRoom's internal Matrix user account; omit it for hosted or public profiles.

```yaml
mindroom_user:
  username: mindroom_user
  display_name: MindRoomUser
```

| Field | Type | Default | Meaning |
| --- | --- | --- | --- |
| `username` | string | `mindroom_user` | Matrix localpart, without `@` or a domain, using lowercase letters, digits, `.`, `_`, `=`, `-`, or `/`; it must not match an agent, team, or router account |
| `display_name` | string | `MindRoomUser` | Display name, changeable at any time |

Set `username` before first startup.
Changing it after the account exists stops startup with `mindroom_user.username cannot be changed after the internal Matrix account has been created.`
Hosted provisioning can return a different actual Matrix ID, which MindRoom then uses.

## Mentions

In agent replies and other messages MindRoom sends, two forms become real Matrix mentions that render as clickable links and notify the agent or team:

- `@calculator`, the configured agent or team key.
- `@actual_calculator:localhost`, the agent's current full Matrix ID.

`@mindroom_calculator`, the `mindroom_` prefix plus the key, also mentions the agent's or team's current account.
Any other bare account localpart, such as `@actual_calculator`, is not a mention.
Any other valid full Matrix ID, such as a human user or an agent's old account like `@mindroom_calculator:localhost`, becomes a mention of that exact account and does not reach the agent's current account.

## End-to-End Encryption

Agents fully participate in encrypted rooms: they read encrypted text, media, and thread history, and they reply encrypted.
To encrypt managed rooms, set `encrypted: true` in their [room policy](https://docs.mindroom.chat/rooms/#room-policy).
Enabling encryption on a Matrix room is irreversible.

Each agent cross-signs its own device, so clients that only share room keys with cross-signed devices (MSC4153) keep sharing them with agents.

### `!encrypt`

Enable end-to-end encryption for the current room.

```
!encrypt
!encrypt confirm
```

`!encrypt` explains what enabling encryption means for the room without changing anything.
`!encrypt confirm` enables encryption and is restricted to Matrix room admins.
The responding bot also needs permission to change room state; if it lacks that permission, a room admin can enable encryption from their own Matrix client.
People joining an encrypted room later cannot read messages sent before they joined.

### `!e2ee`

Show encryption diagnostics for the current room.

```
!e2ee
```

The report includes the room's encryption state, the responding bot account and device, the encryption store status, cross-signing status, and decryption-failure counters since startup.
Use it when an agent seems to ignore messages in an encrypted room.

### Troubleshooting Undecryptable Messages

When an agent cannot decrypt a message from an authorized sender, it logs a `matrix_event_decryption_failed` warning and posts one notice for that message's encryption session asking the user to resend.
Only one agent posts the notice, even in multi-agent rooms.
Resending normally fixes it, because the sender's client then shares a new key with the agent.
Decryption-failure counters also appear on `/api/health` under `e2ee`.

If a bot's encryption store under `mindroom_data/encryption_keys/` is lost, that agent does not start, and `mindroom doctor` reports the missing store.
Restore the matching storage backup before restarting, as described in [Device recovery](https://docs.mindroom.chat/deployment/upgrades/#device-recovery-after-the-upgrade).

### Delivery Policy

Encrypted messages from agents are always shared with the room members' unverified devices.
MindRoom has no device-verification flow, so requiring verified devices would make every reply in an encrypted room fail.

## Managed Avatars

Every agent, team, and managed room gets a Matrix avatar by default, chosen from the painted stock avatars in the [MindRoom assets repository](https://github.com/mindroom-ai/assets/blob/main/avatars/painted/README.md).

- An agent named after a stock avatar (for example `mind`, `code`, `research`, `writer`, or `email`) uses that picture, and any other agent or team receives a stable stock avatar chosen from its name.
- A room served by exactly one agent or team shows that entity's avatar, and any other room receives a stable stock avatar chosen from its key.
- The root Matrix Space uses `avatars/spaces/root_space.png` when present and otherwise the stock `mind-logo` image.
- Avatars are set only where the Matrix profile or room has none, so pictures you set yourself are kept.
- Room avatars are chosen only when a room is created, so run `mindroom avatars sync` to fill in existing rooms.
- A failed stock avatar download is retried at the first start after 24 hours, or immediately for rooms and the root Space by `mindroom avatars sync`.

To choose a picture, place a PNG at `avatars/<agents|teams|rooms|spaces>/<name>.png` next to `config.yaml`; containerized deployments read these overrides from `<storage>/avatars/` instead.
Run `mindroom avatars sync --force` to reset every managed room and the root Space to its default avatar: its own file, its single agent's or team's avatar, or its stock pick.

`mindroom avatars generate` creates a custom avatar file for every entity without a workspace or bundled avatar file, using `gpt-6-astra` to write prompts and `gpt-image-2.5-sunburst` to render them; it requires only `OPENAI_API_KEY` or `OPENAI_API_KEY_FILE`.
Run `mindroom avatars generate --force` to overwrite existing workspace avatar files after changing prompts or styles.
Override the generation styles with the root `prompts` block:

```yaml
prompts:
  AVATAR_CHARACTER_STYLE: "professional AI avatar portrait, abstract geometric silhouette"
  AVATAR_ROOM_STYLE: "minimalist wayfinding icon, precise geometry, strong silhouette"
  AVATAR_AGENT_SYSTEM_PROMPT: "You are creating distinctive visual elements for a professional AI agent avatar."
  AVATAR_TEAM_SYSTEM_PROMPT: "You are creating distinctive visual elements for a professional AI team avatar."
  AVATAR_ROOM_SYSTEM_PROMPT: "You are creating a refined, minimalist icon design for a room avatar."
```

## TLS Trust

MindRoom trusts OpenSSL's default CA file and directory plus certifi's public roots for the homeserver.
For a homeserver behind a private CA, set `SSL_CERT_FILE` or `SSL_CERT_DIR`; each replaces the matching OpenSSL default location, and setting either one stops MindRoom from adding certifi's roots.
A homeserver certificate that fails verification stops startup with an error naming the homeserver and the verification failure.

Requests to the hosted provisioning service always verify its certificate, whatever `MATRIX_SSL_VERIFY` says.
For a provisioning service behind a private CA, set `SSL_CERT_FILE` or `SSL_CERT_DIR` in the process environment.

## Matrix Sync

```yaml
matrix_sync:
  mode: classic
  sliding_timeline_limit: 100
  max_response_bytes: 16777216
  max_pending_bytes: 67108864
```

| Field | Type | Default | Meaning |
| --- | --- | --- | --- |
| `mode` | `classic` or `sliding` | `classic` | `classic` uses `/v3/sync`; `sliding` uses MSC4186 Simplified Sliding Sync and requires a homeserver that advertises it |
| `sliding_timeline_limit` | integer ≥ 1 | `100` | Per-room timeline window for Sliding Sync |
| `max_response_bytes` | integer ≥ 1 | `16777216` (16 MiB) | Maximum HTTP sync response size per bot |
| `max_pending_bytes` | integer ≥ 1 | `67108864` (64 MiB) | Maximum size of received events waiting to be processed per bot |

Choose `mode` before first startup, because each bot's stored sync state stays bound to the mode it first used.
Changing `matrix_sync` through config reload restarts running agents.

If a large initial sync exceeds the response limit, raise `max_response_bytes` enough to fit it.
If processing received events exceeds the pending limit, raise `max_pending_bytes`; stored events can take more space than the HTTP response, so raising the response limit may also require raising the pending limit.
Each bot has its own sync session, so allow enough memory and disk space when raising these limits.
Large initial syncs may also need more startup time: `MINDROOM_MATRIX_SYNC_STARTUP_TIMEOUT_SECONDS` sets the first-sync allowance, and the runtime Helm chart's `probes.startup` values set the Kubernetes startup probe allowance.

# Rooms & Spaces

This page covers the Matrix rooms MindRoom creates and manages, their join, listing, encryption, invitation, and admin settings, and the root Matrix Space that groups them.
Who may talk to an agent in a room is a separate setting covered in [Access Control](https://docs.mindroom.chat/authorization/).

## Configuration

List room keys under an agent's or team's `rooms`, and optionally set per-room policy under `rooms.<key>`:

```yaml
room_defaults:
  join_policy: invite
  listed: false
  invite_users:
    - "@owner:example.com"
  admins:
    - "@owner:example.com"

rooms:
  research:
    display_name: Research Lab
    description: Literature reviews and experiment notes
    encrypted: true
    invite_users:
      - "@owner:example.com"
      - "@scientist:example.com"

agents:
  assistant:
    rooms: [lobby, dev]

teams:
  research_team:
    rooms: [research]
```

Every room key listed by an agent or team, or under top-level `rooms`, is a managed room, created on startup or config reload if it does not exist yet.
Entries starting with `!` are existing Matrix room IDs, which are joined as they are and are not created or managed.

## Room policy

`room_defaults` sets the policy for every managed room, and `rooms.<key>` overrides it for one room.

| Field | Type | Default | Description |
| --- | --- | --- | --- |
| `join_policy` | `invite`, `knock`, or `public` | `invite` | How people may join the room |
| `listed` | bool | `false` | Publish the room in the server's room directory |
| `encrypted` | bool | `false` | Enable end-to-end encryption; once enabled it cannot be turned off |
| `invite_users` | list of Matrix user IDs | `[]` | Users invited to the room |
| `admins` | list of Matrix user IDs | `[]` | Users granted Matrix admin power (level 100) in the room |
| `rooms.<key>.display_name` | string | the key with underscores as spaces, title-cased | Matrix room name |
| `rooms.<key>.description` | string | `""` | Room purpose shown in the dashboard |

`invite_users` and `admins` accept only concrete Matrix user IDs, no wildcards, plus the pairing owner placeholder.
A field set under `rooms.<key>` replaces the default, and a list replaces the whole default list instead of merging with it, so an empty list removes the inherited invitations or admins for that room.
MindRoom logs `Administrators, room invitees, or room admins are on another homeserver; remove them unless you trust them` when any of these users is on a different homeserver.

MindRoom applies this policy to new and existing managed rooms on startup and on every config reload:

- `invite_users` is the desired invitation list, so a listed user who leaves or is kicked is invited again; remove the user from configuration before intentionally removing access.
- `admins` grants missing admin power but never demotes anyone, so removing a user from `admins` only stops future grants; lowering an existing admin requires a Matrix account with higher power.
- `listed: true` requires the router to have moderator or admin power in the room.
- `encrypted: true` turns on encryption in existing rooms, and setting it back to `false` never disables it.

## Room Management

The router creates and maintains managed rooms:

- Creates missing rooms with the alias `#<key>:<server>` (plus the `MINDROOM_NAMESPACE` suffix when set), an AI-generated topic, and a [managed avatar](https://docs.mindroom.chat/matrix/#managed-avatars).
- Invites the configured agents and teams and the room's `invite_users`.
- Sets the room name from `display_name`, and adds a topic only when the room has none.
- Lets every room member write thread tags and call membership state, which requires the router to be joined and able to edit power levels.
- Applies the [room policy](#room-policy) above.

Agents and teams leave rooms they are no longer configured for, except direct-message rooms and rooms they joined by accepting an invitation.

On a shared homeserver, any account can create or repoint an alias such as `#lobby:<server>`, so MindRoom only uses an aliased room it recorded itself or one the router created and aliased.
Otherwise it logs `managed_alias_target_refused`, never joins that room, and creates its own room without the alias.
A recorded room stays in use even when its alias now points elsewhere, which logs `managed_alias_points_elsewhere`.
If the alias of a recorded room is deleted, the router restores it, and creates a replacement room only when it can no longer read or is no longer joined to the recorded room.

## Room Cleanup

On startup, MindRoom removes Matrix accounts of agents and teams that were deleted from `config.yaml` from the rooms they had joined.
Direct-message rooms are left untouched, and no manual cleanup is needed.

## Matrix Space

MindRoom creates and maintains a root Matrix Space that contains every managed room, so users can find all MindRoom rooms in one place in their Matrix client.



```yaml
matrix_space:
  enabled: true
  name: MindRoom
```

| Field | Type | Default | Description |
| --- | --- | --- | --- |
| `enabled` | bool | `true` | Create and maintain the root Space |
| `name` | string | `"MindRoom"` | Space display name; cannot be empty |

Rooms added later are linked into the Space automatically, and the name follows the configured value on every startup.
Every user in any managed room's `invite_users` is invited to the Space, without Space admin power.
Room `admins`, `administrators`, and other access settings do not cause Space invitations.
MindRoom never removes existing Space admins, and adding, removing, or reordering Space children in a Matrix client requires sufficient power in the Space; manage those permissions in a Matrix client.
Setting `enabled: false` stops maintaining the Space but does not delete it or remove its child links.
The Space avatar is described in [Managed Avatars](https://docs.mindroom.chat/matrix/#managed-avatars).

# Personal Rooms

The optional `personal_rooms` section gives each eligible human a private, unlisted room with one selected agent, created when they join an onboarding room such as a lobby or send a configured command there.

## Setup

```yaml
rooms:
  lobby: {}
agents:
  helper:
    display_name: Helper
    rooms: []
    access:
      members_of_rooms: [lobby]
personal_rooms:
  agent: helper
  onboarding_rooms: [lobby]
  commands: ["!personal"]
  welcome_dispatch: true
```

The router watches the onboarding rooms, so the selected agent does not need to join them.
The human must still be allowed to use the agent under its access rules; listing the onboarding rooms in `access.members_of_rooms`, as above, grants that (see [Responder access](https://docs.mindroom.chat/authorization/#responder-access)).

## Configuration

`personal_rooms` is an object, or `null` (the default) to disable onboarding.

| Field | Type | Default | Meaning |
| --- | --- | --- | --- |
| `agent` | string | Required | Configured agent that owns and answers in personal rooms. |
| `onboarding_rooms` | list of strings | Required | Nonempty list of configured rooms whose human joins trigger onboarding. |
| `commands` | list of strings | `[]` | Exact self-onboarding commands accepted in onboarding rooms; each starts with `!` and contains no whitespace or arguments. |
| `alias_prefix` | string | `"personal"` | Room alias prefix matching `[a-z0-9_-]{1,40}`, followed by a hash of the user ID and the installation namespace. |
| `name` | string | `"Personal room for {user}"` | New room name template, up to 1,000 characters. |
| `topic` | string | `"Private conversation with {agent}."` | New room topic template, up to 1,000 characters. |
| `welcome` | string | `"Welcome {user}! This is your personal room with {agent}."` | Welcome message template, up to 10,000 characters; empty disables the welcome. |
| `welcome_dispatch` | boolean | `false` | Send the welcome to the agent as a trusted message on the human's behalf once the human joins, so the agent starts the conversation; otherwise post it as a notice right away. |
| `confirmation` | string | `""` | Optional one-time notice in the onboarding room where the human was onboarded, visible to everyone there, up to 10,000 characters. |
| `backfill` | boolean | `false` | Also onboard current eligible members of the onboarding rooms on startup and config reload. |
| `auto_join_requester` | boolean | `false` | Join the human into a newly created personal room instead of only inviting them. |
| `requester_admin` | boolean | `false` | Grant the human Matrix room admin; when `false`, no admin is granted and existing power levels, including admin granted earlier, stay unchanged. |
| `avatar` | string or `null` | `null` | Room avatar file, relative to the configuration file; takes priority over `avatar_from_requester`. |
| `avatar_from_requester` | boolean | `false` | Copy the human's profile avatar into the room when `avatar` is not set. |

Templates accept only `{user}` (full Matrix user ID), `{room}` (room alias), and `{agent}` (agent display name); any other placeholder fails config validation.
Avatars are set only on rooms that have none.
The welcome is sent once, and changing `welcome` or `welcome_dispatch` later does not change a welcome that is already pending.

`auto_join_requester: true` requires the router's Matrix account to be allowed to use the homeserver's Synapse-compatible admin join API.
It applies only when MindRoom creates the room, and never joins someone who already joined, left, or was banned.

## Onboarding Behavior

- Joining an onboarding room, or sending one of the `commands` there, onboards only that human.
- MindRoom's own router, agent, and team accounts, accounts listed in `bot_accounts`, and the internal `mindroom_user` account never get personal rooms.
- A human who leaves or is banned from their personal room is not re-added by restarts or backfill.
  Leaving and rejoining an onboarding room re-invites them to their existing personal room, except after a ban or for an adopted room.
- In a personal room MindRoom created, the owner may invite other MindRoom agents, and an invited agent sees only messages sent after its invite.
  Anyone else invited into such a room is removed with a notice explaining that the room is private.
  Adopted rooms instead allow only the participants listed in their record.
- Personal rooms survive restarts and room cleanup, even after `personal_rooms` is removed.
  Disabling the feature stops new onboarding but does not remove existing rooms or access.

## Troubleshooting Personal Rooms

An onboarding failure affects only that human, and the onboarding room keeps working for everyone else.
Failed onboarding is retried automatically, first after 30 seconds and then at doubling intervals up to one hour, and a config reload retries it immediately.
A join or command that arrives before the personal-room agent connects is handled once it does.
Revoking the human's access to the agent, or disabling `personal_rooms`, cancels a welcome that has not been sent yet.

| Log message | Meaning |
| --- | --- |
| `Personal-room validation failed for an onboarding trigger` | An existing personal room failed ownership or membership checks, for example because another account already holds the alias, or a guest the agent cannot remove was raised to the agent's power level. |
| `Personal-room imported roster has unattested members` | An adopted room contains members its record does not list; the warning names them, and MindRoom does not change the room. |
| `Personal-room onboarding trigger failed` | Any other failure, such as an invite refused by the human's homeserver. |
| `Personal-room reconciliation failed` | An automatic retry failed; the log shows the attempt number and the delay before the next try. |

## Adopting an Existing Room

Operators can turn an existing room into someone's personal room, keeping its room ID and history.

1. Stop the runtime.
2. Have the selected agent send an `org.mindroom.personal_room` state event in the room with an empty state key and exactly `{"user_id": "<requester>", "agent_user_id": "<agent>"}` as content.
3. Make sure the agent is joined with room admin power, the room is not published in the room directory, and its join rule is invite-only.
4. Write the record with `personal_room_record_path(runtime_paths, agent_name, user_id)` and `write_personal_room(path, record)` from `mindroom.matrix.personal_room_store`.

For a room with shared history and one existing guest, a record looks like this:

```json
{
  "user_id": "@alice:example.test",
  "alias": "#personal-alice:example.test",
  "source_room_id": "!lobby:example.test",
  "room_id": "!existing:example.test",
  "welcome_completed": true,
  "adoption": {
    "creator_user_id": "@router:example.test",
    "agent_user_id": "@helper:example.test",
    "router_user_id": "@router:example.test",
    "expected_history_visibility": "shared",
    "additional_user_ids": ["@guest:example.test"]
  }
}
```

- `room_id` is required; an alias alone never adopts a room.
- `source_room_id` is the onboarding room, and the requester must still be a member of it and allowed to use the agent.
- `creator_user_id` must match the room's creator, and `agent_user_id` must match the selected agent's Matrix ID.
- `router_user_id` (optional) must be this installation's router account and lets it stay in the room, including during room cleanup after onboarding is disabled.
- `expected_history_visibility` is `invited` (default), `joined`, or `shared` and must match the room's current setting; `world_readable` rooms cannot be adopted.
- `additional_user_ids` (default `[]`) lists the only other participants allowed to stay joined, invited, or knocking; it does not invite anyone or grant power.
- Set `welcome_completed: true` to skip the welcome; otherwise MindRoom sends the configured welcome.

If the room's history setting or members stop matching the record, onboarding for that human fails with one of the log messages above until someone fixes the room.
MindRoom does not change an adopted room's name, topic, or history setting, and never removes anyone from it.
It invites the requester if they are not in the room yet and applies the configured `requester_admin`, avatar, and welcome settings.

# Bridges

Bridges connect external messaging platforms to Matrix, so users can talk to MindRoom agents from those platforms.
MindRoom uses [mautrix](https://docs.mau.fi/bridges/) bridges, which run as Matrix [application services](https://spec.matrix.org/latest/application-service-api/) beside the homeserver (Synapse or Tuwunel).
A bridge creates Matrix rooms for external chats, creates ghost Matrix users for external contacts, and relays messages both ways in real time.
In **puppet mode**, you log in with your own account on the external platform, so your messages appear as coming from you on both sides rather than from a bot.

## Available Bridges

| Bridge | Platform | Mode | Status |
|--------|----------|------|--------|
| [Telegram](https://docs.mindroom.chat/deployment/bridges/telegram/) | Telegram | Puppet (login as yourself) | Available |
| Slack | Slack | - | Planned |
| Email | IMAP/SMTP | - | Planned |

## Bridge Manager

Deploy bridges with `./bridge.py` in `local/instances/deploy/`, for an instance created with a Matrix server by `./deploy.py create <name> --matrix tuwunel` or `--matrix synapse`.
Run `./bridge.py --help` there for every command and option.

1. Add the bridge with `./bridge.py add <type> --instance <name>`.
2. Generate its appservice registration with `./bridge.py register <type> --instance <name>`.
3. Register the bridge with the homeserver:
   - **Synapse:** `register` adds `/data/bridges/<type>/registration.yaml` to `app_service_config_files` in Synapse's `homeserver.yaml`, but you must place the generated registration file at that path in the Synapse container yourself.
     Make that copy readable only by the Synapse container's user (UID 1000), then restart Synapse with `./deploy.py restart <name> --only-matrix`.
   - **Tuwunel:** follow the admin-room steps that `register` prints, or run `./bridge.py register-with-matrix <type> --instance <name>`.
4. Start the bridge with `./bridge.py start <type> --instance <name>`.
5. Check it with `./bridge.py status --instance <name>` and `./bridge.py logs <type> --instance <name>`.

Use `./bridge.py stop` to stop bridges, `./bridge.py list` to see bridges across all instances, and `./bridge.py remove` to remove a bridge together with its data.
Each bridge's configuration, registration file, and data live in `bridges/<type>/` under the instance data directory.

# Telegram Bridge

Bridge Telegram and Matrix with `mautrix-telegram` in puppet mode, so MindRoom agents can talk in Telegram groups.
Linked Telegram groups become Matrix rooms, and permitted users can log in with their own Telegram account.

## Prerequisites

Create Telegram API credentials (API ID and API hash) at [my.telegram.org](https://my.telegram.org), and create a bridge bot through [@BotFather](https://t.me/BotFather).
Keep the API hash and bot token secret.

## Deploy

Run the [bridge manager](https://docs.mindroom.chat/deployment/bridges/#bridge-manager) from `local/instances/deploy/`:

```bash
./bridge.py add telegram --instance <instance> --admin @<you>:m-<instance-domain>
./bridge.py register telegram --instance <instance>
./bridge.py start telegram --instance <instance>
./bridge.py status --instance <instance>
./bridge.py logs telegram --instance <instance>
```

Provide the Telegram credentials to `bridge.py add` with `--api-id`, `--api-hash`, and `--bot-token`, through the `TELEGRAM_API_ID`, `TELEGRAM_API_HASH`, and `TELEGRAM_BOT_TOKEN` environment variables, or in `local/instances/deploy/.env.telegram`.
The command prompts for any credential still missing, and for the admin Matrix user ID when `--admin` is omitted.
The manager pins `mautrix-telegram` `v0.15.3`, the legacy Python bridge.

Between `register` and `start`, register the bridge with the homeserver as described in the [bridge manager](https://docs.mindroom.chat/deployment/bridges/#bridge-manager) steps.
On Synapse, place the registration at `/data/bridges/telegram/registration.yaml` in the Synapse container, readable only by the container's user (UID 1000).

## Bridge permissions

The generated bridge configuration grants `admin` only to the `--admin` user and `relaybot` to everyone else, so accounts registered on an open homeserver cannot control the bridge.
To let more users log in with their own Telegram accounts, add their Matrix user IDs with the `puppeting` (or `full`) level under `bridge.permissions` in the generated configuration, then restart the bridge.

Bridges created by older manager versions grant `user` to the whole homeserver domain and `admin` to `@admin:<domain>`.
Fix one by setting `bridge.permissions` to `relaybot` for `*` and `admin` for your own Matrix user ID, then restarting it, or recreate it with `bridge.py remove` and `bridge.py add --admin`, which deletes its data.

## Configure MindRoom

When room access is restrictive, Telegram ghost users must satisfy [responder access](https://docs.mindroom.chat/authorization/#responder-access) like any other sender.
Add a [bridge alias](https://docs.mindroom.chat/authorization/#bridge-aliases) when a ghost should act as an existing Matrix user with the same identity and permissions.
List the bridge bot in [`bot_accounts`](https://docs.mindroom.chat/authorization/#bot-accounts) when it can send messages, and use room-level [thread mode](https://docs.mindroom.chat/configuration/threads/) because Telegram does not preserve Matrix threads:

```yaml
bot_accounts:
  - "@telegrambot:matrix.example.com"

authorization:
  aliases:
    "@owner:matrix.example.com":
      - "@telegram_12345:matrix.example.com"

agents:
  assistant:
    thread_mode: room
```

Replace the owner and Telegram ghost IDs with the ones from your deployment.
Aliases are exact Matrix user IDs, not glob patterns, so list every ghost that should act as that user.

## Log in to Telegram

Start a Matrix DM with the Telegram bridge bot and send `login` for phone authentication or `login-qr` for QR authentication.
Telegram usually sends the login code to an already signed-in Telegram app, and accounts with two-factor authentication are also asked for their password.

Telegram login does not set up Matrix double puppeting; configure that separately with the `double_puppet` settings in the generated bridge configuration.

## Link a room

1. Create a Telegram group and add your Telegram bot to it.
2. Invite the Matrix bridge bot into the MindRoom-managed Matrix room.
3. Create or link the portal with the commands from the bridge bot's `help` output.

Portal commands differ between `mautrix-telegram` versions, so use the running bridge's `help` output rather than commands from other documentation.

## Data and backups

The generated configuration (`data/config.yaml`), registration, and SQLite database live in the bridge data directory under the instance data directory.
The configuration and registration contain the Telegram credentials and appservice tokens.
Deleting the bridge database removes Telegram logins and portal links, and users must log in again, so back up the bridge data directory before any reset.

# Tools

MindRoom includes 100+ built-in tools and presets that agents can use to work with files, services, external APIs, and Matrix-native workflows.
This page covers enabling tools, per-agent tool settings, function filters, presets, update awareness, and dependency installation.
To find a specific tool, see [Browse By Topic](#browse-by-topic).

## Enabling Tools

List tools under an agent's `tools`, or under `defaults.tools` to give them to every agent:

```yaml
defaults:
  tools:
    - scheduler

agents:
  assistant:
    display_name: Assistant
    role: A helpful assistant with file and web access
    model: sonnet
    tools:
      - file
      - shell
      - github
      - duckduckgo
```

Each agent gets its own `tools` plus `defaults.tools`, without duplicates.
The `defaults.tools` default and the per-agent `include_default_tools` opt-out are described in [Agent Configuration](https://docs.mindroom.chat/configuration/agents/).
Configured MCP servers appear as tools named `mcp_<server_id>`; see [MCP](https://docs.mindroom.chat/mcp/).
When [`config_manager` or `self_config`](https://docs.mindroom.chat/tools/agent-orchestration/#access-redaction-and-saving) replaces an agent's tool list, tools that stay keep their inline settings.

## Per-Agent Tool Configuration

A tool entry can be a plain name or a single-key mapping of the tool name to inline settings.
Inline settings change the tool for that agent only.
Settings under `defaults.tools` apply to every agent that includes default tools.

```yaml
defaults:
  tools:
    - scheduler
    - shell:
        enable_run_shell_command: true     # every agent

agents:
  code:
    tools:
      - file                               # no inline settings
      - shell:                             # this agent only
          extra_env_passthrough: "DAWARICH_*"
  research:
    tools:
      - shell                              # uses the defaults.tools settings
      - duckduckgo
```

Each tool's page lists the settings it accepts.

### Security Restrictions

Some fields cannot be set inline:

- `password` fields, such as API keys and tokens, must be set through the dashboard or credential store.
- Fields MindRoom manages itself, such as `base_dir`, cannot be overridden.
- Per-agent-only fields, such as the [`usage_stats`](https://docs.mindroom.chat/usage/#usage_stats) tool's `admin_scope`, are rejected in `defaults.tools`.

MindRoom rejects these fields, unknown field names, and values of the wrong type when the config loads, and the unknown-field error lists the allowed fields.

### How Settings Combine

From lowest to highest priority, a tool's settings come from:

1. The tool's built-in defaults.
2. Saved tool settings from the dashboard or credential store.
3. `defaults.tools` inline settings.
4. `agents.<name>.tools` inline settings.
5. Values MindRoom sets for the agent's runtime, such as its workspace directory.

When a tool appears in both `defaults.tools` and an agent's `tools`, the settings merge field by field.
Per-agent values win for the same field, and fields set in only one place are kept.

### Clearing An Inherited Override

Set a field to `__MINDROOM_INHERIT__` to keep a tool but drop one inherited `defaults.tools` setting:

```yaml
defaults:
  tools:
    - shell:
        extra_env_passthrough: "DAWARICH_*"
        enable_run_shell_command: true

agents:
  research:
    tools:
      - shell:
          extra_env_passthrough: __MINDROOM_INHERIT__
```

`research` still inherits `enable_run_shell_command: true`, while `extra_env_passthrough` falls back to the saved tool setting, or to the tool's default when none is saved.
To drop every default tool and all their settings for an agent instead, set `include_default_tools: false`.

### Filtering Toolkit Functions

Most built-in toolkits accept `include_tools` and `exclude_tools` inline settings to limit which of their functions the agent sees.
`include_tools` is an allowlist, and `exclude_tools` removes functions from what remains.
Use the function names the toolkit exposes.
Tools that do not support filtering reject these fields when the config loads.

```yaml
agents:
  research:
    tools:
      - searxng:
          include_tools:
            - search_web
            - news_search
            - image_search
```

A name the toolkit does not provide fails when the tool loads with `Included tool(s) not present in the toolkit: <names>` or `Excluded tool(s) not present in the toolkit: <names>`.

## Browse By Topic

- [Execution & Coding](https://docs.mindroom.chat/tools/execution-and-coding/) - Local files, shell, Python, coding helpers, and worker-routed execution tools.
- [Background Python Scripts](https://docs.mindroom.chat/tools/background-scripts/) - Long-running Python scripts that call the agent's tools and wake it when something changes.
- [Worker Computer](https://docs.mindroom.chat/tools/worker-computer/) - A visible browser in the agent's worker that the user can watch and control.
- [Desktop Bridge](https://docs.mindroom.chat/tools/desktop/) - Operate allowlisted apps, read selected folders, and run approved commands on a local computer.
- [Minimal Agent Mode](https://docs.mindroom.chat/tools/agent-cli/) - One Bash tool instead of full tool schemas, to cut tokens per request.
- [Data & Databases](https://docs.mindroom.chat/tools/data-and-databases/) - SQL, databases, Google Docs and Drive files, spreadsheets, tabular analysis, and financial/business datasets.
- [Web Search](https://docs.mindroom.chat/tools/web-search/) - Search engines and search APIs.
- [Web Scraping & Browser](https://docs.mindroom.chat/tools/web-scraping-and-browser/) - Crawlers, extractors, browser automation, and page-reading tools.
- [Research Sources](https://docs.mindroom.chat/tools/research-sources/) - ArXiv, Google Scholar, Wikipedia, PubMed, and Hacker News.
- [AI & Generation](https://docs.mindroom.chat/tools/ai-and-generation/) - Image, video, speech, and transcription APIs.
- [Media & Content](https://docs.mindroom.chat/tools/media-and-content/) - Media processing, brand/media retrieval, and Spotify.
- [Matrix & Attachments](https://docs.mindroom.chat/tools/matrix-and-attachments/) - Matrix-native messaging and voice messages, thread tags, resolution, summaries, model overrides, low-level Matrix API access, and attachment-aware workflows.
- [Matrix Message Tool](https://docs.mindroom.chat/tools/matrix-message/) - Send, read, edit, and react to messages with `matrix_message`.
- [Agent Chat UI Actions](https://docs.mindroom.chat/tools/chat-ui/) - Bounded requests to reveal an agent computer, open Settings, open Members, or show an [interactive canvas](https://docs.mindroom.chat/canvases/) in MindRoom Chat.
- [Messaging & Social](https://docs.mindroom.chat/tools/messaging-and-social/) - Email, chat, and social/community integrations.
- [Project Management](https://docs.mindroom.chat/tools/project-management/) - Git hosting, issue trackers, docs platforms, per-thread work plans, and task managers.
- [Atlassian Cloud](https://docs.mindroom.chat/tools/atlassian/) - Per-user OAuth Jira and Confluence Cloud access, additional connected sites, and attachment downloads.
- [Calendar & Scheduling](https://docs.mindroom.chat/tools/calendar-and-scheduling/) - Calendar and task APIs and MindRoom scheduling tools.
- [Memory & Storage](https://docs.mindroom.chat/tools/memory-and-storage/) - Mem0 and Zep external memory services; the built-in [`memory`](https://docs.mindroom.chat/memory/#memory) tool is documented with the memory system.
- [Agent Orchestration](https://docs.mindroom.chat/tools/agent-orchestration/) - Delegation, Dynamic Workflows, report publishing, config tools, and Claude Agent sessions.
- [Dynamic Tools](https://docs.mindroom.chat/tools/dynamic-tools/) - Per-tool lazy loading for optional agent capabilities.
- [Automation & Platforms](https://docs.mindroom.chat/tools/automation-and-platforms/) - Infrastructure automation, generic APIs, and platform aggregators.
- [Location, Commerce, & Home](https://docs.mindroom.chat/tools/location-commerce-and-home/) - Maps, weather, commerce, and Home Assistant.

## Tool Presets And Implied Tools

`openclaw_compat` is a preset that expands to a bundle of MindRoom tools; see [OpenClaw Workspace Import](https://docs.mindroom.chat/openclaw/).
Enabling `matrix_message` also enables its [companion tools](https://docs.mindroom.chat/tools/matrix-message/#setup).

## MindRoom Update Awareness

Enable the `update_awareness` tool to tell the agent which MindRoom version is installed and which release is the latest on PyPI.

```yaml
defaults:
  tools:
    - update_awareness
```

MindRoom checks PyPI at most once every 24 hours and caches the result in `mindroom_data/cache/update_awareness.json`.
When a newer release exists, the agent mentions it briefly at a natural moment, once per conversation, and does not install it unless the user asks.
The agent can also call `get_mindroom_update_status()` to report both versions.

## Automatic Dependency Installation

When an enabled tool's optional Python dependencies are missing, MindRoom installs them the first time the tool loads.
Set `MINDROOM_NO_AUTO_INSTALL_TOOLS=1` to disable this.
When auto-install is disabled or fails, the tool fails to load with `Missing dependencies for tool '<tool>': <packages>`.
Install the tool's extra manually, for example `pip install 'mindroom[<tool>]'` or `uv sync --extra <tool>` in a source checkout.

## Related Docs

- [MCP](https://docs.mindroom.chat/mcp/) - Configure native MCP client servers and expose them as MindRoom tools.
- [Plugins](https://docs.mindroom.chat/plugins/) - Extend MindRoom with custom tools and skills.
- [Sandbox Proxy](https://docs.mindroom.chat/deployment/sandbox-proxy/#worker-routing) - Which tools run in isolated workers and which stay in the primary runtime.
- [OAuth Onboarding In Conversation](https://docs.mindroom.chat/oauth-framework/#mindroom-managed-oauth-onboarding-in-conversation) - How agents send users links to connect OAuth tools.
- [Attachments](https://docs.mindroom.chat/attachments/) - Attachment lifecycle and context scoping.
- [Scheduling](https://docs.mindroom.chat/scheduling/) - Chat command scheduling and task behavior.

# MCP

MindRoom is a Model Context Protocol (MCP) client: it connects to the MCP servers you configure, discovers their tools, and lets agents call them.
MindRoom uses MCP tools only; it does not use MCP resources or prompts.
To go the other way and expose your agents' tools to external MCP clients, use the [MCP Gateway](https://docs.mindroom.chat/deployment/mcp-gateway/).

## Add an MCP Server

Configure servers in the top-level `mcp_servers` block of `config.yaml`.
Each key is a server ID made of letters, numbers, and underscores.
Each enabled server becomes one MindRoom tool named `mcp_<server_id>`; add that name to an agent's `tools:` list to give the agent the server's tools.

```yaml
mcp_servers:
  chrome_devtools:
    transport: stdio
    command: npx
    args:
      - -y
      - chrome-devtools-mcp@latest
    tool_prefix: chrome

agents:
  browser:
    display_name: Browser
    role: Debug and inspect web apps in Chrome
    model: sonnet
    tools:
      - mcp_chrome_devtools
```

MCP tools work on every worker scope, including private agents.

## Transports

| `transport` | Use for | Required | Optional | Not allowed |
| --- | --- | --- | --- | --- |
| `stdio` | A local server that MindRoom starts as a subprocess | `command` | `args`, `cwd`, `env` | `url`, `headers`, `auth` |
| `sse` | A remote server with a Server-Sent Events endpoint | `url` | `headers`, `auth` | `command`, `args`, `cwd`, `env` |
| `streamable-http` | A remote server with a streamable HTTP endpoint | `url` | `headers`, `auth` | `command`, `args`, `cwd`, `env` |

```yaml
mcp_servers:
  remote_http:
    transport: streamable-http
    url: https://mcp.example.com/mcp
    headers:
      Authorization: Bearer ${MCP_API_TOKEN}
```

Remote transports refuse loopback and private-network addresses; run local servers with `stdio` instead.

### Credentials in Server Config

`env` and `headers` values support `${ENV_VAR}` placeholders, resolved from MindRoom's environment when the connection opens.
Pass `stdio` credentials through `env`, never through `args`.
`config_manager` and `!config show` mask `env` and `headers` values, except values that are a whole `${ENV_VAR}` placeholder, but show `args` as written, and `args` support no placeholders.

Without OAuth, every agent and requester shares one server session and the same static `headers`, and MindRoom never passes the requester's identity or credentials to the server.
For per-user or per-agent accounts on a remote server, use [OAuth](#oauth-backed-remote-mcp).

## Tool Names

There are two names to keep apart:

1. The tool entry in an agent's `tools:` list is `mcp_<server_id>`.
2. The function names the model sees are `<prefix>_<remote_tool_name>`, where the prefix is `tool_prefix` or, if unset, the server ID.

With `tool_prefix: chrome`, a remote tool named `navigate_page` becomes `chrome_navigate_page`.
A final function name must be 64 characters or fewer.
MindRoom rejects duplicate function names within one server and across the tools and servers visible to the same agent; agents that do not share the colliding surfaces may use identical names.

## Choose Which Tools an Agent Sees

`include_tools` and `exclude_tools` on the server filter the catalog for every agent.
To narrow it for one agent, set overrides where you assign the tool:

```yaml
agents:
  browser:
    tools:
      - mcp_chrome_devtools:
          include_tools:
            - new_page
            - navigate_page
            - take_snapshot
          call_timeout_seconds: 180
```

Per-agent overrides accept `include_tools`, `exclude_tools`, and `call_timeout_seconds` (greater than 0).
Filters match remote MCP tool names, not prefixed function names, and a name cannot appear in both `include_tools` and `exclude_tools`.

## Dashboard Name and Icon

`display_name`, `summary`, and `icon` control how the server appears in the dashboard tool catalog and on Connections; they do not change tool names or permissions.

```yaml
mcp_servers:
  team_wiki:
    display_name: Team Wiki
    summary: Search and edit team documentation
    icon: SiConfluence
    transport: streamable-http
    url: https://wiki.example.com/mcp
```

`icon` takes a Lucide icon name such as `Book` or `Calendar`, or a bundled React Icons name such as `SiConfluence` or `SiGooglecalendar`.
Without a usable icon, Connections picks one matching the server's name, or a plug icon.

## Server Options

| Option | Type | Default | Notes |
|--------|------|---------|-------|
| `enabled` | bool | `true` | Set to `false` to disable the server without removing its config |
| `transport` | string | *required* | `stdio`, `sse`, or `streamable-http` |
| `command` | string | `null` | Required for `stdio` |
| `args` | list[string] | `[]` | `stdio` arguments; shown unmasked, so never put credentials here |
| `cwd` | string | `null` | `stdio` working directory |
| `env` | map[string,string] | `{}` | `stdio` environment variables; supports `${ENV_VAR}` |
| `url` | string | `null` | Required for `sse` and `streamable-http` |
| `headers` | map[string,string] | `{}` | Remote request headers; supports `${ENV_VAR}` |
| `auth` | object | `null` | OAuth settings for remote servers; see [OAuth Settings](#oauth-settings) |
| `tool_prefix` | string | server ID | Prefix for model-visible function names; letters, numbers, and underscores |
| `include_tools` | list[string] | `[]` | Allowlist of remote tool names |
| `exclude_tools` | list[string] | `[]` | Denylist of remote tool names; must not overlap `include_tools` |
| `display_name` | string | `null` | Catalog name; falls back to `auth.display_name`, then a name derived from the server ID |
| `summary` | string | `null` | Short capability summary for the dashboard and catalog |
| `icon` | string | `null` | Dashboard and Connections icon name |
| `description` | string | `null` | What the server offers, added to the OAuth bridge tool descriptions the model sees; requires `auth` |
| `required` | bool | `false` | Keep dependent agents and teams from starting while the server is unavailable |
| `startup_timeout_seconds` | float | `20.0` | Time allowed to connect, initialize, and list tools; must be greater than 0 |
| `call_timeout_seconds` | float | `120.0` | Default timeout per tool call; must be greater than 0 |
| `max_concurrent_calls` | int | `1` | Maximum concurrent tool calls to the server; at least 1 |
| `auto_reconnect` | bool | `true` | Reconnect for later calls after a dropped connection or timeout; the failed call is never replayed |

## OAuth-Backed Remote MCP

Use `auth.type: oauth` when a remote MCP server needs an OAuth bearer token, so each connection belongs to the right user or agent.
OAuth requires `sse` or `streamable-http`.

```yaml
mcp_servers:
  example:
    transport: streamable-http
    url: https://mcp.example.com/mcp
    tool_prefix: example
    description: Example workspace search, documents, and calendar for the signed-in user.
    auth:
      type: oauth
      display_name: Example MCP
      discovery: auto

agents:
  assistant:
    display_name: Assistant
    role: Use the user's Example workspace
    model: sonnet
    worker_scope: user_agent
    tools:
      - mcp_example
```

The connection follows the agent's effective scope (`private.per`, then `worker_scope`, then `defaults.worker_scope`), as described in [Where Connections Are Stored](https://docs.mindroom.chat/oauth-framework/#where-connections-are-stored).
Changing an agent's scope changes whose connection it uses, and MindRoom never copies a credential into the new scope, so reconnect there.

### Connecting and Bridge Tools

Every OAuth-backed server always exposes three functions:

- `<prefix>_connection_status`
- `<prefix>_list_tools`
- `<prefix>_call_tool`

Before the account is connected, these are the only functions the model sees, so set `description` to tell the model what connecting unlocks.
When no account is connected, they return a connect link (see [OAuth onboarding in conversation](https://docs.mindroom.chat/oauth-framework/#mindroom-managed-oauth-onboarding-in-conversation)).
After connecting, `<prefix>_list_tools` returns the server's tools and `<prefix>_call_tool` calls them with that connection's token.
Once MindRoom has loaded the catalog for that connection, the agent also gets typed `<prefix>_<remote_tool_name>` functions.

A temporary token refresh failure returns `MCP server '<server_id>' OAuth token refresh failed; retry shortly` and keeps the stored credentials.
To reset a stuck or revoked connection, see [Reset A Connection](https://docs.mindroom.chat/oauth-framework/#reset-a-connection).

### Endpoint Discovery

With `discovery: auto`, MindRoom reads the OAuth protected-resource metadata (`/.well-known/oauth-protected-resource`) for `resource`, or `url` when `resource` is unset, then fetches the advertised authorization server's metadata.
If no authorization server is advertised, it looks for authorization-server metadata at the resource's origin.
Setting `authorization_server` skips the protected-resource lookup, and explicit `authorization_url`, `token_url`, or `registration_url` values override the discovered endpoints.
Discovery fails if the authorization server does not support the configured `token_endpoint_auth_method` or PKCE method.

Use `discovery: manual` when the server publishes no metadata or you want fixed endpoints:

```yaml
mcp_servers:
  example_manual:
    transport: streamable-http
    url: https://mcp.example.com/mcp
    auth:
      type: oauth
      discovery: manual
      authorization_url: https://mcp.example.com/oauth/authorize
      token_url: https://mcp.example.com/oauth/token
      registration_url: https://mcp.example.com/oauth/register
```

Discovery requires HTTPS, refuses loopback and private-network hosts, and does not follow redirects.
For local development, set `MINDROOM_MCP_OAUTH_ALLOW_INSECURE_DISCOVERY=1` to allow non-HTTPS discovery URLs and `MINDROOM_MCP_OAUTH_ALLOW_PRIVATE_DISCOVERY=1` to allow loopback or private-network discovery hosts.
These variables do not relax the address rules for the MCP connection itself.

### OAuth Client Registration

With `dynamic_client_registration: true` and no stored client, MindRoom registers a client when the first user connects and reuses it for later users.
See [Built-In Providers](https://docs.mindroom.chat/oauth-framework/#built-in-providers) for when a dynamically registered client works from a hosted address.
If discovery later resolves a different token endpoint, MindRoom registers a new client there and logs `oauth_dynamic_client_reregistered`, and users connected through the old client must reconnect.
With dynamic registration disabled, or when the new server offers no registration endpoint, connecting fails instead.

To use your own OAuth app, store its client config in the credential service `<provider_id>_oauth_client`, or list other services in `client_config_services`.
Tokens are stored in `<provider_id>_oauth`; when `provider_id` does not start with `mcp_`, both service names get an `mcp_` prefix.
Public clients (`token_endpoint_auth_method: none`) need only `client_id`; confidential clients also need `client_secret`.
MindRoom never re-registers an operator-configured client, so for a confidential client pin `authorization_server` or `token_url` to keep the server's metadata from redirecting its secret to another token endpoint.
See [Provider And Credential Service Rules](https://docs.mindroom.chat/oauth-framework/#provider-and-credential-service-rules) for client config service rules.

### OAuth Settings

| `auth` field | Type | Default | Notes |
| --- | --- | --- | --- |
| `type` | string | *required* | Must be `oauth` |
| `provider_id` | string | `mcp_<server_id>` | OAuth provider ID; letters, numbers, and underscores |
| `display_name` | string | `null` | Provider name; defaults to `MCP <Server Id>` |
| `resource` | string | server `url` | Protected resource used for discovery |
| `discovery` | string | `auto` | `auto` or `manual` |
| `authorization_server` | string | `null` | Authorization server issuer or base URL; skips protected-resource discovery |
| `authorization_url` | string | `null` | Authorization endpoint; required for `manual` |
| `token_url` | string | `null` | Token endpoint; required for `manual` |
| `registration_url` | string | `null` | Dynamic client registration endpoint |
| `dynamic_client_registration` | bool | `true` | Allow registering a client automatically |
| `token_endpoint_auth_method` | string | `none` | `none`, `client_secret_post`, or `client_secret_basic` |
| `pkce_code_challenge_method` | string | `S256` | `S256`, or `null` to disable PKCE |
| `scopes` | list[string] | `[]` | Requested OAuth scopes |
| `extra_auth_params` | map[string,string] | `{}` | Extra authorization request parameters, such as `resource` |
| `extra_token_params` | map[string,string] | `{}` | Extra code-exchange and refresh parameters; treated as secret |
| `client_config_services` | list[string] | `[]` | Credential services holding the OAuth client config, in lookup order; defaults to `<provider_id>_oauth_client` |
| `shared_client_config_services` | list[string] | `[]` | Shared client config services checked after `client_config_services` |

## Examples

### Echo Server

A minimal local server, saved as `echo_mcp_server.py`:

```python
from mcp.server.fastmcp import FastMCP

server = FastMCP("Echo Server")


@server.tool()
def echo(text: str) -> str:
    return f"echo:{text}"


if __name__ == "__main__":
    server.run()
```

```yaml
mcp_servers:
  echo:
    transport: stdio
    command: ./.venv/bin/python
    args:
      - ./echo_mcp_server.py

agents:
  code:
    display_name: Code
    role: Test MCP tools
    model: sonnet
    tools:
      - mcp_echo
```

The model sees the remote `echo` tool as `echo_echo`.
Use a Python interpreter that has the `mcp` package installed.
To serve it remotely instead, run `server.run(transport="sse")` (endpoint `/sse`) or `server.run(transport="streamable-http")` (endpoint `/mcp`).

### Chrome DevTools

The [Add an MCP Server](#add-an-mcp-server) example starts `chrome-devtools-mcp`, which launches its own Chrome with a dedicated profile.
To attach to an already running debuggable Chrome instead, add `--browser-url`:

```yaml
mcp_servers:
  chrome_devtools:
    transport: stdio
    command: npx
    args:
      - -y
      - chrome-devtools-mcp@latest
      - --browser-url=http://127.0.0.1:9222
    tool_prefix: chrome
```

If Chrome starts slowly, increase `startup_timeout_seconds`; if browser operations run long, increase `call_timeout_seconds`.

### MemPalace Memory

[MemPalace](https://github.com/milla-jovovich/mempalace) is a local memory store with MCP tools for search and adding memories.
Running it through `uvx` keeps its ChromaDB version separate from MindRoom's:

```yaml
mcp_servers:
  mempalace:
    transport: stdio
    command: uvx
    args:
      - --from
      - mempalace
      - python
      - -m
      - mempalace.mcp_server
      - --palace
      - /path/to/.mempalace/palace
    startup_timeout_seconds: 30
    call_timeout_seconds: 60

agents:
  assistant:
    display_name: Assistant
    role: General assistant with persistent memory
    model: sonnet
    tools:
      - mcp_mempalace
```

Initialize and seed the same palace before the server can return results:

```bash
uvx mempalace --palace /path/to/.mempalace/palace init /path/to/content
uvx mempalace --palace /path/to/.mempalace/palace mine /path/to/content
```

See the [MemPalace CLI reference](https://mempalaceofficial.com/reference/cli) for these commands.

## Failures and Catalog Changes

MindRoom connects to MCP servers at startup and whenever `config.yaml` changes, and logs a warning for each server that fails to start, initialize, or list valid tools.
By default a failed server does not block anything: agents and teams that use it start without its tools.
MindRoom keeps retrying a failed server without OAuth in the background and restarts the affected agents and teams once it recovers.
An OAuth-backed server is retried on its next tool call.
Set `required: true` to keep dependent agents and teams from starting until the server is available.

A function-name collision is not retried, and the colliding servers' tools stay unavailable.
Fix the remote tool names or `tool_prefix` values, then reload `config.yaml` or restart MindRoom.

Errors that the MCP server reports for a tool call return to the agent as tool errors and are not retried.
If the connection drops or times out during a call, the call fails even when `auto_reconnect` restores the connection, and the action is never replayed.
Check whether a call that changes something actually completed before retrying it.

When a server reports that its tool list changed, MindRoom reloads the list, at most once a minute per server.
If the tools changed, the agents and teams that use the server restart to pick them up, waiting for active responses like a [config reload](https://docs.mindroom.chat/configuration/#configuration-file).

# Dynamic Tools

Dynamic tools keep an agent's rarely used tools out of the model's tool list until the agent needs them, which saves context on every request.
Use them for agents with many optional tools.
Each authored entry in an agent's `tools:` list loads or unloads as one unit.

## Configuration

| Field | Type | Default | Effect |
| --- | --- | --- | --- |
| `defer` | bool | `false` | Hide the tool until the agent loads it for the current session. |
| `initial` | bool | `false` | Load a deferred tool at session start and keep it loaded; requires `defer: true`. |

A tool with neither flag is eager and appears in every request.

```yaml
agents:
  assistant:
    display_name: Assistant
    role: Help in chat
    tools:
      - shell
      - coding: {defer: true, initial: true}
      - searxng: {defer: true, host: https://search.example.test, fixed_max_results: 10}
      - name: serper
        defer: true
        overrides: {num_results: 10}
```

These configurations fail validation:

- `initial: true` without `defer: true` fails with `Tool entry initial=true requires defer=true`.
- Lazy loading is per agent, so `defaults.tools` entries reject both flags with `defaults.tools does not support defer or initial flags: <tools>`.
- Presets such as [`openclaw_compat`](https://docs.mindroom.chat/openclaw/) cannot be deferred; set the flags on the individual member tools instead.
- The control-plane tools `delegate`, `dynamic_tools`, `external_trigger_manager`, `invite_router`, `self_config`, and `skill_manage` cannot be deferred and fail with `'<tool>' is a control-plane tool and cannot be deferred; defer/initial are only valid on runtime tools.`

## Native Server-Side Tool Search

Standard-mode agents on these models use the provider's own tool search instead of MindRoom's loading tools:

- Claude Opus 4.5, Sonnet 4.5, Haiku 4.5, and newer on the `anthropic` and `vertexai_claude` providers.
- GPT 5.4 and newer on `openai`, `codex`, and `openai_codex`.

For `openai`, an explicit `api: chat_completions` or a custom endpoint selects runtime loading instead.
The endpoint is a non-empty `extra_kwargs.base_url`, otherwise `OPENAI_BASE_URL`; native search applies only when it is unset or `https://api.openai.com/v1`.

On this path, deferred tools stay out of the model's context until the model searches for them, and discovering a tool does not invalidate the prompt cache.
The system prompt names the deferred toolkits and tells the model to search them before concluding that a tool is unavailable.
Discovered tools are called directly, so there is nothing to load or unload.
Tools with `initial: true` are ordinary always-visible tools.
Toolkit instructions are omitted from the system prompt when every function in the toolkit is deferred and not `initial`.

## Runtime Loading

On other models, an agent with at least one deferred tool gets the `dynamic_tools` toolkit, with `list_tools()`, `tool_search(query)`, `load_tool(tool_name)`, and `unload_tool(tool_name)`.
`tool_search` matches keywords and exact names only.
Tools with `initial: true` cannot be unloaded.

A loaded tool becomes callable in the agent's next tool-call step, not in the same parallel batch as `load_tool()`.
The agent continues the same response with the updated tools and the loaded toolkit's instructions, without waiting for another message.

Loaded tools are tracked per agent and per conversation session, so two agents in the same Matrix thread do not share them.
This state is held in memory, so a restart returns each session to its `initial` tools.

## Minimal Mode

In [minimal mode](https://docs.mindroom.chat/tools/agent-cli/), every request still exposes only Bash.
The agent lists and calls its deferred tools through `mindroom-agent` like its other tools.

# Minimal Agent Mode

Minimal mode gives an existing agent a short prompt and one Bash tool instead of its full system prompt and tool schemas, so each request carries far fewer tokens.
The agent uses `mindroom-agent` in that Bash to discover and call its other tools.
Its identity, model, workspace, memory, history, and tool permissions stay the same, and switching modes does not create a second workspace or memory store.
Standard mode is the default.

You can use minimal mode for one agent in one conversation with `!mode`, or for one subagent run with `run_subagent(minimal=True)`.
Standard-mode agents with the `shell` tool can also call `mindroom-agent` inside their shell commands.
All three need the [deployment requirements](#deployment-requirements).

## Conversation mode

### `!mode`

Switch one agent's tool interface for its next Matrix responses in the current conversation:

```text
!mode helper minimal
!mode helper show
!mode helper standard
!mode helper reset
```

`standard` and `reset` both remove the saved choice and restore standard mode.
You must be authorized to use the named agent.
For agents using threads, run the command inside an existing thread and keep talking to the agent in that thread.
For agents using `thread_mode: room`, the choice applies to the whole room, including commands sent from a thread.
Private agents store the choice separately for each requester.
The choice survives restarts without changing `config.yaml`, and other agents and conversations are unaffected.
Teams and OpenAI-compatible API requests do not use this selection.
A response already in progress keeps the mode it started with.

Selecting minimal mode does not grant shell access or add tools.
If the agent or deployment cannot support minimal mode, the reply lists every missing requirement with its fix, names the `.env` file for deployment settings, and links to the [deployment requirements](#deployment-requirements), without saving the choice.
If shell permissions or deployment settings change after minimal mode was selected, minimal responses fail instead of running; run `!mode helper standard` in the same conversation to return to standard mode.

### Prompt and `minimal_instructions`

The minimal prompt identifies the agent, lists its loaded workspace context files, and names the toolkits available through `mindroom-agent`.
With file memory, it also names the workspace-relative `MEMORY.md` and `memory/` locations.
File contents, the role, ordinary instructions, and tool guidance are not in the prompt; the agent reads them through `mindroom-agent context` when it needs them.

`minimal_instructions` (list of strings, default `[]`) is appended to the minimal prompt on every minimal request:

```yaml
agents:
  helper:
    display_name: Helper
    tools: [shell]
    minimal_instructions:
      - Read the project instructions before changing files.
```

Because guidance moved out of the prompt is seen only when the agent looks it up, put short guidance that must apply on every request in `minimal_instructions`, and keep durable notes in the agent's memory.

## Minimal subagents

A caller with the [`delegate`](https://docs.mindroom.chat/tools/agent-orchestration/#delegate) tool can pass `minimal=True` to `run_subagent` to run that child in minimal mode, and `continue_subagent` keeps the child's mode.
Use it for self-contained tasks where the child does not need its role, instructions, and tool guidance up front; it can still read them with `mindroom-agent context`.
The option is offered only in Matrix conversations, only for allowed subagents that have the `shell` tool and meet the [deployment requirements](#deployment-requirements), and never while shell commands require approval.
A minimal subagent cannot pause for approval, so approval-gated tools are hidden from it.
A listed child whose shell permissions or workspace still rule out minimal mode fails with the reason and a hint to start a new subagent without minimal.

## Standard mode

A standard-mode agent with the `shell` tool can use `mindroom-agent` inside its shell commands, so one script can combine many tool calls, loop over results, or filter them before anything returns to the model.
It is available automatically when the agent answers a Matrix conversation itself and its shell meets the [deployment requirements](#deployment-requirements); the agent's instructions then mention it.
Team members, call agents, workflow participants, and OpenAI-compatible requests do not get it.
The agent keeps all of its tools as ordinary tools as well.
Tools that may require approval, ask the requester a question, delegate to another agent, or end the turn are not offered through the CLI in standard mode; the agent calls them directly instead.
The CLI is not offered when the agent's shell commands themselves require approval, and after a response pauses for any approval, the rest of that response continues without it.
Media returned by CLI calls reaches the model with the shell command's result, and streamed responses show CLI calls as their own tool-trace entries.

## Discover and call tools

```bash
mindroom-agent --help
mindroom-agent tools list
mindroom-agent tools search calendar
mindroom-agent tools describe TOOLKIT FUNCTION
mindroom-agent tools call TOOLKIT FUNCTION --json '{"argument":"value"}'
mindroom-agent calls get CALL_ID
mindroom-agent calls wait CALL_ID
mindroom-agent context list
mindroom-agent context read NAME --offset 0 --limit 8000
```

Tool names are qualified by toolkit, and `TOOLKIT.FUNCTION` works anywhere `TOOLKIT FUNCTION` does.
Discovery and calls use the current agent's permissions and requester context.
Use command-specific `--help` for arguments and options.

`tools call` waits up to 30 seconds and prints the call's receipt: call ID, status, and the outcome once finished.
Use `--timeout SECONDS` to wait longer, or `--timeout 0` to return at once.
For a call that is still queued, running, or waiting for a decision, `calls wait` polls for up to 30 seconds (or `--timeout SECONDS`) and then returns the current receipt with exit code `3`; reaching the timeout does not cancel the tool.
Waiting pauses that shell command and its response; other conversations continue.

Arguments come from `--json`, a final positional argument (`tools call TOOLKIT FUNCTION '{...}'`), `--json-file arguments.json`, or `--json-stdin`.
Use only one of these; the value must be a JSON object, and omitting all of them sends an empty object.
Use `--call-id UUID` to fix the call ID before submission; reusing an ID with the same tool and arguments joins the existing call, and different arguments are rejected.

| Command | Options |
| --- | --- |
| `tools list` | `--cursor`, `--limit` (default 100) |
| `tools search QUERY` | `--toolkit`, `--limit` (default 20) |
| `tools describe TOOLKIT FUNCTION` | None |
| `tools call TOOLKIT FUNCTION [JSON]` | `--call-id`, `--json`, `--json-file`, `--json-stdin`, `--timeout` (seconds to wait for the result, default 30) |
| `calls get CALL_ID` | None |
| `calls wait CALL_ID` | `--timeout` (polling seconds, default 30) |
| `context list` | `--cursor`, `--limit` (default 100) |
| `context read NAME` | `--offset` (default 0), `--limit` (default 8000) |

`context read` accepts only names returned by `context list` or by other commands, not filesystem paths.
Large schemas return a context name for paged reading.
Large tool results return a preview and a workspace file path holding the full output; for an agent without a workspace, they are shortened instead.
Images, audio, and files are handled like ordinary response attachments.

## Exit codes

| Code | Meaning |
| --- | --- |
| `0` | Discovery succeeded or the call completed |
| `1` | Call failed or was cancelled |
| `2` | Invalid input or rejected request |
| `3` | Call remains queued, running, or waiting |
| `4` | Authority or transport unavailable; submission outcome may be unknown |

Scripts using `set -e` must handle exit code `3` explicitly.
After exit code `4`, check the original call ID before retrying, because submitting with a fresh ID may repeat a side effect.

## Approvals, shells, and limits

CLI calls follow the agent's existing approval rules and interactive questions, and an approval applies to the exact tool and arguments shown.
Each Bash command starts a fresh shell: workspace files persist, but shell variables and directory changes do not carry over.
Shell commands can run in parallel, and each keeps its own calls and media.
A command can submit new calls only while it is running or its earlier calls are finishing; later calls, including those from background commands whose Bash call has returned, are rejected rather than queued.
`calls wait` on earlier receipts works until the response ends.

CLI calls are limited by the agent's `max_tool_calls_per_turn`, counted separately from its direct tool calls; calls past the limit return a failed receipt.
One response keeps at most 1024 call receipts and runs at most 64 CLI operations at once; further submissions are rejected with an error.
Call receipts do not survive a restart.
A pending approval still resumes after a restart once it is decided, but MindRoom does not rerun the Bash script that requested it; when the approval was for the Bash command itself, the agent is told the command did not run and can issue it again.

## Deployment requirements

Minimal Bash runs where the agent's ordinary shell runs.
Every mode that uses `mindroom-agent` needs the `shell` tool on the agent and MindRoom running with its API server, not `--no-api`.
Minimal mode and minimal subagents also need:

- the shell's run, check, and kill commands allowed;
- an agent workspace, which `memory_backend: file` or a `private` agent provides.

`!mode <agent> minimal` lists every missing requirement at once.
Set environment settings in the runtime's `.env` and restart MindRoom.

### Shell in MindRoom itself

When the agent's shell runs in the MindRoom process, which is the default without a worker backend, nothing else is needed.
Such an agent is already fully trusted, as described in [the security posture](https://docs.mindroom.chat/architecture/security-posture/).

### Shell in a worker

When the agent's shell runs in a worker, Bash runs in that same worker, which must use an image from the same MindRoom release.
Worker shells can reach MindRoom's API, so the API must be locked down:

- Set `MINDROOM_API_KEY`; `mindroom run` adds a generated key to `.env` when Docker or Kubernetes workers are configured and the dashboard has no credential, while an explicitly empty `MINDROOM_API_KEY=` keeps open access and leaves minimal mode unavailable.
- With trusted-upstream authentication enabled, set `MINDROOM_TRUSTED_UPSTREAM_REQUIRE_JWT=true`.
- Unset `OPENAI_COMPAT_ALLOW_UNAUTHENTICATED`.

Workers call MindRoom at `MINDROOM_AGENT_CLI_PRIMARY_URL`, a plain `http(s)://host:port` origin, when it is set.
Otherwise MindRoom uses its own API address; with Docker workers and the default API bind on every interface, workers reach it through `host.docker.internal`.
Kubernetes workers and shared static runners need `MINDROOM_AGENT_CLI_PRIMARY_URL` unless the API listens on a specific non-loopback address.
An API that listens only on loopback cannot be reached from workers, and a host firewall may drop connections from Docker networks; a `mindroom-agent` call that cannot connect names the address it tried.
With the runtime chart's worker egress policy, allow the API through `egressProxy.networkPolicy.extraEgress`, and admit workers in `networkPolicy.apiIngressFrom` when that list is set, because `mindroom-agent` connects directly rather than through the egress proxy.

The shell receives a grant scoped to its own active response, not provider or administrator credentials, and provider credentials stay where the tools already run.
The grant cannot select another agent, requester, or conversation, and it stops working when the response ends.
Other code in the same worker can read the grant while the response runs, and anything that copies it can use the same permissions from any host that reaches MindRoom's API until then; agents and requesters that share a worker already share its trust.
The external MCP gateway keeps its own authentication and tool restrictions; minimal mode does not widen them.

## Compare modes

A smaller initial prompt alone does not establish lower total cost or better task quality.
To evaluate minimal mode, run the same tasks with the same agent, model, tool configuration, and starting workspace in both modes, including tool-heavy work, approval waits, and tasks that depend on long instructions.
Compare task success, input and output tokens, elapsed time, editing failures, and discovery calls, and record the model and configuration versions.

# Background Python Scripts

The `script` tool lets an agent start a Python program that keeps running after the chat turn ends.
Use it for watchers, polling loops, and other small automations that should wake the agent only when something changes.
The script can call a chosen subset of the agent's tools through the `MindRoomTools` SDK, with the same hooks, approval rules, worker routing, and audit trail as an ordinary tool call.

Treat `script` as trusted arbitrary code with authority comparable to `python` or `shell`.
`allowed_tools` limits only calls made through `MindRoomTools`; it does not restrict Python imports, filesystem access, operating-system calls, subprocesses, or direct network clients in the script.

## Enable The Tool

Add `script` to the agent that will own the process, together with the toolkits the script may call.
This example lets a watcher read a URL and send a Matrix message to its own agent when the value changes.

```yaml
models:
  default:
    provider: anthropic
    id: claude-sonnet-5-5

agents:
  watcher:
    display_name: Watcher
    role: Watch configured values and investigate meaningful changes.
    model: default
    rooms: [operations]
    tools:
      - script:
          allowed_tools: [website, matrix_message]
          max_concurrent_runs: 3
          max_tool_calls_per_minute: 30
          max_runtime_hours: 24
      - website
      - matrix_message
    instructions:
      - Use background scripts only for bounded, observable automation.
      - Cancel watchers that are no longer needed.

defaults:
  tools: []
```

Scripts also need a worker backend or explicit local execution; see [Worker And Network Requirements](#worker-and-network-requirements).

| Option | Default | Meaning |
|--------|---------|---------|
| `allowed_tools` | `[]` | Toolkit names (not function names) the script may call through `MindRoomTools` |
| `max_concurrent_runs` | `3` | Maximum unfinished runs for one requester and agent, at least `1` |
| `max_tool_calls_per_minute` | `30` | Maximum new SDK calls per minute for one run, at least `1`; automatic SDK retries and status polling do not count |
| `max_runtime_hours` | `24` | Maximum lifetime of one run in hours, positive and finite; the deadline holds even while the primary runtime is offline |

Each run keeps the limits and `allowed_tools` value in effect when it started.

### Which tools a script can call and when it needs approval

- A non-empty `allowed_tools` list restricts the script to those toolkits, and their functions run without approval.
- An empty list lets the script call every eligible toolkit on the agent, but each call waits for approval unless a `tool_approval` rule auto-approves it.
- Unknown or ineligible toolkit names, including `*`, make `start_script` fail with an error.
- The `browser`, `script`, `compact_context`, `delegate`, `dynamic_tools`, `dynamic_workflow`, `memory`, `self_config`, and `skill_manage` toolkits are never available to scripts, even when the agent has them.
- Listing `claude_agent`, `config_manager`, or `scheduler` in `allowed_tools` does not auto-approve their calls; only a `tool_approval` rule can.
- Operator [`tool_approval`](https://docs.mindroom.chat/tool-approval/) rules are checked first, so a matching `require_approval` rule still pauses the call, and functions that declare their own confirmation still ask for it.
- Only the original requester can approve or deny a script's call, and approval cards for a cancelled or finished run are settled without running the call.

Access is checked again on every call.
Removing a toolkit or function from the agent, or the requester losing access to the room or agent, takes effect immediately without restarting the script.
Adding toolkits later, including expanding `allowed_tools`, never gives a running script new unapproved access.

## Control Functions

The agent receives five control functions.

```text
start_script(
    source: str | None = None,
    path: str | None = None,
    name: str | None = None,
    resource_profile: Literal["small", "standard", "large"] | None = None,
)
get_script_resource_profiles()
get_script(run_id: str)
cancel_script(run_id: str, force: bool = False)
list_scripts(include_finished: bool = True)
```

- `start_script` requires exactly one of `source` (inline Python) or `path` (relative to the agent workspace), limited to 128 KiB.
  A `path` file is copied at launch, so later edits do not change the running program.
  `name` is an optional label shown in status and list results.
- `resource_profile` selects a Kubernetes CPU and memory profile; omitting it uses the configured default.
  `get_script_resource_profiles` shows the default and each profile's exact requests and limits, and start, status, and list results report the selected profile.
  An explicit profile fails on a backend that cannot enforce resource profiles.
  Operators define the profiles as described in [Kubernetes deployment](https://docs.mindroom.chat/deployment/kubernetes/) and the [environment variable reference](https://docs.mindroom.chat/configuration/#workers-and-background-scripts).
- `get_script` returns the run state plus recent process output when available.
- `list_scripts` returns only runs owned by the current requester and agent.
- `cancel_script` immediately revokes the script's tool access, requests graceful termination, force-kills after a short grace period, and reports `cancelled` once the process has exited.
  `force=True` kills the process without the graceful step.

## Calling Agent Tools From A Script

The worker environment includes the `mindroom.script_sdk` client, which reads its connection settings from the environment the launcher provides.
Create one `MindRoomTools` instance and call tools by toolkit name, function name, and JSON-compatible keyword arguments.

```python
from mindroom.script_sdk import MindRoomTools

tools = MindRoomTools()
result = tools.call("website", "read_url", url="https://example.org/status.txt")
print(result, flush=True)
```

`MindRoomTools.call(toolkit_name, function_name, **arguments)` blocks until the call finishes and returns a JSON-compatible value.
Arguments must have an unambiguous strict-JSON representation.
A result larger than about 64 KiB fails the call with kind `result_too_large`.
Calls within one run execute one at a time, in order.
The SDK retries transient failures automatically without repeating the side effect.

Failures raise `MindRoomToolCallError`, which exposes `kind`, `retryable`, and `call_id` so a script can log or stop predictably.
A denied approval raises a non-retryable error with kind `approval_denied`.
An `indeterminate` call means MindRoom accepted the call but cannot prove whether its side effect happened, so the script must not repeat it automatically.

## Complete Watcher Example

This watcher polls a text endpoint and wakes the same agent once per observed value change.
Replace the URL and agent name with values for your deployment.

```python
from __future__ import annotations

import time

from mindroom.script_sdk import MindRoomTools


STATUS_URL = "https://example.org/controlled-status.txt"
AGENT_NAME = "watcher"
POLL_SECONDS = 15


def main() -> None:
    tools = MindRoomTools()
    previous = tools.call("website", "read_url", url=STATUS_URL)

    while True:
        time.sleep(POLL_SECONDS)
        current = tools.call("website", "read_url", url=STATUS_URL)
        if current == previous:
            continue

        previous = current
        tools.call(
            "matrix_message",
            "matrix_message",
            action="send",
            message=f"The watched value changed; inspect {STATUS_URL} now.",
            recipient=AGENT_NAME,
        )


if __name__ == "__main__":
    main()
```

The script runs in the room, thread, and requester context it was started from, so omitting `room_id` sends to that conversation, and sending to another room still requires normal Matrix authorization.
Setting `recipient` to the agent's own name starts a new turn for that agent; see [Matrix Message](https://docs.mindroom.chat/tools/matrix-message/#agent-conversations) for recipient rules.
Self-messaging requires a human requester, so start a self-triggering watcher from a turn a person started.
Make the watcher edge-triggered: update its observed value before sending, and never react to its own unchanged output.

## Worker And Network Requirements

Run scripts on a dedicated Docker or Kubernetes worker backend as described in [Sandbox Proxy](https://docs.mindroom.chat/deployment/sandbox-proxy/).
The shared `static_runner` sandbox is not supported.
Workers should run the same MindRoom release as the primary; see [Keeping worker versions matched](https://docs.mindroom.chat/deployment/sandbox-proxy/#keeping-worker-versions-matched).
Without a worker backend or explicit local execution, `start_script` fails with `Background scripts require a worker or an explicitly disabled sandbox.`

Workers must reach the primary's script gateway over the network.
Set `MINDROOM_SCRIPT_GATEWAY_URL` to the complete worker-reachable gateway base, including `/api/script-gateway`.
With Docker, `MINDROOM_PUBLIC_URL` can name the reachable MindRoom origin instead, and MindRoom appends `/api/script-gateway`.
Missing, malformed, credential-bearing, unresolvable, unspecified, or loopback gateway addresses are rejected, as are URLs with a query string or fragment.

```bash
export MINDROOM_WORKER_BACKEND=docker
export MINDROOM_DOCKER_WORKER_IMAGE=mindroom:dev
export MINDROOM_SANDBOX_PROXY_TOKEN=replace-with-a-long-random-token
export MINDROOM_SCRIPT_GATEWAY_URL=https://mindroom.example.org/api/script-gateway
```

### Kubernetes

Kubernetes scripts stay disabled until the gateway runs on a listener that exposes nothing else, because workers that can reach the general API listener get more authority than the script gateway grants.
Until then, `start_script` fails with `Kubernetes background scripts require a gateway-only listener; use Docker or explicit unsafe-local mode until that boundary is configured.`

- `MINDROOM_SCRIPT_GATEWAY_PORT` makes the primary serve a second listener that answers only `/api/script-gateway` routes.
- `MINDROOM_SCRIPT_GATEWAY_URL` must name that listener explicitly; `MINDROOM_PUBLIC_URL` is not used on Kubernetes.
- `MINDROOM_SCRIPT_GATEWAY_ISOLATED=true` attests that workers cannot reach other primary API routes through that listener.
  The flag does not create network isolation, and the main API port is unchanged, so network policy must still keep workers off it.

The runtime Helm chart's `scriptGateway.enabled` value configures the listener, its `<fullname>-script-gateway` ClusterIP Service, the worker and control-plane NetworkPolicies, `NO_PROXY`, and all three variables, as described in the [runtime chart README](https://github.com/mindroom-ai/mindroom/blob/main/cluster/k8s/runtime/README.md#background-script-gateway).

```bash
export MINDROOM_WORKER_BACKEND=kubernetes
export MINDROOM_SCRIPT_GATEWAY_PORT=8767
export MINDROOM_SCRIPT_GATEWAY_URL=http://mindroom-runtime-script-gateway.mindroom.svc.cluster.local:8767/api/script-gateway
export MINDROOM_SCRIPT_GATEWAY_ISOLATED=true
```

With Agent Vault enabled, the script's own direct network requests do not receive Agent Vault credential injection, while its `MindRoomTools` calls run through normal tool routing and keep the configured Agent Vault boundary.

### What a script's worker can access

- Each run gets its own dedicated worker and filesystem root, even when other runs belong to the same requester and agent.
- The worker sees the requester-and-agent scoped workspace (see [Filesystem isolation and agent data](https://docs.mindroom.chat/deployment/sandbox-proxy/#filesystem-isolation-and-agent-data)), and the script can read and modify files there; runs of the same requester and agent share that workspace, so they see each other's files in it.
- The script can read operator-provided worker environment values, including backend `extra_env` and workspace environment overlays.
- Global worker credentials are not copied into script workers; `MindRoomTools` calls get their normal authority through the gateway, and each call runs where that tool normally runs, not in the script's worker.
- Worker mode isolates processes and files, not the network: direct network access follows the worker's Docker, host, and egress policy, and `allowed_tools` does not govern it.
- [Private agents](https://docs.mindroom.chat/configuration/agents/#private-instances) must use `private.per: user_agent`; other private scopes fail with `Background-script workers for private agents require private.per=user_agent.`

### Local execution

Setting `MINDROOM_SANDBOX_EXECUTION_MODE` to `off`, `local`, or `disabled` runs scripts in local execution mode instead of a worker.
Local execution is marked unsafe, runs under the primary host account, inherits the primary process environment, and provides no secret isolation.
It requires Linux and fails on other platforms with `Background scripts require Linux process-group containment.`
Use local execution only for trusted development scripts.

## Lifecycle And Failure Semantics

| Run state | Meaning |
|-----------|---------|
| `starting`, `running` | The run is launching or active |
| `exited` | The process returned exit code zero |
| `failed` | Launch failed or the process returned a nonzero exit code |
| `cancelled` | Cancellation was requested and the process has exited |
| `interrupted` | MindRoom stopped or lost the run for one of the reasons below |

Tool calls are `pending`, `completed`, `failed`, or `indeterminate`.
Cancelling a run cannot roll back a side effect a tool already started; such a call is reported `indeterminate` rather than cancelled or failed.

### Restarts, upgrades, and reloads

Kubernetes runs keep running through primary restarts, compatible upgrades, and config reloads that do not affect their owner.
An upgraded image applies only to new runs; existing runs stay on the image they started with.
While MindRoom starts up or replaces its worker backend, new launches return an error and must be retried once MindRoom is ready, while SDK calls from running scripts are retried automatically.
Keep the isolated gateway address stable across upgrades.

Docker and local runs are interrupted by every primary restart.
Worker-backend replacement also interrupts Docker runs.

A run is also interrupted when:

- its worker or supervisor is lost, for example through node loss;
- its owning agent, its `script` tool, or the requester's authorization is removed;
- a reload changes the owning agent's execution isolation or assigned knowledge base paths, or changes `defaults.worker_grantable_credentials`;
- any plugin reload happens, even for plugins the script does not use;
- a restart changes what the run depends on, such as the gateway URL, worker ownership or storage, the selected resource profile's resources, or the `MINDROOM_SCRIPT_GATEWAY_ISOLATED` attestation.

Changing an unused resource profile does not interrupt runs.
MindRoom never relaunches an interrupted script automatically, so design watchers to checkpoint their observed state for a deliberate relaunch.

If the Docker daemon, worker storage root, or worker name prefix changed while MindRoom was offline, script launches stay blocked until the old runs are retired.
Restore the previous settings, restart once so MindRoom can retire those runs, then apply the new settings.

### Output and retention

`get_script(run_id)` keeps returning up to 64 KiB of process output after the run ends.
Finished runs, their tool-call receipts, and approval records are kept for 30 days by default.
Set `MINDROOM_SCRIPT_RETENTION_SECONDS` to change the window; see the [environment variable reference](https://docs.mindroom.chat/configuration/#workers-and-background-scripts).

# Worker Computer

Worker Computer gives an agent a visible, persistent Chromium browser inside its dedicated worker, and lets the user watch it, take control, and hand it back from MindRoom Chat's Computer panel.
Use it when a user wants to see what the agent is doing in a browser, step in for logins or CAPTCHAs, or preview a web app the agent is building.
The browser shares the worker's files with the agent's shell and file tools.
Agents with the [`chat_ui`](https://docs.mindroom.chat/tools/chat-ui/) toolkit can open the panel for the user; see [Control the browser, then show it](https://docs.mindroom.chat/tools/chat-ui/#control-the-browser-then-show-it) for that call.

## Requirements and opt-in

Worker Computer needs a dedicated **Docker** or **Kubernetes** worker running the current MindRoom worker image.
The `static_runner` backend and local process execution do not support it.
It is disabled by default.

Set these values on the **primary MindRoom runtime**, alongside the normal worker settings from [Sandbox Workers](https://docs.mindroom.chat/deployment/sandbox-proxy/):

```dotenv
MINDROOM_WORKER_BACKEND=docker
MINDROOM_DOCKER_WORKER_IMAGE=mindroom:dev
MINDROOM_DOCKER_WORKER_SECURITY_POLICY=computer
MINDROOM_WORKER_COMPUTER_ENABLED=true
MINDROOM_COMPUTER_ALLOWED_ORIGINS=["https://chat.example.org"]
MATRIX_HOMESERVER=https://matrix.example.org
MATRIX_SERVER_NAME=matrix.example.org
```

`MINDROOM_DOCKER_WORKER_SECURITY_POLICY=computer` is required on Docker; see [Browser and container sandboxing](#browser-and-container-sandboxing).
For Docker development, build the image with:

```bash
docker build -t mindroom:dev -f local/instances/deploy/Dockerfile.mindroom .
```

Give the agent exactly one worker-routed browser provider, `browser` or [`browser_mcp`](#native-playwright-mcp-provider), and an effective `user_agent` worker scope, so the browser and shell share one worker per requester and agent.
Shared agents set `worker_scope: user_agent` (directly or through `defaults.worker_scope`); [private agents](https://docs.mindroom.chat/configuration/agents/#private-instances) set `private.per: user_agent` instead, because they cannot also set `worker_scope`:

```yaml
agents:
  researcher:
    display_name: Researcher
    role: Research with a browser and save results.
    model: default
    tools: [browser, shell, file]
    worker_tools: [browser, shell, file]
    worker_scope: user_agent
```

Keep the `browser` tool's default `target: host`; the connected-user `target: desktop` is a separate feature and does not run in the Computer.
Browser URL restrictions still apply, so private networks stay blocked unless allowed.
The `browser` tool uses the worker image's bundled Chromium in a Computer unless `BROWSER_EXECUTABLE_PATH` is set.
For worker egress proxies, see [Egress Proxy for Browsers](https://docs.mindroom.chat/tools/web-scraping-and-browser/#egress-proxy-for-browsers); the same rules apply to both browser providers.

With the runtime Helm chart:

```yaml
workers:
  backend: kubernetes
  computerEnabled: true
env:
  extra:
    - name: MINDROOM_COMPUTER_ALLOWED_ORIGINS
      value: '["https://chat.example.org"]'
```

Use the chart's existing worker image, authentication secret, storage, and RBAC settings.
The hosted instance chart runs no dedicated workers, so Computer is available only through the runtime chart or direct Docker and Kubernetes worker deployments.
Changing `MINDROOM_WORKER_COMPUTER_ENABLED` replaces existing workers; worker files and browser profiles are kept.

### Preview a local web app

The Computer browser can open a web server that the agent's shell started in the same worker.
Bind the server to `127.0.0.1` and open its URL, for example `http://localhost:5173`, with the browser tool.
The user sees the app in the Computer panel without any public port or preview URL.

This works with both `browser` and `browser_mcp` in a Computer, without `allow_private_networks`.
Use `localhost` or a literal loopback address; aliases such as `localhost.localdomain` are treated as private network names and stay blocked.
Other private addresses and cloud metadata endpoints remain blocked by default.
Pages opened in the Computer can reach any service listening on the worker's loopback interface, as in a local development browser.

## Connect MindRoom Chat

Set the trusted origin of the MindRoom runtime API, without a path, in Chat's operator-managed `config.json`:

```json
{
  "mindroom": {
    "computers": {
      "apiUrl": "https://computer.example.org"
    }
  }
}
```

The shipped value is empty, which hides the Computer action.
Remote origins require HTTPS; loopback HTTP works for local development.
Chat shows the Computer action when this origin is set and the room has a joined MindRoom agent; with several agents, the user picks one first.
Point `apiUrl` at the runtime that owns the agent's workers; a host that only serves Matrix, Chat, or local provisioning (`/v1/local-mindroom/*`) does not serve computers.

On the runtime, `MINDROOM_COMPUTER_ALLOWED_ORIGINS` is a JSON list of exact Chat origins allowed to open computers.
Each entry must be a scheme and host with an optional port, with no path or wildcard, and HTTP is allowed only for `localhost` and literal loopback addresses.
One invalid entry disables the whole list.

Opening a computer requires that the requester and the agent are both joined to the room, that the agent's [access policy](https://docs.mindroom.chat/authorization/#responder-access) lets the requester use it, and that the requester belongs to the configured Matrix server.
A session lasts at most one hour.
Each requester can hold up to eight concurrent viewer sessions, and a runtime up to 256; closing a viewer frees its slot.

### Reverse proxy and host routing

Route `/api/computers/*`, including WebSocket upgrades, to the **MindRoom runtime API**.
Preserve the `Origin` and `Sec-WebSocket-Protocol` headers and allow long-lived WSS connections.
These routes use their own session authentication, separate from dashboard authentication.
Do not log bearer tokens or WebSocket subprotocols.

For example, a dedicated Caddy origin pointing to a runtime on the same host:

```caddy
computer.example.org {
    reverse_proxy /api/computers/* 127.0.0.1:8765
}
```

Use your actual runtime address and port.
Expose only this gateway; the worker's display has no public listener.

## Watch, take control, and resume

<video controls playsinline preload="metadata" aria-label="A user watches the agent's browser, takes control, and hands it back" style="width: 100%">
  <source src="https://github.com/user-attachments/assets/63868e17-6824-4073-8904-e1b7abc4b173#t=0.1" type="video/mp4" media="(prefers-color-scheme: dark)">
  <source src="https://github.com/user-attachments/assets/cc079b2f-6dbf-4509-9fdc-4ec21da85d7a#t=0.1" type="video/mp4">
</video>

- **Watch** shows the browser without input rights.
- **Take control** waits for the agent's current browser action to finish, then gives input to one viewer.
  While the user holds control, the agent's browser calls return a blocked result; background shell processes keep running.
- **Resume agent** releases control, returns the screen to watch mode, and sends one ordinary message mentioning the agent in the originating room or thread.
  If that message fails to send, Chat shows the error and does not retry.
- **Close** disconnects this viewer and releases its control; the browser and files stay for later work.
- **Stop** closes the browser and display and ends the session.
  **Start computer** creates a fresh session; profiles and files remain.

Changing room, thread, account, or selected agent closes the old viewer.
On desktop the panel sits beside the conversation and closes the Members drawer; on mobile it fills the screen and has a close button.
Keyboard input over a controlled screen goes to the browser, not the composer.

The browser keeps running between tool calls, including tabs the user opened.
When the agent focuses a tab or acts on a page, that page comes to the foreground, so the agent's screenshots and snapshots show what the viewer sees.
The `browser` provider identifies tabs by stable target IDs, while `browser_mcp` uses tab indices that can change.

## Native Playwright MCP provider

`browser_mcp` gives the agent native Playwright tools such as `browser_navigate`, `browser_snapshot`, `browser_click`, `browser_type`, `browser_evaluate`, `browser_tabs`, `browser_take_screenshot`, `browser_file_upload`, and `browser_pdf_save` instead of the action-based `browser` tool:

```yaml
agents:
  researcher:
    display_name: Researcher
    role: Research with a browser and save results.
    model: default
    tools: [browser_mcp, shell, file]
    worker_scope: user_agent
```

`browser_mcp` runs in workers by default and works only in an enabled Computer.
It uses the Playwright MCP server and Chromium bundled in the worker image, with vision and PDF capabilities.
The agent cannot install packages, change the server or its settings, run server-side code with `browser_run_code_unsafe`, install browsers, or change request routing.
`browser_evaluate` runs JavaScript in the page only.
Service workers are blocked.

| Option | Type | Default | Notes |
| --- | --- | --- | --- |
| `allow_private_networks` | `boolean` | `false` | Allow trusted private destinations; metadata and link-local addresses stay blocked. |

```yaml
agents:
  researcher:
    tools:
      - browser_mcp:
          allow_private_networks: true
    worker_scope: user_agent
```

Screenshots without a `filename` are shown to the model and also saved in the workspace; passing a `filename` saves without showing.
Automatic screenshots, PDFs, and downloads go to `browser/` in the workspace, and explicit filenames resolve relative to the workspace, so use names like `browser/page.png` to keep them together.
On later turns, open a saved image with `view_file(path=...)` from the `attachments` toolkit.
Captures are never posted to Matrix automatically.
`mindroom_output_path` can save text results to a file but not images.
Upload and drop paths must name existing regular files inside the worker workspace.

List tabs again before selecting one by index.
`browser_resize` changes the page viewport, not the window size.
Chromium may show a banner about resolver flags; it is expected.

## Storage and lifetime

Browser profiles live under `browser-profiles/<profile>` in the dedicated worker's persistent storage root; `browser_mcp` uses `browser-profiles/native-mcp`.
Completed downloads land in the workspace's `browser/` directory, where the shell and file tools can read them; for `browser`, a custom [`output_dir`](https://docs.mindroom.chat/tools/web-scraping-and-browser/#browser-configuration) replaces that directory.
The `browser` provider adds a unique prefix to download filenames; `browser_mcp` keeps the native name.
Profiles and files survive stopping the computer and recreating the worker while the storage volume remains.
Stopping the computer does not sign out of sites or reset the profile.

To reset a profile, stop the computer and stop or recycle its dedicated worker.
Remove only that worker's `browser-profiles/<profile>` directory from its persistent storage; the next browser action creates a new profile.
Do not remove the whole worker storage root unless you also intend to delete its files.

An open viewer keeps the worker from idling out.
After the last viewer disconnects, the worker's normal idle timeout applies: `MINDROOM_DOCKER_WORKER_IDLE_TIMEOUT_SECONDS` in [Sandbox Workers](https://docs.mindroom.chat/deployment/sandbox-proxy/#dedicated-docker-worker-backend) or `workers.kubernetes.idleTimeoutSeconds` in [Kubernetes Deployment](https://docs.mindroom.chat/deployment/kubernetes/#worker-settings).
Closing the panel does not stop the worker immediately.

## Browser and container sandboxing

Chromium always runs with its Linux process sandbox enabled.
If the sandbox cannot start, the browser call fails instead of running unsandboxed.

### Docker

`MINDROOM_DOCKER_WORKER_SECURITY_POLICY` accepts `runtime_default` (the default) or `computer`.
`computer` drops all Linux capabilities, sets `no-new-privileges`, and applies a packaged seccomp profile that adds only the namespace and `chroot` operations Chromium's sandbox needs to Docker's default policy.
It applies to every dedicated Docker worker, whether or not Computer is enabled or the agent uses a browser.
Changing the policy replaces existing workers.

Configuration fails with `MINDROOM_WORKER_COMPUTER_ENABLED=true requires MINDROOM_DOCKER_WORKER_SECURITY_POLICY=computer.` when Computer is enabled under `runtime_default`.
Computer workers must also run as a non-root user.
`MINDROOM_DOCKER_WORKER_USER=root` or UID 0, including a runtime that itself runs as root and passes its UID to workers, fails configuration, and a worker that starts as root refuses requests.
An empty `MINDROOM_DOCKER_WORKER_USER` uses the image's default user.

### Kubernetes

Kubernetes workers use the pod's `RuntimeDefault` seccomp policy.
If that policy allows unprivileged user namespaces, nothing more is needed.
Otherwise, install the packaged profile `src/mindroom/workers/backends/seccomp/worker-computer.json` on every node that can run workers, under a versioned filename such as:

```text
/var/lib/kubelet/seccomp/profiles/worker-computer-578ef2b662d8e9a8.json
```

Verify its SHA-256 on every node: `578ef2b662d8e9a886132148a0b75f0388efff92ed902b16d7be2777ae3788fa`.
Kubernetes only names the file, so it cannot check the contents for you.
When the profile changes, install it at a new versioned path on all nodes before switching to it.

Select it for the worker container:

```dotenv
MINDROOM_KUBERNETES_WORKER_SECCOMP_PROFILE_JSON={"type":"Localhost","localhostProfile":"profiles/worker-computer-578ef2b662d8e9a8.json"}
```

The value must contain exactly `type: Localhost` and a relative `localhostProfile` path.
With the runtime chart, set the same object at `workers.kubernetes.seccompProfile`:

```yaml
workers:
  kubernetes:
    seccompProfile:
      type: Localhost
      localhostProfile: profiles/worker-computer-578ef2b662d8e9a8.json
```

This applies only to the main worker container; init and extra containers keep `RuntimeDefault`.
A node without the profile fails pod startup with `CreateContainerError`.
The node kernel and its security modules must also allow unprivileged user namespaces.

## Troubleshooting

| Symptom or error | Fix |
| --- | --- |
| No Computer action in Chat | Set `mindroom.computers.apiUrl` in Chat's `config.json` and make sure an agent is joined to the room. |
| `Computer stream origin is not allowed.` | Add Chat's exact origin to `MINDROOM_COMPUTER_ALLOWED_ORIGINS` and check every entry is valid. |
| `Requester and agent must both be joined to this room.` or `Requester cannot use this agent.` | Check room membership and the agent's access policy. |
| `Computer requires exactly one browser provider and explicit user_agent worker scope.` | Give the agent exactly one of `browser` or `browser_mcp` and an effective `user_agent` scope: `worker_scope: user_agent`, or `private.per: user_agent` for private agents. |
| `Computer requires browser tools routed to the worker.` | Add the browser provider to `worker_tools`. |
| `Computer requires a dedicated Docker or Kubernetes worker backend.` | Set `MINDROOM_WORKER_BACKEND` to `docker` or `kubernetes`. |
| `Worker computer is not enabled on this dedicated worker.` | Set `MINDROOM_WORKER_COMPUTER_ENABLED=true` on the primary runtime. |
| `Computer requester session capacity reached.` or `Computer session capacity reached.` | Close other viewers or wait for sessions to expire. |
| `...create a new session.` | Access, configuration, or the worker changed, or the session expired; reopen the panel. |
| Empty desktop after startup | Ask the agent to open a page. |
| WebSocket fails | Check the gateway's TLS, WebSocket upgrades, and routing to the runtime. |

Use **Reconnect** after a transient stream failure.
If the browser stops responding, recycle the dedicated worker; profiles and files stay in persistent storage.

# Matrix Desktop Bridge

The `desktop` tool lets a cloud-hosted MindRoom agent use a local computer without opening an inbound port.
It can inspect and operate explicitly allowlisted applications, read explicitly selected folders, and run shell commands that a person at the computer approves.
Applications, [read-only folders](#read-only-folders), and [shell commands](#shell-commands) are separate local choices, and each starts disabled.
The computer runs an outbound Matrix client, and commands and responses are end-to-end encrypted between the paired devices.
Set up and run the bridge from the [macOS app](https://docs.mindroom.chat/installation/macos-app/#computer-access) or from the terminal; both use the same saved setup.

## Requirements

- **Matrix account**: Use your chat account or a dedicated Matrix account for the local bridge.
  Setup adds a new device to your chat account when no local Desktop session is saved.
  For password login, a dedicated account limits the impact of exposing that password on the local computer.
  The desktop account and the cloud MindRoom agent must be able to exchange to-device events and media through the same Matrix federation.
- **Local package**: `mindroom desktop run` installs the `desktop` extra when it is missing, unless `MINDROOM_NO_AUTO_INSTALL_TOOLS=1` is set.
  To install it in advance, run `uv tool install 'mindroom[desktop]'` on the computer being controlled.
- **macOS applications**: Accessibility permission is required for state and control, and screenshots require macOS 14 or newer and Screen Recording permission.
  When `mindroom desktop run` starts from a terminal, macOS attributes both permissions to that terminal app and applies a new grant only after the app is quit and reopened, even if it already appears enabled; inside tmux, also restart the tmux server with `tmux kill-server`.
- **Linux applications**: Only screenshot observation of the `primary-screen` app ID, plus coordinate input during a control lease, is available on an active X11 desktop; Wayland is not supported.
- **Windows applications**: The terminal commands offer the same `primary-screen` observation and coordinate input, which have not been verified on a real Windows computer.
  On Linux and Windows, coordinate input is not bound to an app window, so whatever window covers the point receives it.
- **Read-only folders and shell commands**: These need no application selection or macOS permissions, and they require macOS or Linux.
  On Windows, `mindroom desktop access` refuses to save them and `mindroom desktop run` refuses to start while either is saved; run `mindroom desktop access --clear-folders --no-shell` to turn them off.
- **Signed-in browser profile** (optional): Node.js 18 or newer, Chrome or Brave, and the official Playwright MCP Bridge extension in the browser profile MindRoom will use.

Application access needs an unlocked graphical session; folder and shell access also work on a headless computer when no applications are selected.

## 1. Configure the Agent

Add the `desktop` tool to a normal or private agent:

```yaml
agents:
  computer:
    display_name: Computer Agent
    role: Operate my locally authorized applications one step at a time
    tools:
      - desktop:
          defer: true
          timeout_seconds: 30
```

| Option | Type | Default | Notes |
| --- | --- | --- | --- |
| `timeout_seconds` | number | `30` | From 1 to 120; bounds each call and each shell-output download. `run_shell` always waits up to 120 seconds so the person at the computer has time to decide. |

Never put device identity fields in agent YAML for the `desktop` tool; each user pairs their own computer through chat.
Pairings are already scoped to one requester and one agent, so add `private: {per: user_agent}` only when the agent's whole runtime and state should also be requester-private.
The tool needs a live Matrix room and runs in the primary agent process, so it is unavailable in [OpenAI-compatible API](https://docs.mindroom.chat/openai-api/) runs.
To drive the user's signed-in browser, also configure the [`browser`](https://docs.mindroom.chat/tools/web-scraping-and-browser/#browser) tool with the bridge's device identity and follow [Use the Signed-In Browser Profile](#use-the-signed-in-browser-profile).

## 2. Pair the Computer

1. In a private Matrix room containing only you and one Desktop-enabled agent, plus the router when it serves the command, send `!desktop setup`.
   The reply contains setup data for the macOS app, an equivalent `mindroom desktop setup` command, and a short-lived pairing code.
   The pairing code is a secret, so MindRoom refuses `!desktop` when any other member or pending invite is in the room or when it cannot confirm the room's membership.
2. In the macOS app, paste the reply's JSON setup data into **Computer access** and follow the [Computer Access steps](https://docs.mindroom.chat/installation/macos-app/#computer-access).
   In a terminal, run the full `mindroom desktop setup` command from the reply once; it reuses a saved Desktop Matrix session or logs in first, then claims the pairing.
   Add `--allow-app com.apple.TextEdit` to save an app choice at the same time.
3. Copy the printed `!desktop confirm <code> <verification>` command back into the same chat.
   The verification value ensures that only the device that claimed the code can confirm it.

Only the same requester with the same agent can confirm or use the pairing.
Until pairing is confirmed, `desktop` calls return a setup-required result while the agent's other tools keep working, and the model cannot register or replace a device itself.

Repeating setup for the same controller keeps saved browser, capture, folder, and shell choices, and keeps app choices unless you supply new app IDs.
Setup for a different controller starts without saved folders or shell access.
If the saved session belongs to a different homeserver or Matrix user than the setup command names, setup exits without pairing; pass `--storage-path` for a separate setup or run `mindroom desktop login --replace`.
The separate `mindroom desktop login` and `mindroom desktop pair` commands remain available for manual recovery.

### Log In Manually

`mindroom desktop login` creates the local Desktop Matrix device.
By default it opens Matrix SSO in the browser when the homeserver offers it and otherwise uses password login; `--login-method password` or `--login-method sso` selects one explicitly.

```bash
# SSO
mindroom desktop login --homeserver https://matrix.example.org

# Password login for a dedicated account such as @my-laptop:example.org
mindroom desktop login \
  --user-id @my-laptop:example.org \
  --homeserver https://matrix.example.org \
  --login-method password
```

Password login requires `--user-id` and prompts for the password with hidden input unless `MINDROOM_DESKTOP_MATRIX_PASSWORD` is set.
During SSO, `--user-id` is an optional identity check, and MindRoom rejects the new session if SSO returns a different Matrix user.
Use `--sso-idp ID` to pick one of several SSO providers, and `--no-open-browser` to print the URL instead of opening it.
MindRoom does not create cross-signing keys for the device; the bridge trusts the exact device ID and Ed25519 key that login prints:

```text
User: @my-laptop:example.org
Device: ABCDEFGHIJ
Ed25519: desktop-device-fingerprint
```

All `mindroom desktop` commands use the `~/.mindroom` runtime regardless of the current directory.
An explicit `--config`, `--storage-path`, `MINDROOM_CONFIG_PATH`, or `MINDROOM_STORAGE_PATH` selects another runtime and must be reused for login, setup, pairing, and `mindroom desktop run`.
On Unix, the bridge refuses to load a saved session file that group or other users can read; keep it owner-only (`0600`).
Run `mindroom desktop login --replace` to replace a saved session with a fresh device.

### Homeservers Behind an Identity-Aware Proxy

For a homeserver protected by Cloudflare Access, install the [`cloudflared` CLI](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/) and add `--cloudflare-access` to `mindroom desktop login` or `mindroom desktop setup`:

```bash
mindroom desktop login \
  --homeserver https://matrix.example.org \
  --cloudflare-access
```

`cloudflared` opens the browser whenever Access needs authentication, and MindRoom never saves the Access token.
The session remembers the choice, so later `mindroom desktop run` commands and the macOS app need no flag.
For a session saved without it, `mindroom desktop run --cloudflare-access` enables Access for that run only.

Two environment variables on the MindRoom server change what `!desktop setup` prints:

- `MINDROOM_DESKTOP_MATRIX_HOMESERVER`: the public Matrix URL local Desktop clients should use when the server reaches Matrix through an internal address; server traffic keeps using `MATRIX_HOMESERVER`.
- `MINDROOM_DESKTOP_CLOUDFLARE_ACCESS=true`: add `--cloudflare-access` to the printed setup command.

For unattended proxies that issue machine credentials instead, put the exact request headers the proxy requires in a JSON object of string keys and values:

```json
{
  "X-Access-Client-Id": "client-id",
  "X-Access-Client-Secret": "client-secret"
}
```

Restrict the file to its owner and pass it to both login and the bridge, or set `MINDROOM_DESKTOP_MATRIX_HTTP_HEADERS_FILE` to its path:

```bash
chmod 600 ~/.config/mindroom/matrix-http-headers.json

mindroom desktop login \
  --user-id @my-laptop:example.org \
  --homeserver https://matrix.example.org \
  --matrix-http-headers-file ~/.config/mindroom/matrix-http-headers.json
```

MindRoom refuses group-readable or world-readable header files on Unix, adds the headers to every Matrix request the desktop client makes, and does not copy them into the saved session.
During SSO, the browser handles interactive proxy authentication while the header file authenticates the command-line requests.
Accept these machine credentials only for the Matrix endpoints the desktop device needs, keep normal Matrix authentication enabled, and do not combine `--cloudflare-access` with a static `cf-access-token` header.

## 3. Choose Local Access

Applications, read-only folders, and shell commands are saved separately, and the bridge exposes only what is saved.

### Choose Applications

Save applications in the macOS app's **Access** step or with repeated `--allow-app` on `mindroom desktop setup`; `--allow-app` on `mindroom desktop run` applies to that run only.
On macOS, use exact bundle identifiers such as `com.apple.TextEdit` or `com.brave.Browser`; you can look one up without granting access:

```bash
mdls -name kMDItemCFBundleIdentifier /System/Applications/TextEdit.app
```

Add only the applications needed for the current task.
The special app ID `primary-screen` exposes the whole primary display without semantic elements; on Linux and Windows it is the only usable target.
Controlling `primary-screen`, a terminal emulator, a scripting or automation app, or a browser or Matrix client signed in as you hands the agent your own authority, as the [Security Model](#security-model) warning explains.

### Choose Folders and Shell Access from the Terminal

Choose folders and shell access in the macOS app's **Access** step, or save them from the terminal:

```bash
mindroom desktop access --allow-folder ~/Documents/project-notes --shell
```

Repeat `--allow-folder` to add more existing folders, use `--clear-folders` to remove every saved folder, and use `--no-shell` to turn shell requests off.
Omitted options keep the saved values, and the command prints the saved applications, folders, and shell setting.
It requires completed setup and never starts the bridge or grants auto-approval.

## 4. Run the Local Bridge

Start the bridge from the macOS app or with:

```bash
mindroom desktop run
```

The command uses the same saved controller, allowlists, folders, shell setting, browser settings, and capture settings as the macOS app.
Stop the bridge in the interface that started it before switching interfaces.
Saved changes take effect on the next start.
Wait for `Desktop bridge online` before sending work when you need confirmation that startup completed.

Flags override saved settings for one run without changing them; [`mindroom desktop run`](https://docs.mindroom.chat/cli/#desktop-run) lists every option.
`--allow-requester`, `--allow-agent`, and `--allow-app` take exact Matrix user IDs, agent names, and app IDs, can be repeated, and do not accept wildcards.
Changing the controller requires `--controller-user-id`, `--controller-device-id`, `--controller-ed25519`, and all three allowlist options, and such a run starts without saved folders or shell access.
The `!desktop confirm` reply prints a `mindroom desktop run` command with these values filled in except the app ID.

### Grant Application Control

Applications start observe-only.
To allow input, a person at the computer grants a control lease with **Grant Control…** in the macOS app or by restarting the terminal bridge with `--allow-control`:

```bash
mindroom desktop run --allow-control --lease-minutes 15
```

`--lease-minutes` defaults to 15 and accepts 1 to 60.
After the lease expires, the bridge keeps observing but rejects every control action until a person grants another lease.
The macOS app can grant or revoke without restarting; the terminal requires a new invocation.
Leases are never saved or renewed automatically, so a restarted bridge is observe-only again.

### Approve Shell Commands in the Terminal

With shell requests saved, each command appears in the terminal running `mindroom desktop run` with its requester, agent, working directory, length, expiry, and command, and waits for one answer:

```text
[a]pprove once, [r]eject, [5]/[15]/[60] minutes, [u]ntil stopped:
```

If standard input is not an interactive terminal or the bridge runs as a background job, requests are rejected with guidance instead of waiting.
Start with `--shell-auto-approve-minutes`, from 1 to 60, to approve commands without asking for part of the run; this requires saved shell access and is never saved.
The terminal has no separate revoke command; press `Ctrl+C` to stop the bridge instead.
See [Local Approval](#local-approval) for what each choice covers.

### Use the Signed-In Browser Profile

The `browser` tool's desktop target drives the user's real browser profile through the official [Playwright MCP Bridge extension](https://chromewebstore.google.com/detail/playwright-extension/mmlmfjhmonkocbjadbfplnigmagldckm).
Install the extension in the local browser profile, then start the bridge with `--browser-extension`.
The bridge also needs at least one allowed application, saved or passed with `--allow-app`; without one, starting browser control fails with `Desktop GUI is unavailable for browser control`.
For Brave on macOS, also pass its executable and user-data root:

```bash
mindroom desktop run \
  --allow-control \
  --browser-extension \
  --browser-executable "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser" \
  --browser-user-data-dir "$HOME/Library/Application Support/BraveSoftware/Brave-Browser"
```

For Chrome, omit the two path options to use normal Chrome discovery, or provide the matching Chrome paths.
`browser_control(action="start", target="desktop")` requires the local control lease, starts Playwright MCP locally through `npx`, and opens the extension's connection page, where the user chooses an initial tab.
To reconnect after bridge restarts without another browser prompt, store the reconnect token that page shows as `PLAYWRIGHT_MCP_EXTENSION_TOKEN` in the local MindRoom `.env` file with owner-only permissions.
Treat that token as a browser-control credential, never commit it, and regenerate it from the extension page if it is exposed.

See [`browser`](https://docs.mindroom.chat/tools/web-scraping-and-browser/#browser) for the desktop target's configuration and supported actions.
For app-level control of the browser window, or for Safari and other browsers, allowlist the browser as an application.

## Security Model

- **Control is local**: Applications are observe-only until a person at the computer grants a control lease in the macOS app's **Computer access** section or the local terminal.
- **Exact identities**: The bridge accepts commands only from the paired cloud agent device, for locally allowed requesters, agents, and apps, and rejects expired or replayed commands.
- **Local allowlists**: Only applications selected locally can be listed, launched, inspected, captured, or controlled.
  An allowed app can still open a link or document in another app, which the bridge cannot then inspect or control unless that app is also allowlisted.
  Allowlisting a browser exposes whichever window and tab is selected, not one website.
- **MindRoom is excluded**: MindRoom itself (`chat.mindroom.menubar`) and its desktop helper (`chat.mindroom.desktophelper`) can never be allowlisted, because their windows grant shell auto-approval, control leases, and app access.
- **No remote authority**: Cloud configuration, chat messages, and model output cannot enable control, extend a lease, change local allowlists or folders, enable shell commands, or approve one.
- **Emergency stop**: Moving the pointer to the upper-left corner of the primary display stops control and keeps it off until the bridge restarts or a person selects **Reset Emergency Stop** in the macOS app; the reset does not grant control.
- **Local audit log**: The bridge logs each completed or rejected command without its parameters, values, or typed text.
- **One bridge per session**: While a shell command waits for local approval, the bridge refuses every control action except `launch_app`, so its own agents cannot click or type into the approval.
  Another bridge's agents could, so run at most one bridge in each logged-in graphical session.
- **Not exposed**: The bridge offers no clipboard, microphone, webcam, unlock, privilege elevation, or arbitrary local RPC, and it cannot bypass operating-system permission prompts.

!!! warning "Some allowlisted apps give an agent your own authority"
    Controlling a terminal emulator, a scripting or automation app such as Script Editor, Shortcuts, or Automator, or the `primary-screen` target lets an agent run anything your account can, without any shell approval.
    The agent can type commands into a terminal or script, and `primary-screen` input reaches whatever app is under the pointer or has focus, including MindRoom's own windows while no shell request is pending.
    Controlling a web browser or Matrix client signed in as you, such as MindRoom Chat in a browser or Element, or a browser with a local MindRoom dashboard session, lets an agent act as you: it can answer its own Matrix tool approvals, send `!` commands, and instruct other agents.
    Allowlist these only when you would also approve everything the agent could run or send as you.

Enabling the browser extension grants access to every tab and the signed-in state of the connected profile, not one tab or origin.
It is not a network sandbox: MindRoom validates URLs passed to `open`, but redirects and page scripts keep the profile's normal network reach.

### What Leaves the Computer

The Matrix homeserver sees routing metadata, timing, and encrypted media size, but not commands, screenshots, file contents, or output.
Accessibility state, screenshots, file contents, and command output become model input in the cloud, so your model provider receives them.
Accessibility data can include labels, document text, and form values not obvious from the screenshot; macOS secure text fields are suppressed and cannot be changed semantically.
Screenshots and other results can remain in agent session storage, tool traces, and automatically saved workspace files, so protect and expire that storage according to the sensitivity of the controlled applications.
Action arguments, shell commands, and paths appear in model context, approval cards, and Matrix tool traces, so never use `set_value`, `type_text`, or `run_shell` with passwords, tokens, recovery codes, or other secrets.
Screenshots, labels, web content, file contents, and command output are untrusted data and are never user authorization or instructions.
Locally, the bridge keeps command arguments and outcomes, including shell commands, file text, and inline shell output, in the owner-only `<storage>/desktop_bridge/commands.sqlite3`; it stores no plaintext screenshots.

## Applications

On macOS, `get_app_state` returns the app's accessibility tree with roles, names, values, bounds, and available actions, plus a screenshot of the app window.
The agent normally acts on an element from that state instead of guessing a coordinate, and every completed action returns fresh state for the next step.
Taking a screenshot on macOS brings the selected app to the front.

Observation actions are `status` (screen, lease, enabled capabilities, pending shell request, auto-approval time, and the caller's shell handles), `list_apps`, `get_app_state`, `screenshot`, and `request_status` (see [Uncertain Outcomes](#uncertain-outcomes)).
With `return_attachment=true`, `screenshot` also returns an `att_*` handle that `matrix_message` can send in the same turn.

Control actions require a control lease:

- `launch_app` launches or foregrounds one allowlisted app.
- `click_element`, `perform_action`, `scroll_element`, and `set_value` act on an element from the latest state; `set_value` works only on elements that report they are writable.
- `click`, `double_click`, `hover`, and `drag` use coordinates from `0` to `1000` inside the app window, not raw screen pixels, and `scroll` scrolls a bounded number of pages.
- `type_text` types up to 2,000 characters; line breaks and tabs need an element target, otherwise send Enter or Tab with `keypress`.
- `keypress` supports navigation keys, shift plus navigation, command or ctrl plus a, c, x, v, z, or f, and command or ctrl plus shift plus z.
  App-switching, quit, launch, and address-bar shortcuts are rejected because they could reach apps outside the allowlist.

Pass `observation="tree"` to skip the screenshot when semantic state is enough, or `observation="screenshot"` to omit the element list; the default is `both`.

Element references expire after 120 seconds or when the app's window or target element changes; when the bridge reports stale state, observe again instead of reusing the old reference or coordinate.
Coordinate input, typing without an element, `scroll`, and `keypress` need a fresh, complete, stable observation, so let the UI settle and observe again first.
On macOS, keyboard input fails if the app has lost focus, and pointer input fails if another window, such as a notification banner, floating panel, or Picture in Picture, covers the target point.
Windows on secondary displays are supported when the window lies on one display; move a window that spans displays fully onto one.

## Read-Only Folders

The agent can list and read inside locally selected folders, and it cannot write, create, rename, or delete anything there.

- `list_folders` returns an ID, name, and absolute path for each selected folder.
- `list_directory` takes a folder `root_id` and an optional relative `path` and returns at most 200 entries.
- `read_file` takes a `root_id` and relative `path` and returns at most 16 KiB of UTF-8 text per call, continuing from an `offset` for longer files.

Paths are relative to the selected folder, and symbolic links are never followed, so nothing outside the folder can be reached.
Only regular UTF-8 text files are read; binary files and other file types are rejected.
Local file permissions still apply, and macOS may still ask before MindRoom reads protected folders such as Desktop, Documents, or Downloads; MindRoom never requests Full Disk Access.
A saved folder that no longer exists makes startup fail with `Cannot open local folder`.

## Shell Commands

!!! warning "Shell commands run with your full account access"
    An approved command runs as your user account, with the network and every file that account can read or change, including files outside the selected folders.
    Neither the selected folders nor the working directory confine it, and it is not a sandbox.
    Approve only commands you would run yourself, and run the bridge in a dedicated operating-system account when you need stronger isolation.

Enable shell requests locally with **Allow shell command requests** in the macOS app's **Access** step or with `mindroom desktop access --shell`.

- `run_shell` runs `command`, up to 8,192 characters, with `/bin/sh -c` in `cwd`, an absolute local directory that defaults to the home directory.
  `timeout_seconds`, from 1 to 60 with a default of 30, is how long the call waits for the command to finish before returning a handle.
- `check_shell` returns a handle's newest output while it runs, and its complete output and exit code once it finishes.
  Pass `offset=0` to read a running command's output from the beginning, then continue from the returned `next_offset`.
- `kill_shell` asks a handle's command to terminate, or kills it immediately with `force=true`.

Commands or working directories padded with long runs of whitespace, invisible characters, blank lines, or combining marks are refused before approval, because padding could hide part of the request from the approver.

### Local Approval

Every `run_shell` request waits for a decision from the person at the computer, on the macOS app's approval card or at the `mindroom desktop run` terminal prompt, unless auto-approval is active.
The approver sees the exact command, working directory, requester, agent, expiry, and command length, with invisible and control characters escaped and a warning for look-alike non-ASCII characters.
The choices are reject, approve once, or approve and also auto-approve later commands for 5, 15, or 60 minutes or until shell access is revoked or the bridge stops.
A chat message, the agent, or cloud configuration cannot approve a command or grant auto-approval.
A request nobody answers within 120 seconds expires, and rejected or expired requests never run.

!!! warning "Auto-approval covers every allowed caller"
    Timed and until-stopped auto-approval approves every shell command from all locally allowed requesters and agents, not only the command that prompted it.
    Auto-approval is never saved: it ends when its time runs out, when you revoke shell access, or when the bridge stops, and a restarted bridge asks for each command again.

In the macOS app, **Allow Without Asking…** grants auto-approval before any request arrives; in the terminal, use `--shell-auto-approve-minutes`.
Revoking shell access or stopping the bridge also rejects a waiting request and kills every running command.

### Handles

A command still running after its inline wait keeps running as a handle; the agent polls it with `check_shell` and stops it with `kill_shell`.
Handles belong to the requester and agent that started them, and checking or killing your own handle needs no approval.
The macOS app lists every handle with its requester, agent, command preview, elapsed time, and state, and can kill running ones.

The bridge keeps at most 16 handles; a new command drops the handle that finished longest ago, and is refused if all 16 are still running.
Finished output is kept until it is read in full or dropped for a newer command.
Handles do not survive revoking shell access or a bridge restart, so a later `check_shell` reports an unknown handle.
When a command finishes, other processes it started in the background, such as children started with `&`, are terminated, so keep long-running work in the foreground and let it continue as a handle.

### Environment and Output

Commands run with your login shell's environment, captured once when the bridge starts, so profile changes apply at the next start.
If capture fails or takes more than 5 seconds, commands get a minimal environment of `HOME`, `USER`, `LOGNAME`, `SHELL`, `TMPDIR`, `LANG`, and a `PATH` that includes the Homebrew and `~/.local/bin` directories.
The bridge's own credentials are never passed to commands.

!!! warning "Your shell profile's exports are visible to commands"
    Anything your shell profile exports, including tokens and other secrets, is visible to every approved command.
    Commands could already read the profile files that set those values, but a command that prints one sends it to the model provider like any other output.

Each call runs in a fresh non-interactive `/bin/sh`, so directory changes and variables never carry over.
Commands have no terminal and read standard input from `/dev/null`, so they cannot answer password prompts.
Standard error is merged into standard output; redirect it with `2>file` to keep it separate.

The bridge captures up to 10 MiB of output per command and reports `output_truncated: true` when more was dropped.
Large output arrives as an encrypted Matrix attachment that the cloud tool downloads within its `timeout_seconds`.
For agents with a workspace, MindRoom's normal [tool output files](https://docs.mindroom.chat/tools/execution-and-coding/#workspace-and-tool-output-files) apply to large results.

## Uncertain Outcomes

A timeout returns the original `request_id` and an unknown outcome; the action may still be queued, may have run, or, for `run_shell`, may still be waiting for approval or running.
Call `desktop(action="request_status", request_id="...")` to read `queued`, `running`, `completed`, `unknown`, or `not_found`, including after the bridge restarts.
Only the requester and agent that sent the action can read its outcome; other callers get `not_found`, and `not_found` does not prove that the action never ran.
Do not repeat an uncertain action automatically; observe current state first, because some apps change their UI and still report an error.
Interrupted work is never run again and reports an unknown outcome.
Never resubmit or rephrase a rejected or expired shell command.

## Require Matrix Approval for App Control

The local lease is the hard authority boundary, but MindRoom's [tool approval](https://docs.mindroom.chat/tool-approval/#tool-approval) cards can add per-action confirmation in the Matrix conversation.
Create `approval_scripts/desktop_control.py` beside the cloud config:

```python
CONTROL_ACTIONS = {
    "launch_app",
    "click_element",
    "set_value",
    "scroll_element",
    "perform_action",
    "click",
    "double_click",
    "hover",
    "drag",
    "type_text",
    "scroll",
    "keypress",
}


def check(tool_name: str, arguments: dict[str, object], agent_name: str) -> bool:
    action = arguments.get("action")
    return tool_name == "desktop" and isinstance(action, str) and action in CONTROL_ACTIONS
```

Reference that script from the cloud configuration:

```yaml
tool_approval:
  default: auto_approve
  rules:
    - match: desktop
      script: ./approval_scripts/desktop_control.py
```

Observation stays immediate while each app-control action waits for the original requester's approval in Matrix.
Matrix approval does not replace an absent or expired local lease, and it does not cover the separate `browser` tool.
Adding `run_shell` to the set adds a chat confirmation before the request is sent, and the command still waits for approval on the computer.

## Operations

- **Rotate the local device**: Run `mindroom desktop login --replace`, revoke the old device in your Matrix account settings, then run `!desktop rotate` in the agent chat and follow its pairing command.
  `--replace` does not revoke the old device by itself.
- **Change the cloud controller**: Once the bridge has a command history, saving a different controller identity is rejected.
  Use `--storage-path` with a new directory for `mindroom desktop login`, `mindroom desktop setup`, and `mindroom desktop run`; the new setup does not carry over pending commands or outcomes, and the macOS app has no migration action.
- **Identity mismatch**: A device ID or Ed25519 mismatch is a hard failure; treat it as a rotation or possible substitution and do not bypass it.
- **Stop**: `Ctrl+C` in the terminal or **Stop Access** in the macOS app stops the bridge and its shell commands.
  An app or browser action already in progress can still finish, so observe local state before retrying it.
- **Stronger isolation**: Run the bridge in a dedicated operating-system account and expose only a non-sensitive desktop session.

### `!desktop`

Manage your Desktop pairing for one Desktop-enabled agent.
Run these commands in a private Matrix room containing only you and one Desktop-enabled agent, plus the router when it serves the command:

```text
!desktop setup
!desktop status
!desktop confirm <code> <verification>
!desktop rotate
!desktop disconnect
!desktop disconnect confirm
```

`!desktop setup` starts pairing, and `!desktop confirm` completes it with the exact command that local setup prints.
`!desktop rotate` starts the same flow while leaving the current pairing active until confirmation.
`!desktop status`, or `!desktop` alone, reports whether a Desktop target is configured for you and this agent.
`!desktop disconnect` asks for confirmation, and `!desktop disconnect confirm` removes your pairing for this agent, so its `desktop` calls need setup again.
The agent can report setup status, but it cannot start, confirm, rotate, or disconnect pairing on your behalf.

## Current Limits

There is no live screen stream, multi-monitor selector, or unattended service installer.

# Execution & Coding

Use these tools to inspect and edit files, run shell commands or Python, manage Docker resources, do exact arithmetic, and generate files and charts.

<video controls playsinline preload="metadata" aria-label="An agent writes and runs an analysis script in its own workspace and posts the plot" style="width: 100%">
  <source src="https://github.com/user-attachments/assets/1bc1dc4d-742f-490b-a177-3679ae412ec2#t=0.1" type="video/mp4" media="(prefers-color-scheme: dark)">
  <source src="https://github.com/user-attachments/assets/ac87d6b3-a82a-491d-ae5c-7d114f55539f#t=0.1" type="video/mp4">
</video>

## Tools On This Page

- [`file`] - Generic file reads, writes, listings, searches, and line-range edits.
- [`shell`] - Shell commands with background handles, environment passthrough, and PATH prepends.
- [`python`] - Python code execution and package installation.
- [`coding`] - Line-numbered reads, precise edits, grep, file discovery, and directory listing; the best default for code-editing agents.
- [`docker`] - Docker container, image, volume, and network management.
- [`calculator`] - Exact arithmetic without code execution.
- [`reasoning`] - `think` and `analyze` scratchpad steps.
- [`file_generation`] - JSON, CSV, PDF, DOCX, HTML, text, and source-code file exports.
- [`visualization`] - Matplotlib charts.
- [`sleep`] - Intentional pauses.

## Common Setup Notes

None of these tools needs credentials or dashboard setup; `docker` needs Docker daemon access in the runtime that runs it.
`docker`, `file_generation`, and `visualization` depend on the `docker`, `reportlab` and `python-docx`, and `matplotlib` packages, which install on first use unless [automatic dependency installation](https://docs.mindroom.chat/tools/#automatic-dependency-installation) is disabled.

### Where these tools run

`file`, `shell`, `python`, `coding`, and `docker` run in a worker by default when a worker backend or sandbox proxy is configured, and `reasoning` always runs in the primary process.
Whether the other tools here also run in a worker depends on the [execution mode](https://docs.mindroom.chat/deployment/sandbox-proxy/#execution-modes).
Change this with `defaults.worker_tools` or `agents.<name>.worker_tools`, as described in [Worker Routing](https://docs.mindroom.chat/deployment/sandbox-proxy/#worker-routing).
`worker_scope` decides how worker runtimes are shared between agents and users; see [Worker scopes](https://docs.mindroom.chat/deployment/sandbox-proxy/#worker-scopes).
Only a worker isolates `shell`, `python`, and `docker`, because they execute code and are never confined by the agent's [`file_access`](https://docs.mindroom.chat/architecture/security-posture/#file-access) setting.

### Workspace and tool output files

When an agent has a workspace, it is the working directory (`base_dir`) of `file`, `shell`, `coding`, and worker-routed `python`, and generated files and charts are saved inside it.
`python` in the primary process resolves its file functions against the workspace, but the code it runs uses the process's current directory, so relative paths such as `open("data.csv")` may miss workspace files.
`base_dir` is managed by MindRoom and cannot be set inline in `config.yaml`; without a workspace, these tools use the process's current directory.

Agents with a workspace also get an optional `mindroom_output_path` argument on supported tools.
Set it to a workspace-relative file path to save the tool's full output to that file and return a short receipt to the model instead.
The path must name a file inside the workspace; absolute paths, `..`, paths that start with `~` or contain `$` or `%`, symlinks, and paths inside a `.git` directory are refused.
Without `mindroom_output_path`, outputs larger than `defaults.tool_output_auto_save_threshold_bytes` (integer, at least 1, default `51200`, which is 50 KiB) are saved under `mindroom_tool_outputs/` in the workspace, and the model receives the path, size, format, and a preview.
`MINDROOM_TOOL_OUTPUT_REDIRECT_MAX_BYTES` sets the largest output either kind of save writes (default 64 MiB).

```yaml
defaults:
  tool_output_auto_save_threshold_bytes: 51200

agents:
  code:
    display_name: Code
    role: Edit code, run local checks, and export artifacts
    model: sonnet
    memory_backend: file
    tools:
      - coding
      - shell:
          extra_env_passthrough:
            - GITHUB_TOKEN
          shell_path_prepend:
            - /opt/custom/bin
      - file_generation:
          output_directory: exports
      - visualization:
          output_dir: charts
```

Here `file_generation` saves into `exports` and `visualization` into `charts`, both inside the agent workspace.

## [`file`]

`file` provides `save_file()`, `read_file()`, `delete_file()`, `list_files()`, `search_files()`, `search_content()`, `read_file_chunk()`, and `replace_file_chunk()`.
Paths follow the agent's [`file_access`](https://docs.mindroom.chat/architecture/security-posture/#file-access): `workspace` (the default) rejects paths outside the workspace, and `unrestricted` allows any path the tool's process can reach.
`save_file()`, `replace_file_chunk()`, and `delete_file()` refuse paths inside a `.git` directory.
`read_file()` refuses files over `max_file_length` characters or `max_file_lines` lines and asks for `read_file_chunk()` instead.
`search_files()` matches glob patterns; use `search_content()` to search inside text files.
Prefer [`coding`](#coding) for code-editing agents.

### Configuration

| Option | Type | Required | Default | Notes |
| --- | --- | --- | --- | --- |
| `enable_save_file` | `boolean` | `no` | `true` | Enable `save_file()`. |
| `enable_read_file` | `boolean` | `no` | `true` | Enable `read_file()`. |
| `enable_delete_file` | `boolean` | `no` | `false` | Enable `delete_file()`; deletion is opt-in. |
| `enable_list_files` | `boolean` | `no` | `true` | Enable `list_files()`. |
| `enable_search_files` | `boolean` | `no` | `true` | Enable `search_files()`. |
| `enable_search_content` | `boolean` | `no` | `true` | Enable `search_content()`. |
| `enable_read_file_chunk` | `boolean` | `no` | `true` | Enable `read_file_chunk()`. |
| `enable_replace_file_chunk` | `boolean` | `no` | `true` | Enable `replace_file_chunk()`. |
| `expose_base_directory` | `boolean` | `no` | `false` | Include absolute file paths and `base_directory` in `search_files()` output. |
| `max_file_length` | `number` | `no` | `10000000` | Maximum character count for `read_file()`. |
| `max_file_lines` | `number` | `no` | `100000` | Maximum line count for `read_file()`. |
| `line_separator` | `text` | `no` | `"\n"` | Separator used by the chunk functions. |
| `exclude_patterns` | `string[]` | `no` | `null` | Fnmatch-style path-component patterns excluded from `search_content()`; `null` uses Agno's defaults and `[]` disables exclusions. |
| `all` | `boolean` | `no` | `false` | Enable every `file` function. |

### Example

```yaml
agents:
  editor:
    tools:
      - file:
          enable_delete_file: true
          max_file_lines: 2000
```

```python
read_file("README.md")
read_file_chunk("src/app.py", 0, 80)
replace_file_chunk("docs/notes.md", 10, 12, "Updated text")
list_files(directory="src")
search_files("**/*.py")
search_content("TODO", directory="src")
save_file("temporary notes\n", "scratch/notes.txt")
```

## [`shell`]

`shell` provides `run_shell_command()`, `check_shell_command()`, and `kill_shell_command()`.
`run_shell_command()` accepts a shell command string or a list of argv strings.
Command strings run through non-login `bash -c`; pass `["bash", "-lc", "command"]` when login-shell startup files are needed, and use a multi-item argv list when exact argument boundaries matter.
If the command finishes within `timeout` seconds (default 120), the tool returns the last `tail` lines of stdout (default 100), capped at the last 50 KiB; on a non-zero exit, stderr is returned with stdout.
With `mindroom_output_path`, the complete output is saved to that file instead.

A command that exceeds `timeout` keeps running in the background, and the tool returns a `shell:...` handle.
Poll it with `check_shell_command(handle)` and stop it with `kill_shell_command(handle)`, or `kill_shell_command(handle, force=True)` to send SIGKILL.
A backgrounded command with `mindroom_output_path` saves its output when it finishes, and `check_shell_command()` then returns the file receipt.
Each runner keeps at most 16 backgrounded commands; more fail with `Error: Too many backgrounded processes (16/16). Kill or wait for existing ones before running more.`
Records of finished commands are cleared about 10 minutes after they finish.
For how stopping a response and worker restarts affect background commands, see [Stopping and background commands](https://docs.mindroom.chat/deployment/sandbox-proxy/#stopping-and-background-commands).

Worker-routed `shell` sees only a small default environment; which variables pass by default and which `extra_env_passthrough` never passes are listed in [Shell environment and PATH](https://docs.mindroom.chat/deployment/sandbox-proxy/#shell-environment-and-path).

### Configuration

| Option | Type | Required | Default | Notes |
| --- | --- | --- | --- | --- |
| `enable_run_shell_command` | `boolean` | `no` | `true` | Enable `run_shell_command()`, `check_shell_command()`, and `kill_shell_command()`. |
| `all` | `boolean` | `no` | `false` | Enable all shell functions. |
| `extra_env_passthrough` | `text` or `string[]` | `no` | `null` | Additional exported process environment variable names or glob patterns passed to shell commands; comma- or newline-separated in text form. It matches the exported process environment, not entries in the config's `.env` file. |
| `shell_path_prepend` | `text` or `string[]` | `no` | `null` | Directories prepended to `PATH` for shell commands only, with duplicates removed; comma- or newline-separated in text form. |

### Example

```yaml
agents:
  ops:
    tools:
      - shell:
          extra_env_passthrough:
            - GITHUB_TOKEN
            - INTERNAL_API_*
          shell_path_prepend:
            - /run/wrappers/bin
            - /opt/custom/bin
```

```python
run_shell_command("git status --short", tail=50)
run_shell_command(["git", "status", "--short"], tail=50)
run_shell_command(["bash", "-lc", "sleep 300 && echo done"], timeout=2)
check_shell_command("shell:abcd1234")
kill_shell_command("shell:abcd1234")
```

### Per-workspace environment

An agent can set its own environment variables for worker-routed `shell` and `python`, such as `PATH` entries, npm prefixes, or package indexes, by exporting them from `.mindroom/worker-env.sh` in its workspace.
The hook's behavior, limits, and the variables it cannot override are described in [Workspace env hook](https://docs.mindroom.chat/deployment/sandbox-proxy/#workspace-env-hook-mindroomworker-envsh).

```bash
mkdir -p .mindroom .local/bin .cache/npm
cat > .mindroom/worker-env.sh <<'EOF'
export NPM_CONFIG_PREFIX="$PWD/.local"
export NPM_CONFIG_CACHE="$PWD/.cache/npm"
export PATH="$PWD/.local/bin:$PATH"
EOF
```

## [`python`]

`python` provides `run_python_code()`, `save_to_file_and_run()`, `run_python_file_return_variable()`, `pip_install_package()`, `uv_pip_install_package()`, `read_file()`, and `list_files()`.
It runs arbitrary code, so its file functions are never confined to the workspace and it always has unrestricted file access; isolate it with `worker_tools`.
Both install functions install into the interpreter running the tool, which is the worker's environment when `python` is worker-routed.
Worker-routed code can import modules saved in the workspace, but installed and standard-library modules take precedence over same-named workspace files, and user site-packages under the workspace home are not loaded.

### Configuration

| Option | Type | Required | Default | Notes |
| --- | --- | --- | --- | --- |
| `safe_globals` | `text` | `no` | `null` | Passed to Agno's `safe_globals` constructor argument; meant for programmatic wiring rather than hand-written YAML. |
| `safe_locals` | `text` | `no` | `null` | Passed to Agno's `safe_locals` constructor argument; meant for programmatic wiring rather than hand-written YAML. |

### Example

```yaml
agents:
  analyst:
    tools:
      - python
```

```python
run_python_code("total = sum(i * i for i in range(10))", variable_to_return="total")
save_to_file_and_run("scripts/demo.py", "result = 6 * 7", variable_to_return="result")
run_python_file_return_variable("scripts/demo.py", variable_to_return="result")
pip_install_package("rich")
```

## [`coding`]

`coding` provides `read_file()`, `edit_file()`, `write_file()`, `grep()`, `find_files()`, and `ls()`.
`read_file()` returns line-numbered output with pagination hints when a file is truncated.
`edit_file()` replaces text that must match exactly one location, tolerating whitespace and Unicode differences, and returns a unified diff; when a match is not unique, include more surrounding text in `old_text`.
`grep()` and `find_files()` skip hidden and gitignored paths, though `grep()` still searches a file named directly as its path; `ls()` shows dotfiles and marks directories with `/`.
Paths follow the agent's [`file_access`](https://docs.mindroom.chat/architecture/security-posture/#file-access) like [`file`](#file), and `write_file()` and `edit_file()` refuse paths inside a `.git` directory.
`coding` has no configuration fields.

### Example

```yaml
agents:
  code:
    tools:
      - coding
```

```python
read_file("src/app.py", offset=1, limit=120)
grep("TODO", path="src")
find_files("**/*.md", path="docs")
edit_file("docs/example.md", "old text", "new text")
write_file("scratch/todo.txt", "first line\nsecond line\n")
ls("src")
```

## [`docker`]

`docker` manages containers with functions such as `list_containers()`, `run_container()`, `exec_in_container()`, `start_container()`, `stop_container()`, `remove_container()`, `get_container_logs()`, and `inspect_container()`.
It also manages images, volumes, and networks with functions such as `pull_image()`, `build_image()`, `tag_image()`, `list_volumes()`, `create_volume()`, `list_networks()`, and `connect_container_to_network()`.
Docker daemon access is privileged host control, so `docker` runs in a worker by default, and the runtime that runs it needs a reachable Docker daemon or socket.
`get_container_logs(stream=True)` returns a message asking for non-streaming mode instead of a live stream.

### Configuration

| Option | Type | Required | Default | Notes |
| --- | --- | --- | --- | --- |
| `include_tools` | `string[]` | `no` | `null` | Docker functions to expose; unset exposes all of them. |

### Example

```yaml
agents:
  platform:
    tools:
      - docker
```

```python
list_containers()
run_container("postgres:16", name="postgres", detach=True, environment={"POSTGRES_PASSWORD": "example"})
get_container_logs("postgres", tail=50)
```

## [`calculator`]

`calculator` provides `add()`, `subtract()`, `multiply()`, `divide()`, `exponentiate()`, `factorial()`, `is_prime()`, and `square_root()` for exact arithmetic without the risk of `python`.
Each function returns a small JSON result, and errors such as division by zero, negative factorials, and negative square roots come back as JSON errors.
`factorial()` accepts `n` up to 1558 and `is_prime()` accepts `n` up to 10**12; larger arguments return a JSON error.
`calculator` has no configuration fields.

```python
divide(22, 7)
factorial(6)
is_prime(97)
```

## [`reasoning`]

`reasoning` gives an agent a scratchpad for its own step-by-step reasoning rather than user-facing output.
`think()` records an intermediate thought and an optional next action.
`analyze()` records the result of a step and a `next_action` of `continue`, `validate`, or `final_answer`.
Steps are kept for the current run, so later steps see earlier ones.

### Configuration

| Option | Type | Required | Default | Notes |
| --- | --- | --- | --- | --- |
| `enable_think` | `boolean` | `no` | `true` | Enable `think()`. |
| `enable_analyze` | `boolean` | `no` | `true` | Enable `analyze()`. |
| `add_instructions` | `boolean` | `no` | `false` | Add the toolkit's reasoning instructions to the prompt. |
| `add_few_shot` | `boolean` | `no` | `false` | Also add few-shot examples when instructions are added. |
| `instructions` | `text` | `no` | `null` | Replace the default reasoning instructions entirely. |
| `few_shot_examples` | `text` | `no` | `null` | Replace the built-in few-shot examples; used only when few-shot examples are added. |
| `all` | `boolean` | `no` | `false` | Enable all reasoning functions. |

### Example

```yaml
agents:
  researcher:
    tools:
      - reasoning:
          add_instructions: true
          add_few_shot: true
```

## [`file_generation`]

`file_generation` provides `generate_json_file()`, `generate_csv_file()`, `generate_pdf_file()`, `generate_docx_file()`, `generate_html_file()`, `generate_text_file()`, and `generate_code_file()`.
Each function returns the generated file in its tool result.
Filenames are generated when omitted, and a missing extension is added for the export type.
`generate_json_file()` accepts dicts, lists, or strings, and wraps a string that is not valid JSON.
PDF and DOCX generation are disabled when `reportlab` or `python-docx` is unavailable, even if enabled in config.

Files are also saved to disk when `output_directory` is set or `save_files` is `true`.
Saved files go into `output_directory` inside the agent workspace, or the workspace root when only `save_files` is set, and overwrite files of the same name.
`output_directory` must be a relative path that stays inside the workspace.
Agents without a workspace cannot save generated files; the files exist only in the tool result.

### Configuration

| Option | Type | Required | Default | Notes |
| --- | --- | --- | --- | --- |
| `output_directory` | `text` | `no` | `null` | Directory inside the agent workspace where generated files are saved. |
| `save_files` | `boolean` | `no` | `false` | Save generated files; when `output_directory` is unset, they go to the workspace root. |
| `enable_json_generation` | `boolean` | `no` | `true` | Enable `generate_json_file()`. |
| `enable_csv_generation` | `boolean` | `no` | `true` | Enable `generate_csv_file()`. |
| `enable_pdf_generation` | `boolean` | `no` | `true` | Enable `generate_pdf_file()`. |
| `enable_docx_generation` | `boolean` | `no` | `true` | Enable `generate_docx_file()`. |
| `enable_txt_generation` | `boolean` | `no` | `true` | Enable `generate_text_file()`. |
| `enable_html_generation` | `boolean` | `no` | `true` | Enable `generate_html_file()`. |
| `enable_code_generation` | `boolean` | `no` | `true` | Enable `generate_code_file()`. |
| `all` | `boolean` | `no` | `false` | Enable all file-generation functions. |

### Example

```yaml
agents:
  reporter:
    tools:
      - file_generation:
          output_directory: exports
```

```python
generate_csv_file([{"name": "alpha", "value": 1}, {"name": "beta", "value": 2}], filename="data.csv")
generate_pdf_file("Quarterly summary", filename="report.pdf", title="Q1 Report")
generate_docx_file("Quarterly summary", filename="report.docx", title="Q1 Report")
```

## [`visualization`]

`visualization` provides `create_bar_chart()`, `create_line_chart()`, `create_pie_chart()`, `create_scatter_plot()`, and `create_histogram()`.
Bar, line, and pie charts accept a dict, a list of dicts, or a JSON string; scatter plots take x and y lists, and histograms take a list of numbers.
Each function saves a PNG in `output_dir` inside the agent workspace and returns the file path.
A chart's `filename` must be a plain file name; when omitted, the chart is named after its type and numbered, such as `bar_chart_3.png`.
Charts overwrite files of the same name, and `output_dir` is created when missing.
Agents without a workspace cannot save charts.
Because charts are in the workspace, `attachments` and `matrix_message` can send them under the default `file_access: workspace`.

### Configuration

| Option | Type | Required | Default | Notes |
| --- | --- | --- | --- | --- |
| `output_dir` | `text` | `no` | `"charts"` | Directory inside the agent workspace where charts are saved; absolute paths and `..` are rejected. |
| `enable_create_bar_chart` | `boolean` | `no` | `true` | Enable `create_bar_chart()`. |
| `enable_create_line_chart` | `boolean` | `no` | `true` | Enable `create_line_chart()`. |
| `enable_create_pie_chart` | `boolean` | `no` | `true` | Enable `create_pie_chart()`. |
| `enable_create_scatter_plot` | `boolean` | `no` | `true` | Enable `create_scatter_plot()`. |
| `enable_create_histogram` | `boolean` | `no` | `true` | Enable `create_histogram()`. |
| `all` | `boolean` | `no` | `false` | Enable all chart functions. |

### Example

```python
create_bar_chart({"Mon": 12, "Tue": 18, "Wed": 9}, title="Requests per day")
create_scatter_plot(x=[1, 2, 3], y=[2, 5, 7], title="Experiment results")
create_histogram([1, 1, 2, 3, 5, 8, 13], title="Value distribution")
```

## [`sleep`]

`sleep()` waits for 0 to 300 seconds and returns a confirmation; other durations return an error without waiting.
The response stays open until the delay ends or the response is stopped.

| Option | Type | Required | Default | Notes |
| --- | --- | --- | --- | --- |
| `enable_sleep` | `boolean` | `no` | `true` | Enable `sleep()`. |
| `all` | `boolean` | `no` | `false` | Enable all sleep functions. |

## Related Docs

- [Tools Overview](https://docs.mindroom.chat/tools/)
- [Per-Agent Tool Configuration](https://docs.mindroom.chat/tools/#per-agent-tool-configuration)
- [Sandbox Proxy Isolation](https://docs.mindroom.chat/deployment/sandbox-proxy/)

# Data & Databases

This page covers the built-in tools that query SQL and graph databases, analyze local data files, work with Google Drive, Docs, Sheets, and BigQuery, and fetch financial market data.
Use it to pick a tool, configure its connection, and understand why a tool is unavailable or refused a request.

## Choosing a Tool

| Tool | Use it for | Setup |
| --- | --- | --- |
| [`sql`](#sql) | Generic SQL over any database SQLAlchemy can open | Connection URL or fields |
| [`postgres`](#postgres) | Read-only PostgreSQL inspection, query plans, queries, and CSV export | Host, database, user, password |
| [`redshift`](#redshift) | Amazon Redshift inspection, queries, and export | Host, database, user, password |
| [`neo4j`](#neo4j) | Neo4j labels, relationship types, schema, and Cypher queries | URI, user, password |
| [`duckdb`](#duckdb) | Local analytical SQL over Parquet, CSV, JSON, and S3 files, with exports and full-text search | None |
| [`csv`](#csv) | SQL over pre-registered CSV files | Not configurable from `config.yaml`; use `duckdb` |
| [`pandas`](#pandas) | In-memory dataframes and dataframe methods | None |
| [`google_bigquery`](#google_bigquery) | Tables and SQL in one BigQuery dataset | Project, dataset, location, Google Cloud credentials |
| [`google_drive`](#google_drive) | Listing, searching, reading, downloading, uploading, and organizing Drive files | Google Drive OAuth |
| [`google_docs`](#google_docs) | Creating, reading, and editing Google Docs | Google Docs OAuth |
| [`google_sheets`](#google_sheets) | Reading, creating, and updating spreadsheets | Google Sheets OAuth |
| [`openbb`](#openbb) | Stock quotes, symbol search, news, profiles, and price targets from switchable providers | Optional OpenBB PAT |
| [`yfinance`](#yfinance) | Yahoo Finance quotes, fundamentals, statements, news, and history | None |
| [`financial_datasets_api`](#financial_datasets_api) | Financial statements, filings, ownership, earnings, and crypto prices | API key |

## Setup and Trust

Tools that need a connection or account stay unavailable in the dashboard until their required fields or OAuth connection are stored.
Options of type `password` in the tables below cannot be set inline in `config.yaml`; see [Security Restrictions](https://docs.mindroom.chat/tools/#security-restrictions).
`db_engine`, `tables`, `connection`, `init_commands`, `config`, `csvs`, `duckdb_connection`, `duckdb_kwargs`, `credentials`, and `obb` expect Python objects, lists, or mappings, so they cannot be set usefully from `config.yaml` or the dashboard.
`sql`, `postgres`, `redshift`, `duckdb`, `csv`, and `pandas` can read or write local files that the agent's `file_access` setting does not confine, and `sql`, `duckdb`, and `pandas` always run in the primary runtime, so enable them only for agents you trust with what the MindRoom process can reach; see [File access](https://docs.mindroom.chat/architecture/security-posture/#file-access).
The Google tools connect through per-service OAuth; see [Google Services OAuth For Local Installs](https://docs.mindroom.chat/deployment/google-services-user-oauth/) or [Google Services OAuth](https://docs.mindroom.chat/deployment/google-services-oauth/) for custom and hosted setups.
Missing Python dependencies install automatically on first use; see [Automatic Dependency Installation](https://docs.mindroom.chat/tools/#automatic-dependency-installation).

## [`sql`]

`sql` provides `list_tables()`, `describe_table(table_name)`, and `run_sql_query(query, limit=10)`.
`run_sql_query()` returns 10 rows unless the call passes another `limit`, or `limit=None` for all rows.
Connect with `db_url`, or let MindRoom assemble a URL from `dialect`, `user`, `password`, `host`, `port`, and `schema`.
The assembled URL uses `schema` as the database name and also as the schema for table inspection, so use `db_url` for dialects where those differ.
For PostgreSQL or Redshift, the dedicated [`postgres`](#postgres) and [`redshift`](#redshift) tools add query plans and exports.

| Option | Type | Default | Notes |
| --- | --- | --- | --- |
| `db_url` | `password` | `null` | SQLAlchemy connection URL, such as `postgresql://user:pass@host/db`. |
| `dialect` | `text` | `null` | SQLAlchemy dialect prefix such as `postgresql`, `mysql`, or `sqlite`. |
| `user` | `text` | `null` | Username for the assembled URL. |
| `password` | `password` | `null` | Password for the assembled URL. |
| `host` | `url` | `null` | Host for the assembled URL. |
| `port` | `number` | `null` | Port for the assembled URL. |
| `schema` | `text` | `null` | Database name in the assembled URL and schema for inspection. |
| `db_engine` | `text` | `null` | Programmatic only: a live SQLAlchemy `Engine`. |
| `tables` | `text` | `null` | Programmatic only: a table mapping that `list_tables()` returns instead of inspecting the database. |
| `enable_list_tables` | `boolean` | `true` | Enable `list_tables()`. |
| `enable_describe_table` | `boolean` | `true` | Enable `describe_table()`. |
| `enable_run_sql_query` | `boolean` | `true` | Enable `run_sql_query()`. |
| `all` | `boolean` | `false` | Enable every function. |

## [`postgres`]

`postgres` provides `show_tables()`, `describe_table()`, `summarize_table()`, `inspect_query()`, `run_query()`, and `export_table_to_path()`.
The connection is read-only, so queries cannot modify the database.
`inspect_query()` runs `EXPLAIN`, which is a cheap check before a large `run_query()`.
`export_table_to_path()` writes a table as CSV to a path on the machine running MindRoom.

| Option | Type | Required | Default | Notes |
| --- | --- | --- | --- | --- |
| `host` | `url` | yes | `null` | Server hostname. |
| `port` | `number` | no | `5432` | Server port. |
| `db_name` | `text` | yes | `null` | Database name. |
| `user` | `text` | yes | `null` | Username. |
| `password` | `password` | yes | `null` | Password. |
| `table_schema` | `text` | no | `public` | Schema for table operations and the connection search path. |
| `connection` | `text` | no | `null` | Programmatic only: an existing Psycopg connection. |

```yaml
agents:
  warehouse:
    tools:
      - postgres:
          host: warehouse.internal
          db_name: analytics
          user: analyst
          table_schema: reporting
```

## [`redshift`]

`redshift` provides the same functions as [`postgres`](#postgres): `show_tables()`, `describe_table()`, `summarize_table()`, `inspect_query()`, `run_query()`, and `export_table_to_path()`.
It authenticates with `user` and `password`, or with IAM through an AWS `profile` or explicit AWS keys when `iam: true`.
Unset fields can fall back to the environment variables `REDSHIFT_HOST`, `REDSHIFT_DATABASE`, `REDSHIFT_DB_USER`, `REDSHIFT_CLUSTER_IDENTIFIER`, `AWS_REGION`, and `AWS_PROFILE`.
The dashboard treats `user` and `password` as required even when `iam` is enabled, so store values for both before the tool becomes available.

| Option | Type | Required | Default | Notes |
| --- | --- | --- | --- | --- |
| `host` | `url` | yes | `null` | Cluster endpoint. |
| `port` | `number` | no | `5439` | Port. |
| `database` | `text` | yes | `null` | Database name. |
| `user` | `text` | yes | `null` | Username. |
| `password` | `password` | yes | `null` | Password. |
| `iam` | `boolean` | no | `false` | Use IAM authentication instead of the password. |
| `cluster_identifier` | `text` | no | `null` | Cluster identifier, required for IAM against provisioned clusters. |
| `region` | `text` | no | `null` | AWS region for IAM. |
| `db_user` | `text` | no | `null` | Database user for IAM. |
| `access_key_id` | `password` | no | `null` | AWS access key for IAM. |
| `secret_access_key` | `password` | no | `null` | AWS secret key for IAM. |
| `session_token` | `password` | no | `null` | AWS session token for temporary credentials. |
| `profile` | `text` | no | `null` | AWS profile name for IAM. |
| `ssl` | `boolean` | no | `true` | Use SSL. |
| `table_schema` | `text` | no | `public` | Schema for table operations. |

```yaml
agents:
  warehouse:
    tools:
      - redshift:
          host: my-cluster.abc123.us-east-1.redshift.amazonaws.com
          database: dev
          user: analyst
          table_schema: reporting
```

## [`neo4j`]

`neo4j` provides `list_labels()`, `list_relationship_types()`, `get_schema()`, and `run_cypher_query()`.
Set `enable_run_cypher: false` to give an agent schema visibility without free-form graph queries.

| Option | Type | Required | Default | Notes |
| --- | --- | --- | --- | --- |
| `uri` | `url` | no | `null` | Connection URI such as `bolt://localhost:7687`; set it in practice. |
| `user` | `text` | yes | `null` | Username. |
| `password` | `password` | yes | `null` | Password. |
| `database` | `text` | no | `null` | Target database. |
| `enable_list_labels` | `boolean` | no | `true` | Enable `list_labels()`. |
| `enable_list_relationships` | `boolean` | no | `true` | Enable `list_relationship_types()`. |
| `enable_get_schema` | `boolean` | no | `true` | Enable `get_schema()`. |
| `enable_run_cypher` | `boolean` | no | `true` | Enable `run_cypher_query()`. |
| `all` | `boolean` | no | `false` | Enable every function. |

```yaml
agents:
  graph:
    tools:
      - neo4j:
          uri: bolt://graph.internal:7687
          user: neo4j
          database: analytics
          enable_run_cypher: false
```

## [`duckdb`]

`duckdb` is the best choice for repeatable local analytics over files.
It provides `show_tables()`, `describe_table()`, `inspect_query()`, `run_query()`, `summarize_table()`, `create_table_from_path()`, `export_table_to_path()`, `load_local_path_to_table()`, `load_local_csv_to_table()`, `load_s3_path_to_table()`, `load_s3_csv_to_table()`, `create_fts_index()`, and `full_text_search()`.
Without `db_path`, the database lives in memory and is not saved.
`export_table_to_path()` writes Parquet unless the call passes another format such as `CSV`.

| Option | Type | Default | Notes |
| --- | --- | --- | --- |
| `db_path` | `text` | `null` | Path to a persistent DuckDB database file. |
| `read_only` | `boolean` | `false` | Open the database read-only. |
| `connection` | `text` | `null` | Programmatic only: an existing DuckDB connection. |
| `init_commands` | `text` | `null` | Programmatic only: a list of startup SQL commands. |
| `config` | `text` | `null` | Programmatic only: a DuckDB config mapping. |

```yaml
agents:
  analyst:
    tools:
      - duckdb:
          db_path: data/analytics.duckdb
```

```python
create_table_from_path("/workspace/data/orders.parquet", table="orders", replace=True)
run_query("SELECT customer_id, COUNT(*) AS orders FROM orders GROUP BY 1 ORDER BY orders DESC LIMIT 10")
export_table_to_path("orders", format="CSV", path="/tmp")
```

## [`csv`]

`csv` provides `list_csv_files()`, `read_csv_file()`, `get_columns()`, and `query_csv_file()` over a pre-registered list of CSV files, each named by its filename stem.
`config.yaml` and the dashboard accept `csvs` only as a string, so they cannot register a file list and the tool has no files to read; use [`duckdb`](#duckdb) to query CSV files.
`query_csv_file()` runs only the first SQL statement it receives.

| Option | Type | Default | Notes |
| --- | --- | --- | --- |
| `csvs` | `text` | `null` | Programmatic only: the list of CSV paths. |
| `row_limit` | `number` | `null` | Default row cap for `read_csv_file()`. |
| `duckdb_connection` | `text` | `null` | Programmatic only: an existing DuckDB connection. |
| `duckdb_kwargs` | `text` | `null` | Programmatic only: DuckDB connection arguments. |
| `enable_read_csv_file` | `boolean` | `true` | Enable `read_csv_file()`. |
| `enable_list_csv_files` | `boolean` | `true` | Enable `list_csv_files()`. |
| `enable_get_columns` | `boolean` | `true` | Enable `get_columns()`. |
| `enable_query_csv_file` | `boolean` | `true` | Enable `query_csv_file()`. |
| `all` | `boolean` | `false` | Enable every function. |

## [`pandas`]

`pandas` provides `create_pandas_dataframe()` and `run_dataframe_operation()`.
`create_pandas_dataframe()` calls a Pandas constructor such as `read_csv` or `read_json` and stores the result under a name, rejecting empty dataframes and names already in use.
`run_dataframe_operation()` calls a dataframe method such as `head`, `describe`, or `groupby` on a stored dataframe.
Dataframes live only in memory and are lost on restart.

| Option | Type | Default | Notes |
| --- | --- | --- | --- |
| `enable_create_pandas_dataframe` | `boolean` | `true` | Enable `create_pandas_dataframe()`. |
| `enable_run_dataframe_operation` | `boolean` | `true` | Enable `run_dataframe_operation()`. |
| `all` | `boolean` | `false` | Enable every function. |

```python
create_pandas_dataframe("sales", "read_csv", {"filepath_or_buffer": "/workspace/data/sales.csv"})
run_dataframe_operation("sales", "describe", {})
```

## [`google_bigquery`]

`google_bigquery` provides `list_tables()`, `describe_table()`, and `run_sql_query()` for the configured dataset.
The dataset is only the default for unqualified table names, so queries can still reference other datasets that the Google Cloud credentials can read.
It authenticates with the MindRoom process's default Google Cloud credentials, not with MindRoom's Google OAuth connections.

| Option | Type | Required | Default | Notes |
| --- | --- | --- | --- | --- |
| `project` | `text` | yes | `null` | Google Cloud project ID. |
| `dataset` | `text` | yes | `null` | Dataset name. |
| `location` | `text` | yes | `null` | Location such as `US` or `EU`. |
| `credentials` | `text` | no | `null` | Programmatic only: a Google credentials object; rejected as an inline override. |
| `list_tables` | `boolean` | no | `true` | Enable `list_tables()`. |
| `describe_table` | `boolean` | no | `true` | Enable `describe_table()`. |
| `run_sql_query` | `boolean` | no | `true` | Enable `run_sql_query()`. |
| `all` | `boolean` | no | `false` | Enable every function. |

```yaml
agents:
  analyst:
    tools:
      - google_bigquery:
          project: my-gcp-project
          dataset: analytics
          location: US
```

## [`google_drive`]

<video controls playsinline preload="metadata" aria-label="The agent compares two Drive documents and flags a stale number" style="width: 100%">
  <source src="https://github.com/user-attachments/assets/b8f28170-351a-4c6a-b49a-f5b6bd2a3df1#t=0.1" type="video/mp4" media="(prefers-color-scheme: dark)">
  <source src="https://github.com/user-attachments/assets/58281caf-f956-4584-87ec-15bd2a2242cb#t=0.1" type="video/mp4">
</video>

`google_drive` works with files in the connected Google account, including shared drives the account can access.

| Function | What it does |
| --- | --- |
| `google_drive_list_files()` | Lists recently modified files. |
| `google_drive_search_files(query)` | Searches file metadata with a Drive query such as `name contains 'budget'`. |
| `google_drive_read_file(file_id)` | Returns Google Workspace files as text, and plain-text files up to `max_read_size`; PDFs and Office files need a download and a reader for that format. |
| `google_drive_download_file(file_id, export_format=None)` | Saves a file into `google-drive-downloads/` in the agent workspace, exporting Workspace files to a native format such as `.xlsx` for a whole spreadsheet, or to the MIME type in `export_format`, such as `application/pdf`. |
| `google_drive_upload_file(local_path, folder_id=None, name=None, mime_type=None)` | Uploads a local file; relative paths start at the agent workspace. |
| `google_drive_update_file(file_id, local_path, mime_type=None)` | Replaces the contents of an existing non-Workspace file. |
| `google_drive_create_folder(name, parent_id=None)` | Creates a folder under the Drive root or a parent folder. |
| `google_drive_move_file(file_id, new_parent_id, name=None)` | Moves a file and optionally renames it. |
| `google_drive_trash_file(file_id)` | Moves a file to trash; the tool never deletes permanently. |

Non-Workspace files over `max_read_size` return an error suggesting `google_drive_download_file` instead.
Downloads larger than `max_download_size` are refused without leaving a partial file.
Uploads and content replacement read local files according to the agent's [`file_access`](https://docs.mindroom.chat/architecture/security-posture/#file-access) setting.
Downloads need an agent workspace; for an agent without one, `download_file` is ignored and a warning is logged.
To change the content of a native Google Doc, use [`google_docs`](#google_docs).
If the account is not connected, the tool returns an `OAuthConnectionRequired` result with a connect link; see [Google Services OAuth For Local Installs](https://docs.mindroom.chat/deployment/google-services-user-oauth/#connect).
A connection that granted only read-only Drive access keeps working for reads, while writes ask the user to reconnect; see [Scope Rationale](https://docs.mindroom.chat/deployment/google-services-oauth/#scope-rationale).

| Option | Type | Default | Notes |
| --- | --- | --- | --- |
| `list_files` | `boolean` | `true` | Enable file listing. |
| `search_files` | `boolean` | `true` | Enable metadata search. |
| `read_file` | `boolean` | `true` | Enable content reads. |
| `download_file` | `boolean` | `false` | Enable downloads and exports into the agent workspace. |
| `write` | `boolean` | `true` | Enable upload, content replacement, folder creation, move or rename, and trash; `false` removes all of them. |
| `max_read_size` | `number` | `10485760` | Largest non-Workspace file to read, in bytes. |
| `max_download_size` | `number` | `104857600` | Largest file or export to download, in bytes. |

To require a person to approve each Drive change, add [Tool Approval](https://docs.mindroom.chat/tool-approval/) rules:

```yaml
tool_approval:
  rules:
    - match: google_drive_upload_file
      action: require_approval
    - match: google_drive_update_file
      action: require_approval
    - match: google_drive_create_folder
      action: require_approval
    - match: google_drive_move_file
      action: require_approval
    - match: google_drive_trash_file
      action: require_approval
```

## [`google_docs`]

`google_docs` creates, reads, and edits Google Docs through its own OAuth connection, separate from Google Drive.

| Function | What it does |
| --- | --- |
| `google_docs_create_document(title, initial_text="")` | Creates a document, optionally with initial text. |
| `google_docs_get_document(document_id)` | Returns the full document structure and content, including every tab, paragraph, style, table, and list. |
| `google_docs_insert_text(document_id, text, index=None, tab_id=None)` | Inserts text at a body index (1 is the first position), or appends to the end of the tab when `index` is omitted. |
| `google_docs_replace_text(document_id, find_text, replace_text, match_case=False, tab_ids=None)` | Replaces every match across all tabs or the listed tabs and reports the count. |

Each `document_id` accepts either a document ID or a full Google Docs URL.
Every successful call returns the document's edit URL.
If the account is not connected, the tool returns an `OAuthConnectionRequired` result with a connect link.
Before public production use, the Docs scope needs Google verification; see [Production Verification Follow-up](https://docs.mindroom.chat/deployment/google-services-oauth/#production-verification-follow-up).

| Option | Type | Default | Notes |
| --- | --- | --- | --- |
| `create_document` | `boolean` | `true` | Enable document creation. |
| `read_document` | `boolean` | `true` | Enable structure and content reads. |
| `edit_document` | `boolean` | `true` | Enable text insertion and replacement. |

## [`google_sheets`]

`google_sheets` provides `read_sheet(spreadsheet_id=None, spreadsheet_range=None)`, `create_sheet(title)`, and `update_sheet(data, spreadsheet_id=None, range_name=None)`.
When `spreadsheet_id` or `spreadsheet_range` is configured, `read_sheet()` always uses it and ignores the value passed in the call, so leave both unset to read from many spreadsheets.
`update_sheet()` ignores both settings and needs an explicit `spreadsheet_id` and `range_name` on every call.
`update_sheet()` writes values literally, so a formula such as `=SUM(A1:A3)` is stored as text and does not calculate.
The tool is available only once the stored Google Sheets connection includes the Sheets scope; if the account is not connected, calls return an `OAuthConnectionRequired` result with a connect link.

| Option | Type | Default | Notes |
| --- | --- | --- | --- |
| `spreadsheet_id` | `text` | `null` | Spreadsheet ID used by every `read_sheet()` call. |
| `spreadsheet_range` | `text` | `null` | Range used by every `read_sheet()` call, such as `Sheet1!A1:Z100`. |
| `read` | `boolean` | `true` | Enable `read_sheet()`. |
| `create` | `boolean` | `true` | Enable `create_sheet()`. |
| `update` | `boolean` | `true` | Enable `update_sheet()`. |

```yaml
agents:
  ops:
    tools:
      - google_sheets:
          spreadsheet_id: 1AbCdEfGhIjKlMnOpQrStUvWxYz
          spreadsheet_range: Sheet1!A1:G200
```

## [`openbb`]

`openbb` provides `get_stock_price(symbol)`, `search_company_symbol()`, `get_company_news(symbol, num_stories=10)`, `get_company_profile()`, and `get_price_targets()`.
Symbols can be comma-separated, such as `AAPL,MSFT`.
The default `yfinance` provider works without an OpenBB account; set `openbb_pat` or the `OPENBB_PAT` environment variable for premium providers.
Choose `openbb` over [`yfinance`](#yfinance) when you need to switch data providers.

| Option | Type | Default | Notes |
| --- | --- | --- | --- |
| `provider` | `text` | `yfinance` | One of `yfinance`, `benzinga`, `fmp`, `intrinio`, `polygon`, `tiingo`, or `tmx`. |
| `openbb_pat` | `password` | `null` | OpenBB personal access token for premium providers. |
| `obb` | `text` | `null` | Programmatic only: a configured OpenBB instance. |
| `enable_get_stock_price` | `boolean` | `true` | Enable `get_stock_price()`. |
| `enable_search_company_symbol` | `boolean` | `false` | Enable `search_company_symbol()`. |
| `enable_get_company_news` | `boolean` | `false` | Enable `get_company_news()`. |
| `enable_get_company_profile` | `boolean` | `false` | Enable `get_company_profile()`. |
| `enable_get_price_targets` | `boolean` | `false` | Enable `get_price_targets()`. |
| `all` | `boolean` | `false` | Enable every function. |

```yaml
agents:
  market:
    tools:
      - openbb:
          enable_search_company_symbol: true
          enable_get_company_news: true
```

## [`yfinance`]

`yfinance` reads Yahoo Finance data with no credentials.
Only the current stock price is enabled by default, so enable each additional function the agent needs.

| Option | Type | Default | Enables |
| --- | --- | --- | --- |
| `enable_stock_price` | `boolean` | `true` | `get_current_stock_price()` |
| `enable_company_info` | `boolean` | `false` | `get_company_info()` |
| `enable_stock_fundamentals` | `boolean` | `false` | `get_stock_fundamentals()` |
| `enable_income_statements` | `boolean` | `false` | `get_income_statements()` |
| `enable_key_financial_ratios` | `boolean` | `false` | `get_key_financial_ratios()` |
| `enable_analyst_recommendations` | `boolean` | `false` | `get_analyst_recommendations()` |
| `enable_company_news` | `boolean` | `false` | `get_company_news()` |
| `enable_technical_indicators` | `boolean` | `false` | `get_technical_indicators()` |
| `enable_historical_prices` | `boolean` | `false` | `get_historical_stock_prices()` |
| `all` | `boolean` | `false` | Every function |

```yaml
agents:
  market:
    tools:
      - yfinance:
          enable_company_info: true
          enable_historical_prices: true
          enable_company_news: true
```

## [`financial_datasets_api`]

`financial_datasets_api` reads structured data from Financial Datasets through `get_income_statements()`, `get_balance_sheets()`, `get_cash_flow_statements()`, `get_segmented_financials()`, `get_financial_metrics()`, `get_company_info()`, `get_stock_prices()`, `get_earnings()`, `get_insider_trades()`, `get_institutional_ownership()`, `get_news()`, `get_sec_filings()`, `get_crypto_prices()`, and `search_tickers()`.
Set `api_key` or the `FINANCIAL_DATASETS_API_KEY` environment variable; without a key, every call returns `API key not set`.

| Option | Type | Default | Notes |
| --- | --- | --- | --- |
| `api_key` | `password` | `null` | Financial Datasets API key. |
| `timeout` | `number` | `30` | Per-request HTTP timeout in seconds. |

## Related Docs

- [Tools Overview](https://docs.mindroom.chat/tools/)
- [Per-Agent Tool Configuration](https://docs.mindroom.chat/tools/#per-agent-tool-configuration)
- [Google Services OAuth For Local Installs](https://docs.mindroom.chat/deployment/google-services-user-oauth/)
- [Google Services OAuth](https://docs.mindroom.chat/deployment/google-services-oauth/)

# Web Search

Use these tools to give agents general web search, news search, answer-style search APIs, Google- or Baidu-oriented results, or a self-hosted SearXNG backend.

## Tools On This Page

| Tool | Needs | Best for |
| --- | --- | --- |
| [`duckduckgo`] | Nothing | Lowest-friction web and news search. |
| [`googlesearch`] | Nothing | Web and news search that asks for the Google engine first. |
| [`baidusearch`] | Nothing | Baidu-indexed and Chinese-language results. |
| [`tavily`] | API key | Current-information search with an optional synthesized answer, a compact context mode, and URL extraction. |
| [`exa`] | API key | Research: domain and date filters, page contents, similar-page lookup, answers, and long-running research tasks. |
| [`serpapi`] | API key | Paid Google and YouTube results. |
| [`serper`] | API key | Paid Google web, news, and Scholar results plus a webpage scrape call. |
| [`searxng`] | Your SearXNG `host` | Self-hosted search, including image, IT, map, music, science, news, and video categories. |
| [`linkup`] | API key | Raw results or a sourced answer from one call. |

## Setup

Add the tool to an agent's `tools` list; see [Per-Agent Tool Configuration](https://docs.mindroom.chat/tools/#per-agent-tool-configuration) for inline options and `include_tools`/`exclude_tools`.
`duckduckgo`, `googlesearch`, and `baidusearch` work without any setup.
`searxng` needs only a reachable `host` URL.
The API-backed tools need an `api_key`, set through the dashboard or credential store (see [Security Restrictions](https://docs.mindroom.chat/tools/#security-restrictions)).
Each API-backed tool also reads its key from the environment when no `api_key` is stored:

| Tool | Environment variable |
| --- | --- |
| `tavily` | `TAVILY_API_KEY` (and `TAVILY_API_BASE_URL` for `api_base_url`) |
| `exa` | `EXA_API_KEY` |
| `serpapi` | `SERP_API_KEY` |
| `serper` | `SERPER_API_KEY` |
| `linkup` | `LINKUP_API_KEY` |

Missing Python dependencies install on first use; see [Automatic Dependency Installation](https://docs.mindroom.chat/tools/#automatic-dependency-installation).
Every toolkit that has an `all` option enables all of its functions when `all: true`, regardless of the individual `enable_*` flags, except that [`tavily`] still provides only one search function.

## [`duckduckgo`]

`duckduckgo` searches the web and news through DuckDuckGo with no API key.
It provides `web_search(query, max_results=5)` and `search_news(query, max_results=5)`, both returning DDGS results as JSON.

| Option | Type | Default | Notes |
| --- | --- | --- | --- |
| `enable_search` | `boolean` | `true` | Enable `web_search()`. |
| `enable_news` | `boolean` | `true` | Enable `search_news()`. |
| `modifier` | `text` | `null` | Text prepended to every `web_search()` query, for example `site:docs.example.com`. |
| `fixed_max_results` | `number` | `null` | Result count for every call, replacing the per-call `max_results`. |
| `timelimit` | `text` | `null` | Age filter: `d`, `w`, `m`, or `y`. |
| `region` | `text` | `null` | DDGS region code, such as `us-en`. |
| `backend` | `text` | `duckduckgo` | DDGS backend override. |
| `proxy` | `url` | `null` | Proxy for search requests. |
| `timeout` | `number` | `10` | Request timeout in seconds. |
| `verify_ssl` | `boolean` | `true` | Verify TLS certificates. |

```yaml
agents:
  researcher:
    tools:
      - duckduckgo:
          fixed_max_results: 8
          timelimit: w
```

## [`googlesearch`]

`googlesearch` provides the same `web_search()` and `search_news()` functions, options, and output as [`duckduckgo`], except that it has no `backend` option and requests the Google engine.
It is not an official Google API.
DDGS currently falls back to automatic engine selection for Google requests, so results and ranking are not guaranteed to come from Google.
Use [`serper`] or [`serpapi`] when you need real Google results.

```yaml
agents:
  researcher:
    tools:
      - googlesearch:
          modifier: site:docs.mindroom.chat
          fixed_max_results: 6
```

## [`baidusearch`]

`baidusearch` provides `baidu_search(query, max_results=5, language="zh")`, which returns a JSON array of results with `title`, `url`, `abstract`, and `rank`.

| Option | Type | Default | Notes |
| --- | --- | --- | --- |
| `enable_baidu_search` | `boolean` | `true` | Enable `baidu_search()`. |
| `all` | `boolean` | `false` | Enable all functions. |
| `fixed_max_results` | `number` | `null` | Result count for every call, replacing the per-call `max_results`. |
| `fixed_language` | `text` | `null` | Replaces the per-call `language`; has no effect on results. |
| `headers` | `password` | `null` | Has no effect. |
| `proxy` | `url` | `null` | Has no effect. |
| `timeout` | `number` | `10` | Has no effect. |
| `debug` | `boolean` | `false` | Has no effect. |

Only the query and result count reach Baidu, so `language`, `fixed_language`, `headers`, `proxy`, `timeout`, and `debug` do not change the search.

```yaml
agents:
  cn_research:
    tools:
      - baidusearch:
          fixed_max_results: 8
```

## [`tavily`]

`tavily` searches current information through the Tavily API and can extract page content from URLs.
It provides one search function, `web_search_using_tavily(query, max_results=5)` by default or `web_search_with_tavily(query)` when `enable_search_context: true`, plus `extract_url_content(urls)` when `enable_extract: true`.
`web_search_using_tavily()` returns results and, with `include_answer`, a synthesized answer, as Markdown or JSON according to `format`.
`web_search_with_tavily()` returns one compact context block instead of a result list.
`extract_url_content()` accepts one URL or a comma-separated list and returns page content as Markdown or plain text according to `extract_format`.

| Option | Type | Default | Notes |
| --- | --- | --- | --- |
| `api_key` | `password` | `null` | Tavily API key; required unless `TAVILY_API_KEY` is set. |
| `api_base_url` | `url` | `null` | API base URL override. |
| `enable_search` | `boolean` | `true` | Enable the search function. |
| `enable_search_context` | `boolean` | `false` | Provide `web_search_with_tavily()` instead of `web_search_using_tavily()`. |
| `enable_extract` | `boolean` | `false` | Enable `extract_url_content()`. |
| `all` | `boolean` | `false` | Enable search and `extract_url_content()`; `enable_search_context` still selects the search function. |
| `max_tokens` | `number` | `6000` | Output budget for search results and context mode. |
| `include_answer` | `boolean` | `true` | Include Tavily's answer in search output. |
| `search_depth` | `text` | `advanced` | `basic`, `advanced`, `fast`, or `ultra-fast`. |
| `format` | `text` | `markdown` | Search output format: `markdown` or `json`. |
| `topic` | `text` | `null` | Search category: `general`, `news`, or `finance`. |
| `time_range` | `text` | `null` | `day`, `week`, `month`, or `year` (or `d`, `w`, `m`, `y`). |
| `start_date` | `text` | `null` | Only results published after this date (`YYYY-MM-DD`). |
| `end_date` | `text` | `null` | Only results published before this date (`YYYY-MM-DD`). |
| `days` | `number` | `null` | Days back to include; `news` topic only. |
| `include_domains` | `string[]` | `null` | Restrict results to these domains. |
| `exclude_domains` | `string[]` | `null` | Exclude these domains. |
| `country` | `text` | `null` | Boost results from this country, such as `united states`. |
| `auto_parameters` | `boolean` | `false` | Let Tavily tune search parameters; explicitly set options take precedence. |
| `chunks_per_source` | `number` | `null` | Content chunks per source, `1` to `3`; `advanced` depth only. |
| `extract_depth` | `text` | `basic` | `basic` or `advanced`. |
| `extract_format` | `text` | `markdown` | `markdown` or `text`. |
| `extract_timeout` | `number` | `null` | Extraction timeout in seconds. |
| `include_images` | `boolean` | `false` | Requests images from Tavily during extraction, but the returned content does not include them. |
| `include_favicon` | `boolean` | `false` | Requests favicons from Tavily during extraction, but the returned content does not include them. |

The filters from `topic` through `chunks_per_source` apply only to `web_search_using_tavily()`, not to context mode.

```yaml
agents:
  newsdesk:
    tools:
      - tavily:
          enable_extract: true
          topic: news
          time_range: week
```

## [`exa`]

`exa` is the research-oriented search toolkit.
It provides `search_exa(query, num_results=5, category=None)`, `get_contents(urls)`, `find_similar(url, num_results=5)`, `exa_answer(query, text=False)`, and, only when `enable_research: true`, the long-running `research(instructions, output_schema=None)`.
Search results include title, author, published date, URL, and page text truncated to `text_length_limit`.

| Option | Type | Default | Notes |
| --- | --- | --- | --- |
| `api_key` | `password` | `null` | Exa API key; required unless `EXA_API_KEY` is set. |
| `enable_search` | `boolean` | `true` | Enable `search_exa()`. |
| `enable_get_contents` | `boolean` | `true` | Enable `get_contents()`. |
| `enable_find_similar` | `boolean` | `true` | Enable `find_similar()`. |
| `enable_answer` | `boolean` | `true` | Enable `exa_answer()`. |
| `enable_research` | `boolean` | `false` | Enable `research()`. |
| `all` | `boolean` | `false` | Enable all functions. |
| `text` | `boolean` | `true` | Include page text in results. |
| `text_length_limit` | `number` | `1000` | Maximum text length per result. |
| `summary` | `boolean` | `false` | Request result summaries. |
| `num_results` | `number` | `null` | Result count for every call, replacing the per-call `num_results`. |
| `type` | `text` | `null` | Exa search mode, such as `auto`. |
| `category` | `text` | `null` | Content category, such as `news`; replaces the per-call `category`. |
| `include_domains` | `string[]` | `null` | Domain allowlist. |
| `exclude_domains` | `string[]` | `null` | Domain denylist. |
| `start_published_date` | `text` | `null` | Earliest publication date, ISO 8601. |
| `end_published_date` | `text` | `null` | Latest publication date, ISO 8601. |
| `start_crawl_date` | `text` | `null` | Deprecated; Exa ignores it. |
| `end_crawl_date` | `text` | `null` | Deprecated; Exa ignores it. |
| `livecrawl` | `text` | `always` | Has no effect. |
| `model` | `text` | `null` | Model for `exa_answer()` only: `exa` or `exa-pro`. |
| `research_model` | `text` | `exa-research` | Model for `research()` only: `exa-research` or `exa-research-pro`. |
| `timeout` | `number` | `30` | API timeout in seconds. |
| `show_results` | `boolean` | `false` | Log raw parsed results. |

Publication-date filters cannot be combined with the `company` and `people` categories.

```yaml
agents:
  analyst:
    tools:
      - exa:
          enable_research: true
          type: auto
          category: news
          include_domains:
            - matrix.org
            - element.io
```

## [`serpapi`]

`serpapi` provides `search_google(query, num_results=10)` and, when enabled, `search_youtube(query)` through SerpApi.
`search_google()` returns organic results plus recipes, shopping, knowledge graph, and related questions.
`search_youtube()` returns video, movie, and channel results.

| Option | Type | Default | Notes |
| --- | --- | --- | --- |
| `api_key` | `password` | `null` | SerpApi key; required unless `SERP_API_KEY` is set. |
| `enable_search_google` | `boolean` | `true` | Enable `search_google()`. |
| `enable_search_youtube` | `boolean` | `false` | Enable `search_youtube()`. |
| `all` | `boolean` | `false` | Enable all functions. |

```yaml
agents:
  researcher:
    tools:
      - serpapi:
          enable_search_youtube: true
```

## [`serper`]

`serper` provides `search_web(query, num_results=None)`, `search_news(query, num_results=None)`, `search_scholar(query, num_results=None)`, and `scrape_webpage(url, markdown=False)` through the Serper API.
All functions return Serper's raw JSON, and `scrape_webpage(markdown=True)` also requests the page as Markdown.

| Option | Type | Default | Notes |
| --- | --- | --- | --- |
| `api_key` | `password` | `null` | Serper API key; required unless `SERPER_API_KEY` is set. |
| `location` | `text` | `us` | Google country code (`gl`) for all searches. |
| `language` | `text` | `en` | Google language code (`hl`) for all searches. |
| `num_results` | `number` | `10` | Default result count when a call does not pass one. |
| `date_range` | `text` | `null` | Google date filter (`tbs`) for all searches, such as `qdr:w`. |
| `enable_search` | `boolean` | `true` | Enable `search_web()`. |
| `enable_search_news` | `boolean` | `true` | Enable `search_news()`. |
| `enable_search_scholar` | `boolean` | `true` | Enable `search_scholar()`. |
| `enable_scrape_webpage` | `boolean` | `true` | Enable `scrape_webpage()`. |
| `all` | `boolean` | `false` | Enable all functions. |
| `timeout` | `number` | `30` | Request timeout in seconds. |

```yaml
agents:
  analyst:
    tools:
      - serper:
          location: de
          language: de
          enable_scrape_webpage: false
```

## [`searxng`]

`searxng` searches through your own SearXNG instance.
It provides `search_web`, `image_search`, `it_search`, `map_search`, `music_search`, `news_search`, `science_search`, and `video_search`, each taking `(query, max_results=5)`.
The instance must allow JSON output (`json` in SearXNG's `search.formats`).

| Option | Type | Default | Notes |
| --- | --- | --- | --- |
| `host` | `url` | required | Instance root URL, such as `https://search.example.com`, not a `/search` URL. |
| `engines` | `string[]` | `[]` | Restrict searches to these SearXNG engines. |
| `fixed_max_results` | `number` | `null` | Result count for every call, replacing the per-call `max_results`. |
| `timeout` | `number` | `30` | Request timeout in seconds. |

The tool sends no credentials, so put any authentication or access policy at the network or reverse-proxy layer.

```yaml
agents:
  privacy_research:
    tools:
      - searxng:
          host: https://search.example.com
          engines:
            - duckduckgo
            - wikipedia
```

## [`linkup`]

`linkup` provides `web_search_with_linkup(query, depth=None, output_type=None)`, which returns Linkup's response as a result list or a sourced answer.
The configured `depth` and `output_type` apply when a call does not pass its own.

| Option | Type | Default | Notes |
| --- | --- | --- | --- |
| `api_key` | `password` | `null` | Linkup API key; required unless `LINKUP_API_KEY` is set. |
| `depth` | `text` | `standard` | `standard` or `deep`. |
| `output_type` | `text` | `searchResults` | `searchResults` or `sourcedAnswer`. |
| `enable_web_search_with_linkup` | `boolean` | `true` | Enable `web_search_with_linkup()`. |
| `all` | `boolean` | `false` | Enable all functions. |

```yaml
agents:
  briefings:
    tools:
      - linkup:
          depth: deep
          output_type: sourcedAnswer
```

## Related Docs

- [Tools Overview](https://docs.mindroom.chat/tools/)
- [Research Sources](https://docs.mindroom.chat/tools/research-sources/)
- [Web Scraping & Browser](https://docs.mindroom.chat/tools/web-scraping-and-browser/)

# Web Scraping & Browser

This page covers the built-in tools that read web pages, extract article text, crawl sites, call hosted scraping APIs, and drive a browser.
Use it to pick a tool, configure it, and understand why a URL was refused.

## Choosing a Tool

| Tool | Use it for | Credentials |
| --- | --- | --- |
| [`website`](#website) | Reading one page quickly | None |
| [`trafilatura`](#trafilatura) | Local text, metadata, HTML-to-text, batch extraction, and small crawls | None |
| [`newspaper`](#newspaper) | News articles and blog posts with title, authors, and publish date | None |
| [`crawl4ai`](#crawl4ai) | Local browser-rendered reads of one or a few URLs, optionally focused on a query | None |
| [`jina`](#jina) | Jina Reader page reads and optional search | Optional `JINA_API_KEY` |
| [`firecrawl`](#firecrawl) | Hosted scrape, crawl, map, and search | `FIRECRAWL_API_KEY` |
| [`spider`](#spider) | Spider Cloud search, scrape, and crawl | `SPIDER_API_KEY` |
| [`scrapegraph`](#scrapegraph) | Prompt-driven structured extraction | `SGAI_API_KEY` |
| [`apify`](#apify) | Running an Apify Actor as a tool | `APIFY_API_TOKEN` |
| [`brightdata`](#brightdata) | Markdown scraping, screenshots, SERP queries, and data feeds | `BRIGHT_DATA_API_KEY` |
| [`oxylabs`](#oxylabs) | Google SERP, Amazon product data, and generic scraping | `OXYLABS_USERNAME` and `OXYLABS_PASSWORD` |
| [`agentql`](#agentql) | AgentQL query extraction in a local browser | `AGENTQL_API_KEY` |
| [`browserbase`](#browserbase) | Hosted remote browser sessions for navigation, screenshots, and page reads | `BROWSERBASE_API_KEY` and `BROWSERBASE_PROJECT_ID` |
| [`browser`](#browser) | Multi-step automation of MindRoom's browser or the user's own signed-in browser | None |
| [`web_browser_tools`](#web_browser_tools) | Opening a URL in the host computer's browser for a person | None |

Each credential can be stored as the tool's `api_key` (or `apify_api_token`, `username`, and `password`) through the dashboard or credential store, or supplied as the environment variable shown.
Keep password fields out of inline YAML.
`crawl4ai`, `agentql`, `browserbase`, and `browser` need a working Playwright browser runtime.
For a visible, persistent browser that the user can watch or control in MindRoom Chat, see [Worker Computer](https://docs.mindroom.chat/tools/worker-computer/) and [Agent Chat UI Actions](https://docs.mindroom.chat/tools/chat-ui/).

## Network Access

Tools that fetch pages from MindRoom itself (`website`, `trafilatura`, `newspaper`, `crawl4ai`, `agentql`, and the `browser` host target) reach only public HTTP(S) addresses.
Private, loopback, link-local, multicast, reserved, and cloud-metadata targets are refused, and so are redirects, crawled links, and page resources that lead to them.
Unless a worker egress proxy is set, DNS answers that change after a URL is checked cannot redirect the connection to an internal service.
Only the `browser` tool can opt into private networks, with [`allow_private_networks`](#browser-configuration).
In Computer mode, the `browser` tool also opens worker-local loopback previews such as `http://localhost:5173` without that option; see [Preview a local web app](https://docs.mindroom.chat/tools/worker-computer/#preview-a-local-web-app).

### Egress Proxy for Browsers

`crawl4ai`, `agentql`, the `browser` host target, and [`browser_mcp`](https://docs.mindroom.chat/tools/worker-computer/#native-playwright-mcp-provider) in a Worker Computer send every connection through an operator egress proxy set with `http_proxy`, `https_proxy`, or `all_proxy`, using HTTP `CONNECT`.
In the primary process, `http_proxy` applies to port 80, `https_proxy` to every other port, and `all_proxy` to either when its own variable is unset.
The proxy must allow `CONNECT` to ports 80 and 443, because plain-HTTP pages are tunneled too; Squid's default `http_access deny CONNECT !SSL_ports` rule refuses plain HTTP.
In the primary process it must also allow `CONNECT` to IP addresses, so proxies that allow only hostnames are unsupported there.
Loopback destinations are always dialed directly, and `no_proxy` is honored only when `allow_private_networks` is enabled; it never makes a refused destination reachable.
A refused tunnel fails that connection without a direct fallback and logs `browser_egress_proxy_refused_tunnel`.
The primary process logs a warning and dials directly when the proxy is SOCKS, has credentials or a path in its URL, or is set through `auto_proxy` or `socks_server`.
In a worker, every set proxy variable must name the same HTTP(S) proxy, or the browser does not start; hostnames must also resolve in the worker's DNS.
That proxy receives hostnames and resolves them itself, so it must block private and metadata addresses and resist DNS rebinding.
WebRTC cannot send UDP from these browsers.

## No-Key Scrapers

### [`website`]

`website` exposes `read_url(url)` and returns JSON page documents from MindRoom's WebsiteReader variant, which drops search UI, navigation, headers, footers, sidebars, hidden content, and modals before choosing the page text.
Pages larger than 2 MiB fail, as do pages a server sends compressed.

| Option | Type | Default | Notes |
| --- | --- | --- | --- |
| `knowledge` | `object` | `null` | Programmatic `Knowledge` object only; it replaces `read_url()` with `add_website_to_knowledge(url)`. |

```yaml
agents:
  assistant:
    tools:
      - website
```

### [`trafilatura`]

`trafilatura` exposes `extract_text()`, `extract_metadata_only()`, `crawl_website()`, `html_to_text()`, and `extract_batch()`.
`extract_batch()` returns one JSON payload listing successes and failures.
`crawl_website()` stops with an error when it reaches a refused target, while `extract_content=True` reports each refused page individually.
A server that sends a compressed body fails the download.

| Option | Type | Default | Notes |
| --- | --- | --- | --- |
| `output_format` | `text` | `txt` | `txt`, `json`, `markdown`, `xml`, `csv`, or `html`. |
| `include_comments` | `boolean` | `true` | Include comments. |
| `include_tables` | `boolean` | `true` | Include tables. |
| `include_images` | `boolean` | `false` | Include image information where supported. |
| `include_formatting` | `boolean` | `false` | Preserve formatting markers. |
| `include_links` | `boolean` | `false` | Preserve links. |
| `with_metadata` | `boolean` | `false` | Include metadata in extraction output. |
| `favor_precision` | `boolean` | `false` | Bias extraction toward precision. |
| `favor_recall` | `boolean` | `false` | Bias extraction toward recall. |
| `target_language` | `text` | `null` | ISO 639-1 language filter such as `en`. |
| `deduplicate` | `boolean` | `false` | Remove repeated segments. |
| `max_tree_size` | `number` | `null` | Parser tree-size limit. |
| `max_crawl_urls` | `number` | `10` | Maximum URLs visited per crawl. |
| `max_known_urls` | `number` | `100000` | Maximum discovered URLs tracked per crawl. |
| `enable_extract_text` | `boolean` | `true` | Enable `extract_text()`. |
| `enable_extract_metadata_only` | `boolean` | `true` | Enable `extract_metadata_only()`. |
| `enable_html_to_text` | `boolean` | `true` | Enable `html_to_text()`. |
| `enable_extract_batch` | `boolean` | `true` | Enable `extract_batch()`. |
| `enable_crawl_website` | `boolean` | `true` | Enable `crawl_website()`. |
| `all` | `boolean` | `false` | Enable every function. |

```yaml
agents:
  analyst:
    tools:
      - trafilatura:
          output_format: markdown
          with_metadata: true
          include_links: true
```

### [`newspaper`]

`newspaper` exposes `read_article(url)` and returns JSON with whichever of title, authors, text, and publish date were extracted.
It is tuned for article pages, not arbitrary sites, and returns no image fields.
Use `newspaper` in `tools:`, not `newspaper4k`.

| Option | Type | Default | Notes |
| --- | --- | --- | --- |
| `article_length` | `number` | `null` | Truncate article text to this many characters. |
| `enable_read_article` | `boolean` | `true` | Enable `read_article()`. |
| `all` | `boolean` | `false` | Enable every function. |

### [`crawl4ai`]

`crawl4ai` exposes `crawl(url, search_query=None)`, where `url` is one URL or a list, and returns readable text for each page from a local headless browser.
With `search_query`, the text is filtered toward that query; without one, `use_pruning` trims noisy content.
Results skip Crawl4AI's cache and are truncated to `max_length`.

| Option | Type | Default | Notes |
| --- | --- | --- | --- |
| `max_length` | `number` | `5000` | Maximum returned characters. |
| `timeout` | `number` | `60` | Crawl timeout in seconds. |
| `use_pruning` | `boolean` | `false` | Prune noisy content when no `search_query` is given. |
| `pruning_threshold` | `number` | `0.48` | Pruning threshold. |
| `bm25_threshold` | `number` | `1.0` | Query-filter threshold when `search_query` is given. |
| `headless` | `boolean` | `true` | Run the browser headless. |
| `wait_until` | `text` | `domcontentloaded` | Playwright wait condition before extraction. |
| `enable_crawl` | `boolean` | `true` | Enable `crawl()`. |
| `all` | `boolean` | `false` | Enable every function. |

```yaml
agents:
  researcher:
    tools:
      - crawl4ai:
          max_length: 8000
          use_pruning: true
          wait_until: networkidle
```

### [`jina`]

`jina` exposes `read_url(url)` through Jina Reader and, when enabled, `search_query(query)`.
It works without a key for public reads; a key helps with paid plans and rate limits.
Returned content is truncated to `max_content_length`.

| Option | Type | Default | Notes |
| --- | --- | --- | --- |
| `api_key` | `password` | `null` | Optional; falls back to `JINA_API_KEY`. |
| `base_url` | `url` | `https://r.jina.ai/` | Reader endpoint for `read_url()`. |
| `search_url` | `url` | `https://s.jina.ai/` | Search endpoint for `search_query()`. |
| `max_content_length` | `number` | `10000` | Maximum returned characters. |
| `timeout` | `number` | `null` | Jina timeout in seconds. |
| `search_query_content` | `boolean` | `true` | Include full page content in search results; `false` returns summaries only. |
| `enable_read_url` | `boolean` | `true` | Enable `read_url()`. |
| `enable_search_query` | `boolean` | `false` | Enable `search_query()`. |
| `all` | `boolean` | `false` | Enable every function. |

## Hosted Scraping APIs

### [`firecrawl`]

`firecrawl` exposes `scrape_website()`, `crawl_website()`, `map_website()`, and `search_web()`.
It requires `api_key` or `FIRECRAWL_API_KEY`.

| Option | Type | Default | Notes |
| --- | --- | --- | --- |
| `api_key` | `password` | `null` | Falls back to `FIRECRAWL_API_KEY`. |
| `enable_scrape` | `boolean` | `true` | Enable `scrape_website()`. |
| `enable_crawl` | `boolean` | `false` | Enable `crawl_website()`. |
| `enable_mapping` | `boolean` | `false` | Enable `map_website()`. |
| `enable_search` | `boolean` | `false` | Enable `search_web()`. |
| `all` | `boolean` | `false` | Enable every function. |
| `formats` | `string[]` | `null` | Output formats for scrape, crawl, and search, such as `markdown` or `html`; check them against your plan. |
| `limit` | `number` | `10` | Default result cap for crawl and search. |
| `poll_interval` | `number` | `30` | Seconds between crawl status checks. |
| `api_url` | `url` | `https://api.firecrawl.dev` | Firecrawl API base URL. |

```yaml
agents:
  research:
    tools:
      - firecrawl:
          enable_crawl: true
          enable_search: true
          limit: 5
```

### [`spider`]

`spider` exposes `search_web(query, max_results=5)`, `scrape(url)`, and `crawl(url, limit=None)`, returning Markdown.
Search results do not include full page content.
It requires `SPIDER_API_KEY` in the environment even though the dashboard lists it as needing no setup, and it has no `api_key` field.

| Option | Type | Default | Notes |
| --- | --- | --- | --- |
| `max_results` | `number` | `null` | Default result count for `search_web()`. |
| `url` | `url` | `null` | Unused; `scrape()` and `crawl()` always take the URL as an argument. |
| `enable_search` | `boolean` | `true` | Enable `search_web()`. |
| `enable_scrape` | `boolean` | `true` | Enable `scrape()`. |
| `enable_crawl` | `boolean` | `true` | Enable `crawl()`. |
| `all` | `boolean` | `false` | Enable every function. |

### [`scrapegraph`]

`scrapegraph` turns pages into structured answers from natural-language prompts.
`smartscraper(url, prompt)` extracts data from one page, `markdownify()` converts a page to Markdown, `crawl()` applies a prompt and JSON schema across a crawl, `searchscraper()` searches the web before extracting, and `scrape()` returns raw HTML.
It requires `api_key` or `SGAI_API_KEY`.
Keep at least one function enabled, or set `all: true`.
The upstream `headers` option is not supported in `config.yaml`.

| Option | Type | Default | Notes |
| --- | --- | --- | --- |
| `api_key` | `password` | `null` | Falls back to `SGAI_API_KEY`. |
| `enable_smartscraper` | `boolean` | `true` | Enable `smartscraper()`. |
| `enable_markdownify` | `boolean` | `false` | Enable `markdownify()`. |
| `enable_crawl` | `boolean` | `false` | Enable `crawl()`. |
| `enable_searchscraper` | `boolean` | `false` | Enable `searchscraper()`. |
| `enable_scrape` | `boolean` | `false` | Enable `scrape()`. |
| `render_heavy_js` | `boolean` | `false` | Render heavy JavaScript in `scrape()` only. |
| `crawl_poll_interval` | `number` | `3` | Seconds between crawl status checks. |
| `crawl_max_wait` | `number` | `180` | Maximum seconds to wait for a crawl. |
| `all` | `boolean` | `false` | Enable every function. |

### [`apify`]

`apify` registers one tool function per configured Apify Actor, with parameters from the Actor's input schema, and returns the Actor's dataset items as JSON.
Without `actors`, it provides no functions.
Function names derive from the Actor ID, so check the agent's tool list for the exact name.

| Option | Type | Default | Notes |
| --- | --- | --- | --- |
| `apify_api_token` | `password` | `null` | Falls back to `APIFY_API_TOKEN`. |
| `actors` | `text` | `null` | One Actor ID, such as `apify/rag-web-browser`; a comma-separated list is treated as a single ID. |

```yaml
agents:
  extractor:
    tools:
      - apify:
          actors: apify/rag-web-browser
```

### [`brightdata`]

`brightdata` exposes `scrape_as_markdown()`, `get_screenshot()`, `search_engine()` for Google, Bing, and Yandex, and `web_data_feed()` for Bright Data's supported source types.
`get_screenshot()` returns the image directly to the model rather than a file path.
It requires `api_key` or `BRIGHT_DATA_API_KEY`.

| Option | Type | Default | Notes |
| --- | --- | --- | --- |
| `api_key` | `password` | `null` | Falls back to `BRIGHT_DATA_API_KEY`. |
| `enable_scrape_markdown` | `boolean` | `true` | Enable `scrape_as_markdown()`. |
| `enable_screenshot` | `boolean` | `true` | Enable `get_screenshot()`. |
| `enable_search_engine` | `boolean` | `true` | Enable `search_engine()`. |
| `enable_web_data_feed` | `boolean` | `true` | Enable `web_data_feed()`. |
| `all` | `boolean` | `false` | Enable every function. |
| `serp_zone` | `text` | `serp_api` | SERP zone; `BRIGHT_DATA_SERP_ZONE` overrides it. |
| `web_unlocker_zone` | `text` | `web_unlocker1` | Web unlocker zone; `BRIGHT_DATA_WEB_UNLOCKER_ZONE` overrides it. |
| `verbose` | `boolean` | `false` | Log extra request detail. |
| `timeout` | `number` | `600` | Timeout in seconds. |

### [`oxylabs`]

`oxylabs` exposes `search_google()`, `get_amazon_product()`, `search_amazon_products()`, and `scrape_website()`.
`search_google()` returns organic results with title, URL, description, and position.
Pass `domain_code`, such as `com` or `de`, to choose the regional Google or Amazon domain.
It requires both a username and password.

| Option | Type | Default | Notes |
| --- | --- | --- | --- |
| `username` | `text` | `null` | Falls back to `OXYLABS_USERNAME`. |
| `password` | `password` | `null` | Falls back to `OXYLABS_PASSWORD`. |
| `markdown` | `boolean` | `false` | Return `scrape_website()` content as Markdown instead of parsed HTML. |

## Browser Tools

### [`agentql`]

`agentql` exposes `scrape_website(url)`, which extracts generic page text, and `custom_scrape_website(url)`, which runs your `agentql_query` and returns the extracted values as JSON.
Setting `agentql_query` registers `custom_scrape_website()` even when `enable_custom_scrape_website` is `false`.
It opens only HTTP(S) URLs and launches a visible browser window, so it needs a GUI-capable runtime or virtual display.
It requires `api_key` or `AGENTQL_API_KEY`; AgentQL SDK settings and CLI credential files do not override that key.

| Option | Type | Default | Notes |
| --- | --- | --- | --- |
| `api_key` | `password` | `null` | Falls back to `AGENTQL_API_KEY`. |
| `enable_scrape_website` | `boolean` | `true` | Enable `scrape_website()`. |
| `enable_custom_scrape_website` | `boolean` | `false` | Enable `custom_scrape_website()`. |
| `all` | `boolean` | `false` | Enable every function. |
| `agentql_query` | `text` | `""` | AgentQL query for `custom_scrape_website()`. |

```yaml
agents:
  extractor:
    tools:
      - agentql:
          agentql_query: |
            {
              title
              links[]
            }
```

### [`browserbase`]

`browserbase` exposes `navigate_to()`, `screenshot()`, `get_page_content()`, and `close_session()` against a hosted Browserbase session that it creates automatically.
It still needs local Playwright to connect to the remote browser.
It requires an API key and project ID.
Use it when you need remote navigation, screenshots, and page reads without the full action set of `browser`.

| Option | Type | Default | Notes |
| --- | --- | --- | --- |
| `api_key` | `password` | `null` | Falls back to `BROWSERBASE_API_KEY`. |
| `project_id` | `text` | `null` | Falls back to `BROWSERBASE_PROJECT_ID`. |
| `base_url` | `url` | `null` | Browserbase API endpoint, not the site to visit; falls back to `BROWSERBASE_BASE_URL`. |
| `enable_navigate_to` | `boolean` | `true` | Enable `navigate_to()`. |
| `enable_screenshot` | `boolean` | `true` | Enable `screenshot()`. |
| `enable_get_page_content` | `boolean` | `true` | Enable `get_page_content()`. |
| `enable_close_session` | `boolean` | `true` | Enable `close_session()`. |
| `all` | `boolean` | `false` | Enable every function. |
| `parse_html` | `boolean` | `true` | Return visible text instead of raw HTML. |
| `max_content_length` | `number` | `100000` | Maximum returned characters of page content. |

### [`browser`]

`browser` exposes one function, `browser_control(action=...)`, for multi-step browser sessions.
It can drive two targets:

- `target="host"` (default) controls MindRoom's own Chromium on the MindRoom host, or in the agent's worker when `browser` is listed in `worker_tools`.
- `target="desktop"` controls the user's own signed-in Chrome or Brave profile through the [Matrix Desktop Bridge](https://docs.mindroom.chat/tools/desktop/), which owns extension installation, the local control lease, and the trust model.

Call `action="help"` or `action="actions"` to list the actions and `act` request kinds.

| Action | Host | Desktop |
| --- | --- | --- |
| `status`, `start`, `stop`, `profiles`, `tabs`, `open`, `snapshot`, `screenshot`, `console` | Yes | Yes |
| `focus`, `close`, `navigate`, `pdf`, `upload`, `dialog`, `act` | Yes | No |
| `help`, `actions` | Yes | Yes |

On the host target, `snapshot()` returns `ai` or `aria` format with element refs that later `act()` and `screenshot()` calls can use.
`act()` takes `request.kind` set to `click`, `type`, `press`, `hover`, `drag`, `select`, `fill`, `resize`, `wait`, `evaluate`, or `close`.
The desktop target returns the browser's native accessibility snapshot and rejects `targetId` and host-only options such as `profile`, snapshot format hints, `inputRef`, and `timeoutMs`.
Desktop-target calls always run in the primary process, so routing `browser` to a worker isolates only host-target calls.
To show the worker browser to the user, see [Agent Chat UI Actions](https://docs.mindroom.chat/tools/chat-ui/#control-the-browser-then-show-it).

#### Screenshots and Files

Screenshots are shown to the model by default; host screenshots are also saved, and `saveOnly=True` saves without showing.
Large images may be resized before the model sees them.
Model-visible screenshots can be kept in the agent's session history.
On the desktop target, `returnAttachment=True` with `action="screenshot"` also returns an `att_*` handle, valid until the turn ends, that `matrix_message` can send.

Host screenshots, PDFs, and other artifacts go to `output_dir`.
In the primary process it defaults to `browser/` in the agent's state root: `<storage>/agents/<agent>/browser` for a shared agent, or the requester's private-instance root for a private agent.
In a worker it defaults to `browser/` in the worker workspace, and a custom `output_dir` must stay inside that workspace.
`output_dir` must not point at runtime state, and it does not affect the desktop target.

Which files `upload` may read follows the agent's [`file_access`](https://docs.mindroom.chat/architecture/security-posture/#file-access) setting.
With the default `workspace`, `upload` accepts files in the artifact directory, files in the agent's workspace, and `att_*` IDs of attachments in the current conversation; use `./` for a workspace file whose name starts with `att_`.
Everything else is refused, including credentials, encryption keys, Matrix state, sessions, and other agents' workspaces; in a worker only the worker workspace is readable.
With `unrestricted`, `upload` accepts any file its process can read.
One browser holds at most 256 MiB of uploaded files until their tabs close, so a larger upload is refused until a tab is closed.

#### Profiles and Signed-In Sessions

Host-target profiles are named, with `mindroom` as the default; names starting with a dot are rejected.
In the primary process a profile lives at `browser-profiles/<profile>` under the agent's state root, so every requester of a shared agent shares its signed-in sessions, while different agents never share them.
This holds even with `worker_scope: user` or `user_agent` unless `browser` is in `worker_tools`, which gives each worker scope its own profiles.
Use a private agent when requesters need separate browser sessions.
Chromium is taken from `BROWSER_EXECUTABLE_PATH`, `chromium`, or `google-chrome-stable`.

#### Browser Configuration

| Option | Type | Default | Notes |
| --- | --- | --- | --- |
| `output_dir` | `text` | `null` | Host-target artifact directory; defaults to `browser/` in the agent's state root, or in the worker workspace when routed to a worker. |
| `allow_private_networks` | `boolean` | `false` | Let the host target, and URLs passed to desktop-target `open`, reach trusted private and loopback addresses; metadata and link-local addresses stay blocked. Pages on the desktop target keep the profile's normal network access either way. |
| `default_target` | `select` | `host` | `host` or `desktop`. |
| `device_user_id` | `text` | `null` | Required for the desktop target: Matrix user the desktop bridge signs in as. |
| `device_id` | `text` | `null` | Required for the desktop target: device ID printed by `mindroom desktop login`. |
| `device_ed25519` | `text` | `null` | Required for the desktop target: Ed25519 fingerprint of that device. |
| `timeout_seconds` | `number` | `90` | Desktop-target timeout, from 1 to 120 seconds. |

```yaml
agents:
  browser_worker:
    tools:
      - browser:
          default_target: desktop
          device_user_id: "@my-laptop:example.org"
          device_id: "ABCDEFGHIJ"
          device_ed25519: "desktop-device-fingerprint"
```

```python
browser_control(action="start", target="desktop")
browser_control(action="open", target="desktop", targetUrl="https://matrix.org/blog/")
browser_control(action="screenshot", target="desktop", fullPage=True, returnAttachment=True)
```

### [`web_browser_tools`]

`web_browser_tools` exposes `open_page(url, new_window=False)`, which opens an `http` or `https` URL in a tab or window of the host computer's browser for a person to use.
It refuses `file:` paths, other schemes, and scheme-less strings, and returns no page content, so it is not a scraper.
It only works on a host with a desktop browser.

| Option | Type | Default | Notes |
| --- | --- | --- | --- |
| `enable_open_page` | `boolean` | `true` | Enable `open_page()`. |
| `all` | `boolean` | `false` | Enable every function. |

## Related Docs

- [Tools Overview](https://docs.mindroom.chat/tools/)
- [Per-Agent Tool Configuration](https://docs.mindroom.chat/tools/#per-agent-tool-configuration)

# Research Sources

Use these tools to query a specific source, such as ArXiv papers, Google Scholar citations, Wikipedia summaries, PubMed literature, or Hacker News, instead of general web search.

## Tools On This Page

| Tool | Best for |
| --- | --- |
| [`arxiv`] | Searching ArXiv and reading the text of selected papers. |
| [`google_scholar`] | Cross-publisher publication search with citation counts and PDF links. |
| [`wikipedia`] | One encyclopedia summary per query. |
| [`pubmed`] | Medical and life-science literature. |
| [`hackernews`] | Top Hacker News stories and user profiles. |

## Setup

None of these tools needs an API key or OAuth.
Add the tool to an agent's `tools` list; see [Per-Agent Tool Configuration](https://docs.mindroom.chat/tools/#per-agent-tool-configuration) for inline options and `include_tools`/`exclude_tools`.
Missing Python dependencies install on first use; see [Automatic Dependency Installation](https://docs.mindroom.chat/tools/#automatic-dependency-installation).
For toolkits with an `all` option, `all: true` enables every function regardless of the individual `enable_*` flags.
Use [Web Search](https://docs.mindroom.chat/tools/web-search/) for broader web discovery or news search.

## [`arxiv`]

`arxiv` searches ArXiv and can download papers to extract their page text.

| Option | Type | Default | Notes |
| --- | --- | --- | --- |
| `enable_search_arxiv` | `boolean` | `true` | Enable `search_arxiv_and_return_articles(query, num_articles=10)`. |
| `enable_read_arxiv_papers` | `boolean` | `true` | Enable `read_arxiv_papers(id_list, pages_to_read=None)`. |
| `all` | `boolean` | `false` | Enable both functions. |
| `download_dir` | `text` | unset | Directory where downloaded PDFs are stored; when unset, PDFs go to an `arxiv_pdfs` directory inside the installed Agno package. |

Search returns JSON with each paper's title, ID, authors, categories, publish date, PDF URL, summary, and comment.
`read_arxiv_papers()` takes ArXiv IDs such as `2103.03404v1`, not a search query, and returns the same metadata plus the text of each page; `pages_to_read=None` reads every page.

```yaml
agents:
  researcher:
    tools:
      - arxiv:
          download_dir: /srv/mindroom/arxiv
```

## [`google_scholar`]

`google_scholar` provides `search_google_scholar(query, max_results=None)`, which returns a JSON list with title, authors, year, venue, abstract, citation count, publication URL, and PDF URL when available.

| Option | Type | Default | Notes |
| --- | --- | --- | --- |
| `max_results` | `number` | `5` | Result cap when the call does not pass `max_results`. |

Google Scholar has no official API, so results are scraped and citation counts, venues, and abstracts are best-effort.
Google Scholar rate-limits scrapers, and when it blocks requests the tool returns `Google Scholar is currently blocking automated requests. Try again later.`
Prefer `arxiv` or `pubmed` when they cover the topic, and use Google Scholar for cross-publisher coverage or citation counts.

## [`wikipedia`]

`wikipedia` provides `search_wikipedia(query)`, which returns the Wikipedia summary for the query as JSON.
An ambiguous query returns a list of candidate page titles instead of a summary, so retry with one of them or a more specific query.

| Option | Type | Default | Notes |
| --- | --- | --- | --- |
| `auto_suggest` | `boolean` | `true` | Let Wikipedia suggest or correct the title before lookup; set `false` for exact-title lookup. |
| `knowledge` | `text` | unset | Has no effect in YAML configuration; leave unset. |
| `all` | `boolean` | `false` | Has no effect for this toolkit. |

```yaml
agents:
  researcher:
    tools:
      - wikipedia
```

## [`pubmed`]

`pubmed` provides `search_pubmed(query, max_results=None)` through NCBI E-utilities and returns a JSON list of formatted text results.
By default each result has the title, publication year, and a summary truncated to about 200 characters.
With `results_expanded: true`, each result has the full abstract plus the first author, journal, publication type, DOI, PubMed URL, full-text URL when available, keywords, and MeSH terms.

| Option | Type | Default | Notes |
| --- | --- | --- | --- |
| `email` | `text` | `your_email@example.com` | Contact email sent to NCBI with each request; set a real address. |
| `max_results` | `number` | unset | Result cap when the call does not pass `max_results`; 10 results when neither sets it. |
| `results_expanded` | `boolean` | `false` | Return the full abstract and the expanded metadata described above. |
| `enable_search_pubmed` | `boolean` | `true` | Enable `search_pubmed()`. |
| `all` | `boolean` | `false` | Enable all functions. |
| `timeout` | `number` | `30` | Per-request HTTP timeout in seconds. |

```yaml
agents:
  clinician:
    tools:
      - pubmed:
          email: research@example.com
          max_results: 5
          results_expanded: true
```

## [`hackernews`]

`hackernews` reads the public Hacker News API.
`get_top_hackernews_stories(num_stories=10)` returns the current top story items, including title, URL, score, and author.
`get_user_details(username)` returns the user's karma, about text, and number of submitted items.

| Option | Type | Default | Notes |
| --- | --- | --- | --- |
| `enable_get_top_stories` | `boolean` | `true` | Enable `get_top_hackernews_stories()`. |
| `enable_get_user_details` | `boolean` | `true` | Enable `get_user_details()`. |
| `all` | `boolean` | `false` | Enable both functions. |
| `timeout` | `number` | `30` | Per-request HTTP timeout in seconds. |

The tool returns story metadata only; pair it with [Web Scraping & Browser](https://docs.mindroom.chat/tools/web-scraping-and-browser/) to read the linked pages.

## Related Docs

- [Tools Overview](https://docs.mindroom.chat/tools/)
- [Web Search](https://docs.mindroom.chat/tools/web-search/)
- [Web Scraping & Browser](https://docs.mindroom.chat/tools/web-scraping-and-browser/)

# AI & Generation

These tools let an agent transcribe and translate audio, generate images, video, speech, music, and sound effects, and work with provider voices.
Pick the toolkit for the provider you have an API key for:

| Tool | Provider | Functions |
| --- | --- | --- |
| [`openai`](#openai) | OpenAI | Transcription, image generation, text-to-speech |
| [`gemini`](#gemini) | Google | Image generation (Nano Banana), video generation (Veo, Vertex AI only) |
| [`groq`](#groq) | Groq | Transcription, translation to English, text-to-speech |
| [`replicate`](#replicate) | Replicate | Image or video from any prompt-only model |
| [`fal`](#fal) | Fal | Image or video generation, image editing |
| [`cartesia`](#cartesia) | Cartesia | Voice listing, voice localization, text-to-speech |
| [`eleven_labs`](#eleven_labs) | ElevenLabs | Voice listing, sound effects, text-to-speech |
| [`lumalabs`](#lumalabs) | Luma AI | Text-to-video, image-to-video |
| [`modelslabs`](#modelslabs) | ModelsLab | Image, video, GIF, music, or sound-effect generation |

## Setup

Every tool on this page needs a provider API key, except `gemini` in Vertex AI mode, which uses Google Cloud authentication.
Set `api_key` through the dashboard or credential store, not inline in YAML (see [Security Restrictions](https://docs.mindroom.chat/tools/#security-restrictions)).
When `api_key` is unset, each toolkit reads its provider environment variable, listed in its configuration table.
Missing Python dependencies install automatically on first use (see [Automatic Dependency Installation](https://docs.mindroom.chat/tools/#automatic-dependency-installation)).
Every toolkit except `modelslabs` has `enable_*` fields to turn individual functions on or off, and `all: true` enables every function.
Generated media is attached to the agent's reply.
`openai`, `gemini`, `groq`, `cartesia`, and `eleven_labs` return the media files, while `replicate`, `fal`, `lumalabs`, and `modelslabs` return provider-hosted URLs.

## `openai`

`openai` provides `transcribe_audio(audio_path)`, `generate_image(prompt)`, and `generate_speech(text_input)`.
`transcribe_audio()` takes a local file path, not a URL.
The path follows the agent's [`file_access`](https://docs.mindroom.chat/architecture/security-posture/#file-access): with the default `workspace` it must be a regular file inside the agent workspace, and relative paths resolve from there.

| Option | Type | Default | Notes |
| --- | --- | --- | --- |
| `api_key` | `password` | `null` | Falls back to `OPENAI_API_KEY`. |
| `enable_transcription` | `boolean` | `true` | Enable `transcribe_audio()`. |
| `enable_image_generation` | `boolean` | `true` | Enable `generate_image()`. |
| `enable_speech_generation` | `boolean` | `true` | Enable `generate_speech()`. |
| `all` | `boolean` | `false` | Enable all three functions. |
| `transcription_model` | `text` | `gpt-transcribe` | Model for `transcribe_audio()`. |
| `text_to_speech_voice` | `text` | `alloy` | Voice for `generate_speech()`. |
| `text_to_speech_model` | `text` | `gpt-4o-mini-tts` | Model for `generate_speech()`. |
| `text_to_speech_format` | `text` | `mp3` | Speech format, such as `mp3`, `wav`, or `opus`. |
| `image_model` | `text` | `gpt-image-2.5-sunburst` | Model for `generate_image()`. |
| `image_quality` | `text` | `null` | Passed through to the image API. |
| `image_size` | `text` | `null` | Passed through to the image API. |
| `image_style` | `text` | `null` | Passed through to the image API. |

```yaml
agents:
  illustrator:
    tools:
      - openai:
          enable_transcription: false
          enable_speech_generation: false
```

The `dalle` toolkit no longer exists, and a config that lists it fails with `Unknown tool 'dalle'.`
Replace the whole `dalle` block with the image-only `openai` block above, move supported overrides to the `image_*` options, and change instructions that call `create_image()` to `generate_image()`.
The `desi_vocal` toolkit also no longer exists; use `openai` or `eleven_labs` for speech.

## `gemini`

`gemini` provides `generate_image(prompt)` and `generate_video(prompt)`.
Generated images are square (1:1).
`generate_video()` works only in Vertex AI mode and otherwise returns `Video generation requires Vertex AI mode.`

| Option | Type | Default | Notes |
| --- | --- | --- | --- |
| `api_key` | `password` | `null` | Gemini API key, falling back to `GOOGLE_API_KEY`. Required unless Vertex AI mode is on. |
| `vertexai` | `boolean` | `false` | Use Vertex AI instead of the Gemini API. `GOOGLE_GENAI_USE_VERTEXAI=true` also enables it. Required for `generate_video()`. |
| `project_id` | `text` | `null` | Vertex project, falling back to `GOOGLE_CLOUD_PROJECT`. |
| `location` | `text` | `null` | Vertex location, falling back to `GOOGLE_CLOUD_LOCATION`. |
| `image_generation_model` | `text` | `gemini-3.1-flash-image` | Model for `generate_image()` (Nano Banana 2). |
| `video_generation_model` | `text` | `veo-3.1-generate-001` | Model for `generate_video()` (Veo 3.1). |
| `enable_generate_image` | `boolean` | `true` | Enable `generate_image()`. |
| `enable_generate_video` | `boolean` | `true` | Enable `generate_video()`. |
| `all` | `boolean` | `false` | Enable both functions. |

One `gemini` toolkit uses a single Vertex location for both functions.
Google lists `global`, `us`, and `eu` for [Gemini 3.1 Flash Image](https://docs.cloud.google.com/gemini-enterprise-agent-platform/models/gemini/3-1-flash-image) and `us-central1` for [Veo 3.1](https://docs.cloud.google.com/gemini-enterprise-agent-platform/models/veo/3-1-generate), with no shared location.
To use both on Vertex AI, give each to a separate agent and disable the other function:

```yaml
agents:
  illustrator:
    tools:
      - gemini:
          vertexai: true
          project_id: my-gcp-project
          location: global
          enable_generate_video: false
  filmmaker:
    tools:
      - gemini:
          vertexai: true
          project_id: my-gcp-project
          location: us-central1
          enable_generate_image: false
```

## `groq`

`groq` provides `transcribe_audio(audio_source)`, `translate_audio(audio_source)`, and `generate_speech(text_input)`.
`transcribe_audio()` and `translate_audio()` accept a public `http` or `https` URL or a local file path.
A local path follows the agent's [`file_access`](https://docs.mindroom.chat/architecture/security-posture/#file-access): with the default `workspace` it must be a regular file inside the agent workspace, and relative paths resolve from there.
`translate_audio()` translates the audio to English.
`generate_speech()` always returns WAV audio.

| Option | Type | Default | Notes |
| --- | --- | --- | --- |
| `api_key` | `password` | `null` | Falls back to `GROQ_API_KEY`. |
| `transcription_model` | `text` | `whisper-large-v3` | Model for `transcribe_audio()`. |
| `translation_model` | `text` | `whisper-large-v3` | Model for `translate_audio()`. |
| `tts_model` | `text` | `canopylabs/orpheus-v1-english` | Model for `generate_speech()`. |
| `tts_voice` | `text` | `troy` | Voice for `generate_speech()`. |
| `enable_transcribe_audio` | `boolean` | `true` | Enable `transcribe_audio()`. |
| `enable_translate_audio` | `boolean` | `true` | Enable `translate_audio()`. |
| `enable_generate_speech` | `boolean` | `true` | Enable `generate_speech()`. |
| `all` | `boolean` | `false` | Enable all three functions. |

## `replicate`

`replicate` provides `generate_media(prompt)`, which runs the configured Replicate model with only a `prompt` input.
It works only with models that accept a single `prompt` field and return files whose URLs end in an image or video extension.

| Option | Type | Default | Notes |
| --- | --- | --- | --- |
| `api_key` | `password` | `null` | Falls back to `REPLICATE_API_KEY`. `REPLICATE_API_TOKEN` is not read. |
| `model` | `text` | `minimax/h3` | Replicate model reference. |
| `enable_generate_media` | `boolean` | `true` | Enable `generate_media()`. |
| `all` | `boolean` | `false` | Enable `generate_media()`. |

## `fal`

`fal` provides `generate_media(prompt)` and, when enabled, `image_to_image(prompt, image_url)`.
`generate_media()` runs the configured `model` with only a `prompt` input.
`image_to_image()` edits the image at `image_url` with [Fal's FLUX.2 edit model](https://fal.ai/models/fal-ai/flux-2/edit/api), regardless of `model`.

| Option | Type | Default | Notes |
| --- | --- | --- | --- |
| `api_key` | `password` | `null` | Falls back to `FAL_API_KEY`. |
| `model` | `text` | `fal-ai/hunyuan-video-v1.5/text-to-video` | Model for `generate_media()`. |
| `enable_generate_media` | `boolean` | `true` | Enable `generate_media()`. |
| `enable_image_to_image` | `boolean` | `false` | Enable `image_to_image()`. |
| `all` | `boolean` | `false` | Enable both functions. |

## `cartesia`

`cartesia` provides `list_voices()`, `localize_voice(name, description, language, original_speaker_gender, voice_id=None)`, and `text_to_speech(transcript, voice_id=None)`.
`localize_voice()` creates a new voice in another language from an existing one.
Both `localize_voice()` and `text_to_speech()` use `default_voice_id` unless the call passes `voice_id`.
`text_to_speech()` always returns MP3 audio at 44.1 kHz and 128 kbps.

| Option | Type | Default | Notes |
| --- | --- | --- | --- |
| `api_key` | `password` | `null` | Falls back to `CARTESIA_API_KEY`. |
| `model_id` | `text` | `sonic-3.6` | Model for `text_to_speech()`. |
| `default_voice_id` | `text` | `78ab82d5-25be-4f7d-82b3-7ad64e5b85b2` | Voice used when a call passes no `voice_id`. |
| `enable_text_to_speech` | `boolean` | `true` | Enable `text_to_speech()`. |
| `enable_list_voices` | `boolean` | `true` | Enable `list_voices()`. |
| `enable_localize_voice` | `boolean` | `false` | Enable `localize_voice()`. |
| `all` | `boolean` | `false` | Enable all functions. |

## `eleven_labs`

`eleven_labs` provides `get_voices()`, `generate_sound_effect(prompt, duration_seconds=None)`, and `text_to_speech(prompt)`.
The default [Eleven v4 model](https://elevenlabs.io/docs/overview/models) accepts up to 10,000 characters per request.
Audio attachments are always labeled MP3 (`audio/mpeg`), even when `output_format` selects PCM or u-law.

| Option | Type | Default | Notes |
| --- | --- | --- | --- |
| `api_key` | `password` | `null` | Falls back to `ELEVEN_LABS_API_KEY`. |
| `voice_id` | `text` | `JBFqnCBsd6RMkjVDRZzb` | Voice for `text_to_speech()`. |
| `model_id` | `text` | `eleven_v4` | Model for `text_to_speech()`. |
| `output_format` | `text` | `mp3_44100_64` | Codec and bitrate preset. |
| `target_directory` | `text` | `null` | Also save generated audio files to this directory. |
| `enable_get_voices` | `boolean` | `true` | Enable `get_voices()`. |
| `enable_generate_sound_effect` | `boolean` | `true` | Enable `generate_sound_effect()`. |
| `enable_text_to_speech` | `boolean` | `true` | Enable `text_to_speech()`. |
| `all` | `boolean` | `false` | Enable all functions. |

## `lumalabs`

`lumalabs` provides `generate_video(prompt, loop=False, aspect_ratio="16:9", keyframes=None)` and `image_to_video(prompt, start_image_url, end_image_url=None, loop=False, aspect_ratio="16:9")`.
`image_to_video()` animates between one or two remote image URLs; local file paths are not accepted.
Both functions wait for the video until it finishes or `max_wait_time` passes.
Keep `wait_for_completion: true`, because `false` makes both functions return `Async generation unsupported` without a job handle.

| Option | Type | Default | Notes |
| --- | --- | --- | --- |
| `api_key` | `password` | `null` | Falls back to `LUMAAI_API_KEY`. |
| `model` | `select` | `ray-2` | [Dream Machine](https://docs.lumalabs.ai/docs/video-generation) model: `ray-2` or `ray-flash-2`. `null` means `ray-2`. |
| `wait_for_completion` | `boolean` | `true` | Wait for the finished video. |
| `poll_interval` | `number` | `3` | Seconds between status checks. |
| `max_wait_time` | `number` | `300` | Seconds to wait before timing out. |
| `enable_generate_video` | `boolean` | `true` | Enable `generate_video()`. |
| `enable_image_to_video` | `boolean` | `true` | Enable `image_to_video()`. |
| `all` | `boolean` | `false` | Enable both functions. |

## `modelslabs`

`modelslabs` provides `generate_media(prompt)`, and `file_type` decides what it generates:

| `file_type` | Output | Default model |
| --- | --- | --- |
| `png`, `jpg` | Image | `flux` |
| `mp4`, `gif` | Video | `cogvideox` |
| `mp3` | Music | Provider default |
| `wav` | 10-second sound effect | Provider default |

`model_id`, `width`, and `height` apply to images and video.
Without `wait_for_completion`, a queued job returns its URLs and the provider ETA, and those URLs may not be ready yet.
With `wait_for_completion: true`, the tool checks the job about once per second for the provider ETA plus `add_to_eta` attempts, capped at `max_wait_time` attempts, and returns the finished URLs.
If the job is still unfinished at the cap, the tool returns the queued URLs, and the job may still complete on the provider side.
A job the provider rejects returns the provider's message instead of media.

| Option | Type | Default | Notes |
| --- | --- | --- | --- |
| `api_key` | `password` | `null` | Falls back to `MODELS_LAB_API_KEY`. |
| `file_type` | `text` | `mp4` | `png`, `jpg`, `mp4`, `gif`, `mp3`, or `wav`. |
| `model_id` | `text` | `null` | Image or video model override. |
| `width` | `number` | `512` | Image or video width. |
| `height` | `number` | `512` | Image or video height. |
| `wait_for_completion` | `boolean` | `false` | Wait for the finished output. |
| `add_to_eta` | `number` | `15` | Extra one-second attempts added to the provider ETA. |
| `max_wait_time` | `number` | `60` | Maximum one-second attempts; `0` skips waiting. HTTP request time is additional. |

```yaml
agents:
  generator:
    tools:
      - modelslabs:
          file_type: gif
          wait_for_completion: true
          max_wait_time: 90
```

## Related Docs

- [Tools Overview](https://docs.mindroom.chat/tools/)
- [OpenAI-Compatible API](https://docs.mindroom.chat/openai-api/)

# Media & Content

Use these tools to caption and extract audio from local videos, search GIFs and stock photos, read YouTube metadata and transcripts, look up brand assets, and work with Spotify.

## Tools On This Page

- [`moviepy_video_tools`] - Extract audio, save SRT files, and burn word-highlighted captions into local videos.
- [`giphy`] - Search Giphy for animated GIFs.
- [`youtube`] - Read metadata, captions, and timestamped transcript lines for a YouTube URL.
- [`unsplash`] - Search Unsplash stock photos and read photo metadata.
- [`brandfetch`] - Look up logos, colors, fonts, and other brand data by domain, brand ID, ISIN, stock ticker, or name.
- [`spotify`] - Search music, manage playlists, get recommendations, and control playback.

## Setup

`moviepy_video_tools` and `youtube` need no credentials.
`giphy` and `unsplash` need an API key, `brandfetch` needs an API key or a client ID depending on the enabled function, and `spotify` needs an OAuth access token, normally from the dashboard connection.
Enter `api_key`, `access_key`, and `access_token` in the dashboard **Tools** tab, not in `config.yaml` (see [Security Restrictions](https://docs.mindroom.chat/tools/#security-restrictions)).
`giphy`, `unsplash`, and `brandfetch` also read the environment variables named in their configuration tables when no key is stored.
Missing Python dependencies install on first use (see [Automatic Dependency Installation](https://docs.mindroom.chat/tools/#automatic-dependency-installation)).

## [`moviepy_video_tools`]

`moviepy_video_tools` processes video files on disk; it cannot search for, download, or host videos.

### What It Does

- `extract_audio(video_path, output_path)` saves a video's audio track to `output_path`.
- `create_srt(transcription, output_path)` writes the given text to disk unchanged, so the text must already be SRT-formatted.
- `embed_captions(video_path, srt_path, output_path=None, font_size=24, font_color="white", stroke_color="black", stroke_width=1)` renders an MP4 with word-by-word highlighted captions from an SRT file.

In `embed_captions()`, `font_size` is the caption text size in pixels and `font_color` is the base text color, while the word being spoken is always yellow.
`stroke_color` and `stroke_width` set the text outline, and `stroke_width=0` removes it.
Captions sit at the bottom of the video, and a word or caption line too large to fit at the requested size returns an error.
Without `output_path`, the output is `<video>_captioned.mp4` next to the input, where `<video>` is the input file name without its extension.

### Files And Limits

All inputs are local file paths, not URLs or attachment IDs.
Paths follow the agent's [`file_access`](https://docs.mindroom.chat/configuration/agents/): with the default `workspace`, relative paths resolve from the agent workspace and paths outside it are refused, while `unrestricted` allows any file the runtime can reach.
A video larger than 1 GiB or a caption file larger than 1 MiB returns an error.
Video inputs must be MP4, MOV, M4A, 3GP, 3G2, Motion JPEG 2000, Matroska, WebM, AVI, MPEG-TS, MPEG program stream (`.mpg`, `.vob`), FLV, WMV or other ASF, GIF, Ogg, WAV, MP3, FLAC, or AAC.
Other formats, including HLS and DASH playlists and manifests, return an error containing `Video input must be a supported plain media file; playlists and manifests are refused.`
Real audio and video processing needs FFmpeg in the runtime that executes the tool.

### Configuration

| Option | Type | Default | Notes |
| --- | --- | --- | --- |
| `enable_process_video` | `boolean` | `true` | Enable `extract_audio()`. |
| `enable_generate_captions` | `boolean` | `true` | Enable `create_srt()`. |
| `enable_embed_captions` | `boolean` | `true` | Enable `embed_captions()`. |
| `all` | `boolean` | `false` | Enable all three functions regardless of the `enable_*` options. |

### Example

```yaml
agents:
  editor:
    tools:
      - moviepy_video_tools:
          enable_process_video: false
```

```python
create_srt(transcription_srt, "clips/demo.srt")
embed_captions("clips/demo.mp4", "clips/demo.srt", output_path="clips/demo_captioned.mp4")
```

## [`giphy`]

`giphy` provides `search_gifs(query)`, which returns Giphy-hosted GIF URLs and attaches the GIFs as images to the tool result.
The number of GIFs per search is set by `limit`, not per call.
A Giphy API key is needed for searches to succeed, even though the field is marked optional.

| Option | Type | Default | Notes |
| --- | --- | --- | --- |
| `api_key` | `password` | `null` | Giphy API key; falls back to `GIPHY_API_KEY`. |
| `limit` | `number` | `1` | GIFs returned per search. |
| `enable_search_gifs` | `boolean` | `true` | Enable `search_gifs()`. |
| `all` | `boolean` | `false` | Enable all functions. |

```yaml
agents:
  social:
    tools:
      - giphy:
          limit: 3
```

## [`youtube`]

`youtube` works on one YouTube video URL; it cannot search YouTube by keyword, so use a search tool such as `serpapi` for discovery.

- `get_youtube_video_data(url)` returns title, author, thumbnail, size, and provider fields.
- `get_youtube_video_captions(url)` returns the video's transcript text.
- `get_video_timestamps(url)` returns transcript lines with timestamps.

Invalid or unsupported URLs return an error message.

| Option | Type | Default | Notes |
| --- | --- | --- | --- |
| `enable_get_video_captions` | `boolean` | `true` | Enable `get_youtube_video_captions()`. |
| `enable_get_video_data` | `boolean` | `true` | Enable `get_youtube_video_data()`. |
| `enable_get_video_timestamps` | `boolean` | `true` | Enable `get_video_timestamps()`. |
| `all` | `boolean` | `false` | Enable all functions. |
| `languages` | `string[]` | `null` | Preferred transcript languages, for example `["en", "es"]`; affects only the caption and timestamp functions. |
| `timeout` | `number` | `30` | Timeout in seconds for `get_youtube_video_data()`; caption and timestamp requests do not use it. |

Proxy settings are not configurable.

```yaml
agents:
  researcher:
    tools:
      - youtube:
          languages: [en]
```

## [`unsplash`]

`unsplash` returns stock photo metadata and image URLs, not downloaded files, and is not a source for logos or brand assets.

- `search_photos(query, per_page=10, page=1, orientation=None, color=None)` returns the total match count and a list of photos with author and image URLs.
- `get_photo(photo_id)` adds details such as EXIF data, views, downloads, and location when Unsplash provides them.
- `get_random_photo(query=None, orientation=None, count=1)` returns one or more random photos, optionally matching a query.
- `download_photo(photo_id)` reports a download to Unsplash, as its API guidelines require, and returns the download URL without fetching the image.

| Option | Type | Default | Notes |
| --- | --- | --- | --- |
| `access_key` | `password` | `null` | Required Unsplash access key; falls back to `UNSPLASH_ACCESS_KEY`. Get one from [Unsplash Developers](https://unsplash.com/developers). |
| `enable_search_photos` | `boolean` | `true` | Enable `search_photos()`. |
| `enable_get_photo` | `boolean` | `true` | Enable `get_photo()`. |
| `enable_get_random_photo` | `boolean` | `true` | Enable `get_random_photo()`. |
| `enable_download_photo` | `boolean` | `false` | Enable `download_photo()`. |
| `all` | `boolean` | `false` | Enable all functions. |
| `timeout` | `number` | `30` | Request timeout in seconds. |

```python
search_photos("conference stage lighting", per_page=5, orientation="landscape")
get_random_photo(query="workspace desk", count=3)
```

## [`brandfetch`]

`brandfetch` returns brand identity data such as logos, colors, and fonts.

- `search_by_identifier(identifier)` looks up a domain, Brandfetch brand ID, ISIN, or stock ticker and needs `api_key`.
- `search_by_brand(name)` finds brands by name when you do not know the domain, needs `client_id`, and is off by default.

| Option | Type | Default | Notes |
| --- | --- | --- | --- |
| `api_key` | `password` | `null` | Brand API key for `search_by_identifier()`; falls back to `BRANDFETCH_API_KEY`. |
| `client_id` | `text` | `null` | Brand Search client ID for `search_by_brand()`; falls back to `BRANDFETCH_CLIENT_ID`. |
| `enable_search_by_identifier` | `boolean` | `true` | Enable `search_by_identifier()`. |
| `enable_search_by_brand` | `boolean` | `false` | Enable `search_by_brand()`. |
| `base_url` | `url` | `https://api.brandfetch.io/v2` | Brandfetch API base URL. |
| `timeout` | `number` | `20.0` | Request timeout in seconds. |
| `all` | `boolean` | `false` | Enable all functions. |

Get keys from [Brandfetch Developers](https://developers.brandfetch.com/).

```yaml
agents:
  branding:
    tools:
      - brandfetch:
          enable_search_by_brand: true
```

```python
search_by_identifier("openai.com")
search_by_brand("OpenAI")
```

## [`spotify`]

`spotify` acts on the connected Spotify account.
It provides `search_tracks()`, `search_playlists()`, `search_artists()`, `search_albums()`, `get_user_playlists()`, `get_track_recommendations()`, `get_artist_top_tracks()`, `get_album_tracks()`, `get_my_top_tracks()`, `get_my_top_artists()`, `create_playlist()`, `add_tracks_to_playlist()`, `get_playlist()`, `update_playlist_details()`, `remove_tracks_from_playlist()`, `get_current_user()`, `play_track()`, and `get_currently_playing()`.

### Connect Spotify

1. Create an app in the [Spotify Developer Dashboard](https://developer.spotify.com/dashboard) and add the redirect URI `<dashboard URL>/api/integrations/spotify/callback`.
2. Set `SPOTIFY_CLIENT_ID` and `SPOTIFY_CLIENT_SECRET` in MindRoom's environment; without them, connecting fails with `Spotify OAuth not configured. Set SPOTIFY_CLIENT_ID and SPOTIFY_CLIENT_SECRET environment variables.`
3. If the dashboard is reached through a different public URL than the one MindRoom sees, set `SPOTIFY_REDIRECT_URI` to the exact registered redirect URI.
4. In the dashboard **Tools** tab, connect Spotify and approve access.

The connection requests the scopes `user-read-private`, `user-read-email`, `user-read-playback-state`, `user-read-currently-playing`, `user-top-read`, `playlist-read-private`, `playlist-modify-public`, `playlist-modify-private`, and `user-modify-playback-state`.
MindRoom renews the access token automatically, so a connection keeps working after Spotify's one-hour token lifetime.
A connection that a shared-scope agent uses through `defaults.worker_grantable_credentials` is not renewed, so reconnect Spotify when its token expires.

`spotify` requires `worker_scope` unset or `shared` and always runs in the primary runtime (see [Shared-only integrations](https://docs.mindroom.chat/deployment/sandbox-proxy/#shared-only-integrations)).

### Configuration

| Option | Type | Default | Notes |
| --- | --- | --- | --- |
| `access_token` | `password` | `null` | Required Spotify OAuth access token, saved by the dashboard connection or entered manually; a manually entered token is not renewed and stops working when it expires. |
| `default_market` | `text` | `US` | Default market code for search and album lookups. |
| `timeout` | `number` | `30` | Request timeout in seconds. |

```yaml
agents:
  dj:
    tools:
      - spotify:
          default_market: GB
```

```python
search_tracks("ambient coding music", max_results=5)
create_playlist("MindRoom Picks", description="Tracks from this week's chat")
```

### Troubleshooting

- If playlist changes or playback control fail with a permissions error, disconnect and reconnect Spotify to grant the current scopes.
- `play_track()` needs an active Spotify device and returns a `NO_ACTIVE_DEVICE` error when no device can play.
- `get_track_recommendations()` also needs a Spotify app with access to the Recommendations endpoint, which OAuth scopes cannot grant; Spotify [restricts this endpoint](https://developer.spotify.com/blog/2024-11-27-changes-to-the-web-api) for new and affected Development Mode apps.

## Related Docs

- [Tools Overview](https://docs.mindroom.chat/tools/)
- [Per-Agent Tool Configuration](https://docs.mindroom.chat/tools/#per-agent-tool-configuration)
- [Sandbox Proxy Isolation](https://docs.mindroom.chat/deployment/sandbox-proxy/)

# Matrix & Threads

These built-in tools let an agent work inside its current Matrix room and thread: inspect rooms, send voice notes, tag, resolve, and summarize threads, make low-level Matrix API calls, and reuse conversation attachments.
This page also covers automatic thread summaries.

## Tools On This Page

- [`matrix_message`](https://docs.mindroom.chat/tools/matrix-message/#matrix-messages) - Send, read, edit, and react in Matrix conversations.
- [`matrix_room`] - Inspect room metadata, available agents, members, thread roots, and room state.
- [`matrix_voice_message`] - Generate speech from text and send it as a Matrix voice note.
- [`thread_tags`] - Add, remove, and list shared tags on Matrix threads.
- [`thread_resolution`] - Resolve or reopen threads in the current room.
- [`thread_summary`] - Set or replace a thread's summary.
- [`thread_model`](https://docs.mindroom.chat/configuration/models/#thread_model) - List models or show, switch, and reset the current thread's model override.
- [`matrix_api`] - Low-level Matrix event, state, redaction, and search calls with explicit IDs.
- [`attachments`](https://docs.mindroom.chat/attachments/#the-attachments-tool) - List, inspect, and register attachment IDs for later tool calls.

None of these tools has tool-specific configuration fields except [`matrix_voice_message`].
Enable them by listing them in an agent's `tools`, as described in [Per-Agent Tool Configuration](https://docs.mindroom.chat/tools/#per-agent-tool-configuration).
Enabling `matrix_message` also enables `attachments` and `matrix_room`.

## Tool Runtime Context

These tools work only while an agent is responding in a Matrix conversation.
Unless the call passes explicit IDs, they act on the current room and thread.
`thread_tags`, `thread_resolution`, and `thread_summary` accept a reply event ID as `thread_id` and act on its thread root; other tools need the thread root ID itself.
Tools that accept `room_id` can target another room only when the requester has access to the agent and is currently joined to that room.
`matrix_api` is the exception: it defaults `room_id` to the current room but never infers event IDs, thread IDs, or state keys from the conversation.
Attachment IDs (`att_*`) are limited to the current conversation and IDs registered earlier in the same tool run; see [Attachments](https://docs.mindroom.chat/attachments/#attachment-ids).

## [`matrix_room`]

`matrix_room(action="room-info", room_id=None, limit=None, event_type=None, state_key=None, page_token=None)` reads room data and never writes.
Use `matrix_message` or `matrix_api` for writes.

| Action | Returns |
|--------|---------|
| `room-info` | Room name, topic, encryption, member count, join rule, canonical alias, room version, guest access, creator, power-level summary, and the current `thread_id`, `reply_to_event_id`, `requester_id`, and `agent_name` (the thread is omitted when inspecting another room) |
| `members` | Joined users with display names, avatar URLs, and power levels |
| `agents` | Agents that can currently answer this requester in the room, each with `name`, `matrix_user_id`, `description`, and `thread_mode` (`thread` or `room`) |
| `threads` | Thread-root previews with sender, timestamp, reply count, and latest activity; `limit` defaults to 20 (range 1-50), and `next_token` plus `has_more` support paging through `page_token` |
| `state` | With `event_type`, one state event (`state_key` defaults to empty); without it, a summary of up to 100 non-member state events |

Pass an `agents` result's `name` as `recipient` in [`matrix_message`](https://docs.mindroom.chat/tools/matrix-message/#agent-conversations) to address that agent.
The list covers conversation targets only; the `run_subagent` tool describes the caller's allowed subagents.
Each agent, requester, and room combination may make 20 calls per 30 seconds.

```python
matrix_room(action="agents")
matrix_room(action="threads", limit=10)
matrix_room(action="threads", page_token="next-page-token")
matrix_room(action="state", event_type="m.room.topic")
```

## [`matrix_voice_message`]

`matrix_voice_message(text, room_id=None, thread_id=None, caption=None, companion_message=None)` converts text to speech and sends it as one Matrix voice note.
It targets the current room and thread by default; pass `thread_id="room"` to post at room level.
`caption` sets the audio event's body, and `companion_message` sends a separate readable text message to the same place.
The result includes `event_id` and, when companion text was sent, `companion_event_id`.
Each agent, requester, and room combination may send six voice messages per 30 seconds.

| Field | Default | Meaning |
|-------|---------|---------|
| `api_key` | none | Speech API key; when empty, OpenAI models use `OPENAI_API_KEY` (or `OPENAI_API_KEY_FILE`, or a stored credential) and OpenRouter models use `OPENROUTER_API_KEY` |
| `model` | `gpt-4o-mini-tts` | Plain IDs use OpenAI; provider-prefixed IDs such as `hexgrad/kokoro-82m` route through OpenRouter |
| `base_url` | none | Any OpenAI-compatible speech endpoint, such as a local Kokoro server; also settable through the `TTS_URL` secret |
| `voice` | `alloy` | Voice name passed to the endpoint |
| `response_format` | `opus` | Format the endpoint returns: `aac`, `flac`, `mp3`, `opus`, or `wav`; OpenRouter always uses `mp3` |

`base_url` accepts a bare host, a `/v1` URL, or a full `/audio/speech` URL, which is used as given.
A custom non-OpenRouter `base_url` without `api_key` never receives your OpenAI key.
Formats other than `opus` require `ffmpeg` and `ffprobe` on the backend host; `opus` works without them but the voice note then has no duration.

```yaml
agents:
  assistant:
    tools:
      - matrix_voice_message:
          base_url: http://localhost:8880/v1
```

```python
matrix_voice_message(
    "The build finished successfully.",
    companion_message="The build finished successfully.",
)
```

## [`thread_tags`]

`thread_tags` adds, removes, and lists tags that every member sees on a thread, such as on MindRoom Chat thread cards.

- `tag_thread(tag, thread_id=None, room_id=None, note=None, data=None)` adds or updates one tag and returns the thread's updated tags.
- `untag_thread(tag, thread_id=None, room_id=None)` removes one tag and returns the updated tags.
- `list_thread_tags(thread_id=None, room_id=None, tag=None, include_tag=None, exclude_tag=None, include_untagged=False)` lists tags for one thread or the whole room.

Without `thread_id`, `tag_thread()` and `untag_thread()` use the current thread; outside a thread, pass `thread_id`.
Without `thread_id`, `list_thread_tags()` lists the current thread, or the whole room when called outside a thread or with `room_id`.

Tag names that are already valid IDs of up to 50 characters are kept; other input is lowercased and hyphenated into a tag of at most 25 characters.
The `tag_thread()` description shows the agent up to 20 of the room's most-used tags, refreshed daily, so it reuses existing vocabulary.
Some tags validate their `data`: `blocked.data.blocked_by` must be a list of strings, `waiting.data.waiting_on` a non-empty string, `priority.data.level` one of `high`, `medium`, or `low`, and `due.data.deadline` an ISO-8601 timestamp.

`resolved` is thread lifecycle state, so `tag_thread()` and `untag_thread()` reject it; use [`thread_resolution`] instead.
Listing and filtering by `resolved` still works.

Filters for `list_thread_tags()`:

- `tag` keeps only that tag in the output.
- `include_tag` returns only threads that have the tag, and `exclude_tag` returns only threads without it; they can be combined.
- `include_untagged=True` lists the whole room, including threads that have no tags, and cannot be combined with `thread_id`.
  It reads at most 2,000 thread roots, so check the `truncated` field before treating the list as complete.

```python
tag_thread("blocked", data={"blocked_by": ["$otherThread"]})
untag_thread("blocked")
list_thread_tags(exclude_tag="resolved", include_untagged=True)  # unresolved threads
```

Writing a tag requires both the agent's Matrix account and the requesting user to have enough power level to send room state in the target room, and the requesting user must be joined to it.

## [`thread_resolution`]

`thread_resolution` lets an agent resolve or reopen threads in the current room.
It is not in starter configs or default tool sets, so agents cannot resolve threads unless an operator grants it or an agent with [`self_config`](https://docs.mindroom.chat/tools/agent-orchestration/#self_config) adds it to its own tools.

`resolve_thread(thread_id=None)` adds the `resolved` state, and `reopen_thread(thread_id=None)` removes it.
Without `thread_id`, both require an active thread.
Pass a thread root or reply event ID to target another thread in the current room, including from the room timeline; an ID that does not resolve to a thread returns an error and changes nothing.

```yaml
agents:
  triage:
    tools:
      - thread_tags
      - thread_resolution
```

To close out finished threads, find candidates with `list_thread_tags(exclude_tag="resolved", include_untagged=True)`, check `truncated`, then call `resolve_thread(thread_id=...)` for each one.

## [`thread_summary`]

`set_thread_summary(summary, thread_id=None, room_id=None, pin=True)` posts a new summary notice in the thread, which MindRoom Chat shows as the thread title.
`summary` must be plain text of 1-300 characters after whitespace normalization.
Without `thread_id`, it uses the current thread; outside a thread, pass `thread_id`.

By default a manual summary pins the thread, so [automatic summaries](#automatic-thread-summaries) stop updating it.
Pass `pin=False` for a summary that later automatic summaries may replace; this also releases an earlier pin.
Editing a summary in MindRoom Chat also pins it when the editor can address an agent in that room.
A pinned thread receives no automatic summaries or automatic topic tags; add tags with [`thread_tags`] instead.

The tool returns an error without posting when it cannot read the thread's complete history, which always happens for threads longer than 2,000 messages or 16 MiB of content.

```python
set_thread_summary("Decision: ship the current plan and revisit logs tomorrow.")
set_thread_summary("Routine status update.", pin=False)
set_thread_summary("Summary for the import thread.", thread_id="$threadRoot", room_id="!ops:example.org")
```

## [`matrix_api`]

`matrix_api` gives exact control over Matrix events and room state when `matrix_message` is not enough.
Grant it only to agents that need raw Matrix access.
Every action returns a JSON payload, including errors.

| Action | Required arguments |
|--------|--------------------|
| `send_event` | `event_type`, `content` |
| `get_state` | `event_type`, optional `state_key` |
| `put_state` | `event_type`, `content`, optional `state_key` |
| `redact` | `event_id`, optional `reason` |
| `get_event` | `event_id` |
| `search` | `search_term`, optional `keys`, `order_by`, `limit`, `next_batch`, `filter`, `event_context` |

`room_id` defaults to the current room; see [Tool Runtime Context](#tool-runtime-context) for other rooms.
`dry_run=true` validates `send_event`, `put_state`, and `redact` without writing.
Writes are logged and limited to 8 units per 60 seconds for each agent, requester, and room, where `send_event` costs 1 and `put_state` and `redact` cost 2.
A message sent with `send_event` for a human requester triggers the agents it mentions as that requester, like an ordinary agent reply.

Write restrictions:

- `m.room.create` is always blocked.
- `m.room.power_levels`, `m.room.encryption`, `m.room.server_acl`, `m.room.join_rules`, `m.room.history_visibility`, `m.room.guest_access`, `m.room.member`, `m.room.canonical_alias`, `m.room.tombstone`, and `m.room.third_party_invite` are dangerous: `put_state` needs `allow_dangerous=true`, and the requesting user (or one of their bridge aliases) must be joined to the room with power level 100.
- Redacting a state event follows the same rules as writing that state type.
- The `com.mindroom.*` and `io.mindroom.*` namespaces are reserved: no event type in them can be written, and `content` cannot contain keys in them at any depth.

`search` is read-only full-text search in one room.
It uses the top-level `limit` (1-50, default 10), rejects `filter.limit`, and `keys` may include `content.body`, `content.name`, and `content.topic`.
Set `event_context={"include_profile": true}` to include sender profiles in the surrounding context.

```python
matrix_api(action="get_event", event_id="$event123")
matrix_api(action="put_state", event_type="com.example.marker", state_key="status", content={"value": "ready"})
matrix_api(action="redact", event_id="$event123", reason="Cleanup")
matrix_api(
    action="search",
    search_term="deployment incident",
    keys=["content.body"],
    event_context={"before_limit": 1, "after_limit": 1, "include_profile": True},
)
```

## Related Matrix Runtime Features

### Automatic Thread Summaries

MindRoom posts a summary notice in a thread after a response brings it to `defaults.thread_summary_first_threshold` messages (default `1`), then again after every `defaults.thread_summary_subsequent_interval` further messages (default `10`).
Threads that are pinned with [`thread_summary`] or marked `resolved` are skipped, as are threads whose full history cannot be read, which always includes threads longer than 2,000 messages or 16 MiB of content.
For threads with more than 50 messages, the summary model sees only the first three and last three.

The summary uses the first match of:

1. `room_thread_summary_models`, keyed by managed room alias or Matrix room ID.
2. `room_thread_summary_models`, keyed by the responding agent or team name, which covers ad-hoc rooms without a managed alias.
3. `defaults.thread_summary_model`.
4. The `default` model.

```yaml
defaults:
  thread_summary_model: luna
room_thread_summary_models:
  dev: sonnet
```

`defaults.thread_summary_temperature` (default `0.2`, `null` for provider defaults) applies when the provider supports a temperature override.
Models that accept only their provider's default sampling always use it: Vertex AI Claude, Claude Fable, Opus, and Sonnet 5 and 5.x models, GPT-6 Astra, Sol, and Luna, and direct Gemini API Gemini 3.8 Flash, 3.6 Flash, and 3.5 Flash-Lite.

The first automatic refresh of a thread that already has a summary also adds up to three topic tags, but only when the thread has never had tags.
Later summaries never change tags, so tags someone removed are not re-added.

## Related Docs

- [Tools Overview](https://docs.mindroom.chat/tools/)
- [Attachments](https://docs.mindroom.chat/attachments/)
- [Per-Agent Tool Configuration](https://docs.mindroom.chat/tools/#per-agent-tool-configuration)

# Matrix messages

`matrix_message` lets an agent send, read, edit, and react to Matrix messages, and ask other agents to respond in a visible conversation.
It acts on the current room and thread unless the call selects another one.
For room details, available agents, members, thread discovery, and room state, use [`matrix_room`](https://docs.mindroom.chat/tools/matrix-and-attachments/#matrix_room).
To get another agent's answer back inside the same tool call, use [`run_subagent`](https://docs.mindroom.chat/tools/agent-orchestration/#agent-delegation) instead.

## Setup

Add the tool to an agent; it has no tool-specific configuration fields.
Enabling `matrix_message` also enables `matrix_room` and [`attachments`](https://docs.mindroom.chat/attachments/#the-attachments-tool).

```yaml
agents:
  assistant:
    tools:
      - matrix_message
```

## Common calls

```python
# Post an update in the current conversation.
matrix_message(message="The checks passed.")

# Ask another agent to respond in the current conversation.
matrix_message(recipient="code", message="Review the failing check.")

# Start a separate conversation; keep the returned thread_id.
matrix_message(recipient="code", message="Investigate the regression.", new_thread=True)

# Read or continue that conversation later.
matrix_message(action="read", thread_id="$returned-root")
matrix_message(recipient="code", message="Also check the retry path.", thread_id="$returned-root")

# Send files in their listed order.
matrix_message(message="Results", attachments=["reports/results.txt", "att_screenshot"])

# Edit a message you sent, or react to a message.
matrix_message(action="edit", event_id="$message", message="Corrected results")
matrix_message(action="react", event_id="$message", message="✅")
```

## Agent conversations

<video controls playsinline preload="metadata" aria-label="One agent rolls back a broken deploy and hands the customer update to another" style="width: 100%">
  <source src="https://github.com/user-attachments/assets/845b92f1-fa94-4aab-af0c-381d3cab5fb1#t=0.1" type="video/mp4" media="(prefers-color-scheme: dark)">
  <source src="https://github.com/user-attachments/assets/b9b9cb8d-e334-4319-8b33-d6a885d11580#t=0.1" type="video/mp4">
</video>

List possible recipients with `matrix_room(action="agents")` and pass a result's `name` as `recipient`; no Matrix mention is needed.
Only the chosen recipient responds, even if the text mentions other agents, and without `recipient` no agent is started.
An unknown or unavailable name returns `Recipient '<name>' is not available in this room. Available recipients: ...`.
Recipients can be agents or teams.
An agent can address itself only when a human requested the current turn; otherwise use `run_subagent` for a fresh self-run.

Sending returns immediately with the delivered event IDs; read the conversation later to see the response.

Thread-mode recipients accept `new_thread=True` or an explicit `thread_id`.
A task sent to a thread-mode recipient on the room timeline starts the response thread at the task message, and the returned `thread_id` identifies it.
Room-mode recipients share the room timeline: omit thread options or pass `thread_id="room"`; asking for a separate thread returns an error before anything is sent.

## Choosing the conversation

Without `room_id`, the call targets the current room; without `thread_id`, `send` and `read` use the current conversation.
Selecting another room never carries over the current thread.
Pass `thread_id="room"` to target the room timeline, or `new_thread=True` on `send` to start a separate thread, even from inside a thread.
`new_thread` and `thread_id` cannot be combined.
Calls to other rooms follow the [room access rules](https://docs.mindroom.chat/tools/matrix-and-attachments/#tool-runtime-context).

A successful send returns `room_id`, `thread_id`, and `event_id`; a null `thread_id` means the room timeline.
Keep the returned `thread_id` to read or continue a new thread, because it differs from `event_id` when a file became the thread root.
A new thread may not appear in `matrix_room(action="threads")` until it has a reply.

## Arguments

| Argument | Type | Default | Meaning |
| --- | --- | --- | --- |
| `action` | `"send" \| "read" \| "edit" \| "react"` | `"send"` | Message operation. |
| `message` | `str \| None` | `None` | Text for send/edit; emoji for react, defaulting to 👍. |
| `recipient` | `str \| None` | `None` | Available agent or team name to request a response from; send only. |
| `room_id` | `str \| None` | `None` | Matrix room ID or configured room name; current room by default. |
| `thread_id` | `str \| None` | `None` | Thread root ID, or `"room"` for the room timeline; current conversation by default. |
| `new_thread` | `bool` | `False` | Start a separate thread; send only, cannot be combined with `thread_id`. |
| `event_id` | `str \| None` | `None` | Target message ID; required for edit and react. |
| `attachments` | `list[str] \| None` | `None` | Ordered attachment IDs or file paths; send only, at most five. |
| `message_extras` | `list[object] \| None` | `None` | [Collapsible sections](#collapsible-sections) for send/edit. |
| `idempotency_key` | `str \| None` | `None` | Nonblank key of at most 256 characters for [safe send retries](#safe-send-retries); text-only sends. |
| `limit` | `int \| None` | `None` | Messages to read, clamped to 1–50; default 20. |

Agents with a workspace also see the standard [`mindroom_output_path`](https://docs.mindroom.chat/tools/execution-and-coding/#workspace-and-tool-output-files) argument, which saves the tool result to a file; it does not change where the message goes.

`send` requires text, attachments, or both.
`edit` requires non-empty text and can only edit messages the agent's own account sent.
`read` returns recent messages with their event IDs, and thread reads also list which messages can be edited.
Room-timeline reads skip encrypted messages the agent cannot decrypt, and `limit` counts fetched events, so edits and unreadable messages can leave fewer visible messages than `limit`.
Each agent, room, and requester may make 12 calls per 30 seconds, and each attached file counts as one more call.

## Attachments

Each `attachments` entry is an [attachment ID](https://docs.mindroom.chat/attachments/#attachment-ids) (`att_*`) or a local file path, and files are sent in the listed order.
Local paths are registered as new attachments and follow the same [path rules and limits as `register_attachment`](https://docs.mindroom.chat/attachments/#registering-local-files).
Use `./att_filename` for a local file whose name starts with `att_`.
Every reference is checked before anything is sent.

With a `recipient`, the files arrive before the task text, and the recipient can open them even in a room-mode conversation.
Turn-scoped media, such as ephemeral screenshots, can only go to a recipient in a thread; sending it to a recipient on the room timeline returns an error suggesting `new_thread=True` with a thread-mode recipient or a registered local file.
Without a `recipient`, text comes before its files.
Files go to the selected thread, and room-mode or `thread_id="room"` sends keep them on the room timeline.
When a thread-mode agent sends outside an existing thread, text and files are grouped in a thread under the text, and several files without text are grouped under the first file.

Results include `attachment_event_ids`, `resolved_attachment_ids`, and `newly_registered_attachment_ids`.
If delivery fails partway, the error result still lists the files that arrived, and when a file fails before a recipient is addressed, the task text is not sent.

## Collapsible sections

`message_extras` adds collapsible sections below the main text of a `send` or `edit`:

```json
{
  "title": "Details",
  "content": "Additional information",
  "content_type": "text/markdown",
  "collapsed": true
}
```

`title` and `content` are required strings.
`content_type` defaults to `text/markdown` and also accepts `text/plain` or `text/html`.
`collapsed` defaults to `true`.
A call accepts up to eight sections, with titles of at most 120 characters and content of at most 16,384 characters per section.
Sections require a non-empty main message.
HTML allows basic formatting only, with no scripts, styles, images, forms, media, SVG, math, or interactive elements; links may use `http`, `https`, and `mailto`.

[Interactive prompts](https://docs.mindroom.chat/interactive/) cannot be sent or edited through this tool and return `Interactive prompts are only supported in normal agent responses.`; put them in the agent's normal reply instead.

## Safe send retries

Pass `idempotency_key` on a text-only `send` so that retrying after a timeout or lost tool result never posts a duplicate.

```python
matrix_message(message="Review this result.", recipient="code", new_thread=True, idempotency_key="review-42")
```

A key is scoped to the requester, the sending agent, and the destination room; using it in another room creates a separate send.
A retry with the same key delivers the first call's message, recipient, and thread target, even if the retry's text, recipient, or current thread differs.
Only `status="ok"` confirms delivery; after any error or lost result, keep the key and retry, because an error does not prove the message was not delivered.
Errors that end in `retry with the same idempotency_key.`, such as the 60-second `Idempotent Matrix send timed out; retry with the same idempotency_key.`, are safe to retry.
Retries recheck authorization for the sender and the original recipient, and they still count toward the rate limit.
There is no automatic retry; the agent decides when to retry.

Keys cannot be used with `read`, `edit`, `react`, or attachments.
They need persistent MindRoom storage and the same Matrix device across retries, so keep the storage across restarts.
A send whose device changed fails with `Idempotent Matrix send belongs to another sender or device; refusing unsafe replay.`, and a runtime without the required storage returns `Idempotent Matrix sends require durable control storage and a known Matrix sender and device.`.

Completed sends are remembered for eight days, after which reusing the key may post a new message.
Unfinished sends never expire, so retrying one later still delivers its original message.
Each requester, agent, and room keeps at most 10,000 remembered sends, 1,024 of them unfinished; beyond that, new keys fail with `Matrix message send capacity is exhausted; retry existing keys or wait for completed receipts to expire.` while existing keys keep working.

# Agent Chat UI Actions

The `chat_ui` toolkit lets an agent show parts of MindRoom Chat to the user: the Computer panel (a live view of the agent's own worker browser), the Canvas panel (an interactive web page the agent writes, which the user can answer), the Members panel, or a Settings section.
Each call posts a notice in the current conversation that MindRoom Chat turns into the requested view.
Success means the request was sent, not that the user saw it.

## Enable the tool

Add `chat_ui` to the agent that should be allowed to request UI actions:

```yaml
agents:
  researcher:
    display_name: Researcher
    role: Research topics with browser and shell tools.
    model: default
    tools:
      - chat_ui
      - browser
      - shell
```

The toolkit is opt-in and works only for a configured agent replying in a Matrix conversation; teams and the router cannot use it.
A subagent running as a different agent cannot use it; the replying agent must make the request.
Canvases need a separate opt-in; see [Turn canvases on](https://docs.mindroom.chat/canvases/#turn-canvases-on).
The agent cannot choose the recipient, room, thread, or a URL: every request goes to the current conversation and is addressed to the user the agent is answering.

## Actions

None of the actions touches the user's own computer or browser:

| Call | What the user sees | What it works on | What comes back to the agent |
| --- | --- | --- | --- |
| `open_panel(panel="computer")` or `show_computer()` | Computer panel | The agent's own worker browser, the one `browser_control` drives with `target="host"`; real websites | Nothing; if the user takes control and hands it back, a message mentioning the agent |
| `show_canvas(title, html=None, path=None, canvas_event_id=None)` | Canvas panel | A web page the agent wrote; it cannot load any website | The user's confirmed answer, as their next message |
| `open_panel(panel="members")` | Members panel | The people and agents in this room | Nothing |
| `open_settings(section="general")` | Settings dialog | The user's Chat settings, opened at one section; nothing is changed | Nothing |

- `open_panel` accepts only `members` (the default) and `computer`; `show_computer()` is the same as `open_panel(panel="computer")`.
- `open_settings` accepts `general` (the default), `account`, `notifications`, `devices`, `emojis-stickers`, `developer`, or `about`.
- `show_canvas` exists only when canvases are turned on for the agent; [Interactive Canvases](https://docs.mindroom.chat/canvases/) covers turning them on, answers, updates, and writing pages.

The Computer, Canvas, and Members panels share one place on the screen, so opening one replaces whichever is open.
Requests made in a thread are posted in that thread; requests made at room level stay at room level.
A sent request returns `UI action request sent.`

## Control the browser, then show it

The [`browser`](https://docs.mindroom.chat/tools/web-scraping-and-browser/#browser) toolkit controls the agent's worker browser when routed to that worker.
Opening the Computer panel does not navigate or take control, so navigate first and then request the panel:

```python
browser_control(action="open", target="host", targetUrl="https://example.org")
chat_ui.open_panel(panel="computer")
```

To control the user's own local browser instead, use the `browser` tool's `desktop` target through the [Matrix Desktop Bridge](https://docs.mindroom.chat/tools/desktop/).
`web_browser_tools` opens a browser on the host operating system and does not show anything in Chat.

The Computer panel needs an enabled [Worker Computer](https://docs.mindroom.chat/tools/worker-computer/), which owns the worker, browser, proxy, and Chat configuration and describes watching, taking control, and handing back.
If the computer is unavailable, Chat shows an unavailable state in the notice.
An automatic request never replaces a session the user is currently controlling; the user can open it from the notice instead.

## What the user sees

MindRoom Chat opens the requested view automatically only when all of these hold:

- The request is addressed to this user and arrives live, not from history.
- The user has that exact room and thread open, and the Chat window is visible and focused.
- The agent's homeserver is listed in `autoOpenFromHomeservers`.

Otherwise the notice stays in the conversation with a button the user can choose later.
Chat acts only on requests from a joined `mindroom_` agent account on the user's own homeserver.
Other Matrix clients show the notice's text, such as "Open Settings (general) in MindRoom Chat."

The Chat deployment controls automatic opening in its runtime `config.json`:

```json
{
  "mindroom": {
    "uiActions": {
      "autoOpenFromHomeservers": ["mindroom.chat"]
    }
  }
}
```

The shipped MindRoom Chat configuration lists `mindroom.chat`.
An absent or empty list keeps every request behind its button.
Entries match the exact server name in the agent's Matrix ID, including any port; URLs, wildcards, and subdomain matching are unsupported.
Operators must reserve and control the agent username namespace on any homeserver they allow, as `mindroom.chat` does.

# Messaging & Social

This page covers the built-in tools that read and send email, post into team chats, send SMS and WhatsApp messages, work with X and Reddit, and manage Zoom meetings.
Use it to pick a tool, configure its credentials, and understand why a tool is unavailable or a send failed.

## Choosing a Tool

| Tool | Use it for | Setup |
| --- | --- | --- |
| [`gmail`](#gmail) | Reading, searching, drafting, sending, and labeling mail in a Gmail mailbox | Google Gmail OAuth |
| [`slack`](#slack) | Slack messages, threaded replies, channel and user lookups, history, search, and files | Bot or user token |
| [`discord`](#discord) | Discord channel messages, channel info and history, and message deletion | Bot token |
| [`telegram`](#telegram) | Text and media sent by a Telegram bot to one fixed chat | Bot token and chat ID |
| [`whatsapp`](#whatsapp) | WhatsApp Business text, template, interactive, and media messages | Access token and phone number ID |
| [`twilio`](#twilio) | SMS sends, call lookups, and recent message listing | Account SID plus auth token or API key |
| [`webex`](#webex) | Webex room messages and room listing | Access token |
| [`resend`](#resend) | Transactional HTML email through Resend | API key and sender |
| [`email`](#email) | Plain-text mail to one fixed recipient through Gmail SMTP | Gmail address and app password |
| [`x`](#x) | X posts, replies, DMs, profile lookups, home timeline, and recent-post search | Bearer token or OAuth user credentials |
| [`reddit`](#reddit) | Reddit user and subreddit reads, plus optional posts and replies | App client ID and secret |
| [`zoom`](#zoom) | Scheduling, listing, inspecting, and deleting Zoom meetings and reading recordings | Server-to-Server OAuth app |

For Amazon SES email, see `aws_ses` in [Automation & Platforms](https://docs.mindroom.chat/tools/automation-and-platforms/).

## Credentials

Set fields of type `password` through the dashboard or credential store; see [Security Restrictions](https://docs.mindroom.chat/tools/#security-restrictions).
Many credential fields below are optional in the dashboard but required in practice, and the tool fails to load or errors on its first call without them.
Instead of stored fields, these tools also read the environment variables `SLACK_TOKEN`, `SLACK_USER_TOKEN`, `DISCORD_BOT_TOKEN`, `TELEGRAM_TOKEN`, `WHATSAPP_ACCESS_TOKEN`, `WHATSAPP_PHONE_NUMBER_ID`, `TWILIO_ACCOUNT_SID`, `TWILIO_AUTH_TOKEN`, `TWILIO_API_KEY`, `TWILIO_API_SECRET`, `WEBEX_ACCESS_TOKEN`, `RESEND_API_KEY`, `X_BEARER_TOKEN`, `X_CONSUMER_KEY`, `X_CONSUMER_SECRET`, `X_ACCESS_TOKEN`, `X_ACCESS_TOKEN_SECRET`, `REDDIT_CLIENT_ID`, `REDDIT_CLIENT_SECRET`, `REDDIT_USERNAME`, `REDDIT_PASSWORD`, `ZOOM_ACCOUNT_ID`, `ZOOM_CLIENT_ID`, and `ZOOM_CLIENT_SECRET`.
`gmail` instead connects through Google OAuth; see [Google Services OAuth For Local Installs](https://docs.mindroom.chat/deployment/google-services-user-oauth/) or [Google Services OAuth](https://docs.mindroom.chat/deployment/google-services-oauth/) for custom and hosted setups.
Most tools enable or disable individual functions with `enable_<function>` flags, and `all: true` enables every function; `include_tools` and `exclude_tools` filter further, as described in [Filtering Toolkit Functions](https://docs.mindroom.chat/tools/#filtering-toolkit-functions).

## [`gmail`]

`gmail` reads, searches, drafts, sends, replies to, stars, and labels mail in the connected Gmail account.
Connect the account through the `google_gmail` OAuth provider; when no usable connection exists, the tool replies with a link to connect.
To use a Google Workspace service account instead, set `GOOGLE_SERVICE_ACCOUNT_FILE` to the key file path and `GOOGLE_DELEGATED_USER` to the mailbox to impersonate through domain-wide delegation.
`gmail` always runs in the primary MindRoom runtime, even for agents whose other tools run in a worker, so workers never receive Google credentials.

Its functions are `apply_label()`, `create_draft_email()`, `delete_custom_label()`, `get_draft()`, `get_emails_by_context()`, `get_emails_by_date()`, `get_emails_by_thread()`, `get_emails_from_user()`, `get_latest_emails()`, `get_message()`, `get_starred_emails()`, `get_thread()`, `get_unread_emails()`, `list_custom_labels()`, `list_drafts()`, `mark_email_as_read()`, `mark_email_as_unread()`, `remove_label()`, `search_emails()`, `search_threads()`, `send_email()`, `send_email_reply()`, `send_email_to_self()`, `star_email()`, `unstar_email()`, and `update_draft()`.
Archiving, trashing, attachment downloads, and sending existing drafts are not available.
`send_email_to_self(subject, body)` always sends to the connected account's own address, so an approval rule can allow it while still requiring approval for `send_email`; see [Tool Approval](https://docs.mindroom.chat/tool-approval/).

Draft and send functions accept attachment file paths, not Matrix attachment IDs.
Allowed paths follow the agent's [`file_access`](https://docs.mindroom.chat/architecture/security-posture/#file-access) setting: with the default `workspace`, attachments must be files inside the agent workspace, and an agent without a workspace cannot attach files.
With `unrestricted`, any file MindRoom can read is allowed.
Relative paths resolve from the workspace root, or from the MindRoom working directory when an `unrestricted` agent has no workspace.
Attachments may total at most 25 MiB per message; larger sets fail with `Gmail attachments exceed the 25 MiB limit`.

Each option below defaults to `true`, and setting it to `false` removes the matching function.

| Option | Type | Default | Controls |
| --- | --- | --- | --- |
| `get_latest_emails` | `boolean` | `true` | `get_latest_emails()` |
| `get_emails_from_user` | `boolean` | `true` | `get_emails_from_user()` |
| `get_unread_emails` | `boolean` | `true` | `get_unread_emails()` |
| `get_starred_emails` | `boolean` | `true` | `get_starred_emails()` |
| `search_emails` | `boolean` | `true` | `search_emails()` |
| `create_draft_email` | `boolean` | `true` | `create_draft_email()` |
| `send_email` | `boolean` | `true` | `send_email()` |
| `send_email_reply` | `boolean` | `true` | `send_email_reply()` |

Use `exclude_tools` to remove other functions, such as `delete_custom_label`.

## [`slack`]

`slack` sends messages and threaded replies, lists channels and users, reads channel history and threads, searches messages, and uploads or downloads files.
Use channel IDs rather than names, especially for threaded replies and history reads.
`get_channel_history()` returns only top-level messages; to read a thread's replies, enable `get_thread()` and pass the parent message's timestamp.
`slack` always runs in the primary runtime, and its file uploads and downloads are not confined by the agent's `file_access`, so enable it only for agents trusted with what the MindRoom process can reach; see [File access](https://docs.mindroom.chat/architecture/security-posture/#file-access).

To search messages, set `enable_search_messages: true` and store a user token with the [`search:read`](https://docs.slack.dev/reference/methods/search.messages/) scope in `user_token`; a bot token alone cannot search.
A user token stored as the primary `token` also works for search.
`search_workspace()` works only when Slack itself invokes the agent and supplies an action token, so it fails in ordinary Matrix conversations; use `search_messages()` there.

| Option | Type | Default | Notes |
| --- | --- | --- | --- |
| `token` | `password` | `null` | Bot or user token, or `SLACK_TOKEN`; required. |
| `user_token` | `password` | `null` | User token for `search_messages()`, or `SLACK_USER_TOKEN`. |
| `markdown` | `boolean` | `true` | Render Slack markdown in sent messages. |
| `output_directory` | `text` | `null` | Directory for saved downloads; setting it also enables saving. |
| `save_downloads` | `boolean` | `false` | Save downloads to disk instead of returning their content; without `output_directory`, files go to the current working directory. |
| `enable_send_message` | `boolean` | `true` | Enable `send_message()`. |
| `enable_send_message_thread` | `boolean` | `true` | Enable `send_message_thread()`. |
| `enable_list_channels` | `boolean` | `true` | Enable `list_channels()`. |
| `enable_get_channel_history` | `boolean` | `true` | Enable `get_channel_history()`. |
| `enable_upload_file` | `boolean` | `true` | Enable `upload_file()`. |
| `enable_download_file` | `boolean` | `true` | Enable `download_file()`. |
| `enable_search_messages` | `boolean` | `false` | Enable `search_messages()`; needs a user token. |
| `enable_search_workspace` | `boolean` | `false` | Enable `search_workspace()`. |
| `enable_get_thread` | `boolean` | `false` | Enable `get_thread()`. |
| `enable_list_users` | `boolean` | `false` | Enable `list_users()`. |
| `enable_get_user_info` | `boolean` | `false` | Enable `get_user_info()`. |
| `enable_get_channel_info` | `boolean` | `false` | Enable `get_channel_info()`. |
| `all` | `boolean` | `false` | Enable every function. |
| `max_file_size` | `number` | `1073741824` | Maximum upload or download size in bytes. |
| `thread_message_limit` | `number` | `20` | Maximum messages returned by `get_thread()`. |

```yaml
agents:
  support:
    tools:
      - slack:
          enable_get_thread: true
          enable_search_messages: true
```

## [`discord`]

`discord` sends messages, reads channel messages and info, lists a server's channels, and deletes messages through the Discord REST API with a bot token.
It does not run a live Discord bot, so it cannot receive messages, handle slash commands, or set presence.
Functions take Discord IDs such as channel, server, and message IDs, not names.
Deletion is enabled by default; set `enable_delete_message: false` for read and send access only.

| Option | Type | Default | Notes |
| --- | --- | --- | --- |
| `bot_token` | `password` | `null` | Discord bot token, or `DISCORD_BOT_TOKEN`; required. |
| `enable_send_message` | `boolean` | `true` | Enable `send_message()`. |
| `enable_get_channel_messages` | `boolean` | `true` | Enable `get_channel_messages()`. |
| `enable_get_channel_info` | `boolean` | `true` | Enable `get_channel_info()`. |
| `enable_list_channels` | `boolean` | `true` | Enable `list_channels()`. |
| `enable_delete_message` | `boolean` | `true` | Enable `delete_message()`. |
| `all` | `boolean` | `false` | Enable every function. |

## [`telegram`]

`telegram` sends messages from a Telegram bot to the one chat or channel set in `chat_id` or `TELEGRAM_CHAT_ID`, so calls never choose a destination.
For several Telegram destinations, configure the tool separately per agent or credential scope.
Only `send_message()` is enabled by default.
Optional functions send photos, documents, video, audio, animations, and stickers, edit, delete, and pin messages, react with emoji, read chat details with `get_chat()`, and download files with `get_file()`.
`get_file()` returns file content unless `save_downloads` or `output_directory` is set, in which case it saves the file and returns its path.

| Option | Type | Required | Default | Notes |
| --- | --- | --- | --- | --- |
| `chat_id` | `text` | yes | `null` | Chat or channel ID every call targets, or `TELEGRAM_CHAT_ID`. |
| `token` | `password` | no | `null` | Bot token, or `TELEGRAM_TOKEN`; required in practice. |
| `output_directory` | `text` | no | `null` | Directory for downloaded files; setting it also enables saving. |
| `save_downloads` | `boolean` | no | `false` | Save downloads to disk; without `output_directory`, files go to the current working directory. |
| `enable_send_message` | `boolean` | no | `true` | Enable `send_message()`. |
| `enable_send_photo` | `boolean` | no | `false` | Enable `send_photo()`. |
| `enable_send_document` | `boolean` | no | `false` | Enable `send_document()`. |
| `enable_send_video` | `boolean` | no | `false` | Enable `send_video()`. |
| `enable_send_audio` | `boolean` | no | `false` | Enable `send_audio()`. |
| `enable_send_animation` | `boolean` | no | `false` | Enable `send_animation()`. |
| `enable_send_sticker` | `boolean` | no | `false` | Enable `send_sticker()`. |
| `enable_edit_message` | `boolean` | no | `false` | Enable `edit_message()`. |
| `enable_delete_message` | `boolean` | no | `false` | Enable `delete_message()`. |
| `enable_react_with_emoji` | `boolean` | no | `false` | Enable `react_with_emoji()`. |
| `enable_pin_message` | `boolean` | no | `false` | Enable `pin_message()`. |
| `enable_get_chat` | `boolean` | no | `false` | Enable `get_chat()`. |
| `enable_get_file` | `boolean` | no | `false` | Enable `get_file()`. |
| `all` | `boolean` | no | `false` | Enable every function. |

```yaml
agents:
  notifier:
    tools:
      - telegram:
          chat_id: "-1001234567890"
          enable_send_document: true
```

## [`whatsapp`]

`whatsapp` sends messages through the WhatsApp Business (Meta Graph) API.
Text and template messages are enabled by default; reply buttons, list messages, images, documents, locations, and reactions are opt-in.
Template sends need a template name approved in WhatsApp Business.
Set `recipient_waid` or `WHATSAPP_RECIPIENT_WAID` to give calls a default recipient; otherwise, every call must name one.

| Option | Type | Default | Notes |
| --- | --- | --- | --- |
| `access_token` | `password` | `null` | Business API access token, or `WHATSAPP_ACCESS_TOKEN`; required. |
| `phone_number_id` | `text` | `null` | Business phone number ID, or `WHATSAPP_PHONE_NUMBER_ID`; required. |
| `version` | `text` | `v22.0` | Graph API version, or `WHATSAPP_VERSION`. |
| `recipient_waid` | `text` | `null` | Default recipient phone number or WhatsApp ID. |
| `timeout` | `number` | `30` | Per-request HTTP timeout in seconds. |
| `enable_send_text_message` | `boolean` | `true` | Enable `send_text_message()`. |
| `enable_send_template_message` | `boolean` | `true` | Enable `send_template_message()`. |
| `enable_send_reply_buttons` | `boolean` | `false` | Enable `send_reply_buttons()`. |
| `enable_send_list_message` | `boolean` | `false` | Enable `send_list_message()`. |
| `enable_send_image` | `boolean` | `false` | Enable `send_image()`. |
| `enable_send_document` | `boolean` | `false` | Enable `send_document()`. |
| `enable_send_location` | `boolean` | `false` | Enable `send_location()`. |
| `enable_send_reaction` | `boolean` | `false` | Enable `send_reaction()`. |
| `all` | `boolean` | `false` | Enable every function. |

```yaml
agents:
  pager:
    tools:
      - whatsapp:
          recipient_waid: "+15551234567"
          enable_send_reply_buttons: true
```

## [`twilio`]

`twilio` sends SMS with `send_sms()`, looks up calls with `get_call_details()`, and lists recent messages with `list_messages()`.
Authenticate with `account_sid` plus either `auth_token` or the `api_key` and `api_secret` pair.
`send_sms()` rejects sender and recipient numbers that are not in E.164 format, such as `+15551234567`, and the sender must be a number on the Twilio account.

| Option | Type | Default | Notes |
| --- | --- | --- | --- |
| `account_sid` | `text` | `null` | Account SID, or `TWILIO_ACCOUNT_SID`; required. |
| `auth_token` | `password` | `null` | Auth token, or `TWILIO_AUTH_TOKEN`. |
| `api_key` | `password` | `null` | API key, or `TWILIO_API_KEY`; used with `api_secret`. |
| `api_secret` | `password` | `null` | API secret, or `TWILIO_API_SECRET`; used with `api_key`. |
| `region` | `text` | `null` | Twilio region, such as `au1`. |
| `edge` | `text` | `null` | Twilio edge location, such as `sydney`. |
| `debug` | `boolean` | `false` | Log Twilio HTTP requests. |
| `enable_send_sms` | `boolean` | `true` | Enable `send_sms()`. |
| `enable_get_call_details` | `boolean` | `true` | Enable `get_call_details()`. |
| `enable_list_messages` | `boolean` | `true` | Enable `list_messages()`. |
| `all` | `boolean` | `false` | Enable every function. |

## [`webex`]

`webex` sends messages to Webex rooms with `send_message()` and lists rooms with `list_rooms()`.
It does not manage meetings.
Send to room IDs, not room titles.

| Option | Type | Default | Notes |
| --- | --- | --- | --- |
| `access_token` | `password` | `null` | Webex access token, or `WEBEX_ACCESS_TOKEN`; required. |
| `enable_send_message` | `boolean` | `true` | Enable `send_message()`. |
| `enable_list_rooms` | `boolean` | `true` | Enable `list_rooms()`. |
| `all` | `boolean` | `false` | Enable every function. |

## [`resend`]

`resend` sends transactional email through Resend with `send_email()`, using the message body as HTML.
Every message comes from `from_email`, which usually must be a sender verified in Resend.
Use `gmail` instead when the agent needs to read or reply within a mailbox.

| Option | Type | Default | Notes |
| --- | --- | --- | --- |
| `api_key` | `password` | `null` | Resend API key, or `RESEND_API_KEY`; required. |
| `from_email` | `text` | `null` | Sender address for every message. |
| `enable_send_email` | `boolean` | `true` | Enable `send_email()`. |
| `all` | `boolean` | `false` | Enable every function. |

## [`email`]

`email` sends plain-text mail with `email_user(subject, body)` to the single address in `receiver_email`; calls cannot choose another recipient.
It always signs in to Gmail SMTP (`smtp.gmail.com:465`), so the sender must be a Gmail or Google Workspace account, usually with an app password, and other SMTP servers cannot be configured.
All four fields are required; a missing one makes the call return an error such as `error: No receiver email provided`.
Use `gmail` for mailbox access or `resend` for HTML transactional mail.

| Option | Type | Default | Notes |
| --- | --- | --- | --- |
| `receiver_email` | `text` | `null` | Recipient of every message. |
| `sender_name` | `text` | `null` | Display name in the `From` header. |
| `sender_email` | `text` | `null` | Gmail address used to sign in and send. |
| `sender_passkey` | `password` | `null` | Gmail app password. |
| `enable_email_user` | `boolean` | `true` | Enable `email_user()`. |
| `all` | `boolean` | `false` | Enable every function. |

```yaml
agents:
  alerts:
    tools:
      - email:
          receiver_email: oncall@example.com
          sender_name: MindRoom Alerts
          sender_email: alerts@gmail.com
```

## [`x`]

`x` creates posts, replies to posts, sends DMs, looks up users, reads the home timeline, and searches recent posts.
A bearer token is enough for search and lookups, but posting, replying, DMs, and the home timeline generally need the four OAuth user credentials.
`search_posts()` accepts a result limit of 10 to 100, and may return fewer matches.

| Option | Type | Default | Notes |
| --- | --- | --- | --- |
| `bearer_token` | `password` | `null` | Bearer token, or `X_BEARER_TOKEN`. |
| `consumer_key` | `password` | `null` | OAuth consumer key, or `X_CONSUMER_KEY`. |
| `consumer_secret` | `password` | `null` | OAuth consumer secret, or `X_CONSUMER_SECRET`. |
| `access_token` | `password` | `null` | OAuth access token, or `X_ACCESS_TOKEN`. |
| `access_token_secret` | `password` | `null` | OAuth access token secret, or `X_ACCESS_TOKEN_SECRET`. |
| `include_post_metrics` | `boolean` | `false` | Add reply, repost, like, and quote counts to search results. |
| `wait_on_rate_limit` | `boolean` | `false` | Wait out rate limits instead of failing. |

## [`reddit`]

`reddit` reads user profiles, top posts, subreddit info and stats, and trending subreddits, and can create posts and reply to posts and comments.
`client_id` and `client_secret` are enough for reads; posting and replying also need `username` and `password`.
A nonempty `allowed_subreddits` list limits posts and replies to those subreddits, named without `r/` and matched case-insensitively; reads are unaffected.

| Option | Type | Default | Notes |
| --- | --- | --- | --- |
| `client_id` | `text` | `null` | Reddit app client ID, or `REDDIT_CLIENT_ID`; required. |
| `client_secret` | `password` | `null` | Reddit app client secret, or `REDDIT_CLIENT_SECRET`; required. |
| `user_agent` | `text` | `null` | Custom user agent, or `REDDIT_USER_AGENT`; defaults to `RedditTools v1.0`. |
| `username` | `text` | `null` | Reddit username for posts and replies. |
| `password` | `password` | `null` | Reddit password for posts and replies. |
| `allowed_subreddits` | `string[]` | `null` | Subreddits permitted for posts and replies; `null` or `[]` allows all. |
| `reddit_instance` | `text` | `null` | Programmatic only: an existing PRAW `Reddit` client, not settable from YAML. |

```yaml
agents:
  community:
    tools:
      - reddit:
          user_agent: MindRoomCommunityBot/1.0
          allowed_subreddits: [matrixdotorg]
```

## [`zoom`]

`zoom` schedules, lists, inspects, and deletes Zoom meetings and reads meeting recordings.
It uses the credentials of a Zoom Server-to-Server OAuth app, so there is no browser connect step.
Meetings are scheduled for the app account's own user with fixed settings: automatic recording is off, and participants cannot join before the host.

| Option | Type | Default | Notes |
| --- | --- | --- | --- |
| `account_id` | `text` | `null` | Account ID of the Server-to-Server OAuth app, or `ZOOM_ACCOUNT_ID`; required. |
| `client_id` | `text` | `null` | Client ID, or `ZOOM_CLIENT_ID`; required. |
| `client_secret` | `password` | `null` | Client secret, or `ZOOM_CLIENT_SECRET`; required. |
| `timeout` | `number` | `30` | Per-request HTTP timeout in seconds. |

## Related Docs

- [Tools Overview](https://docs.mindroom.chat/tools/)
- [Automation & Platforms](https://docs.mindroom.chat/tools/automation-and-platforms/) - `aws_ses` for Amazon SES email.

# Project Management

This page covers the built-in tools for source hosts, issue trackers, wikis, kanban boards, task managers, help centers, and MindRoom's own per-thread work plans.
Use it to choose a tool, connect its account, and understand its limits.

## Tools On This Page

- [`github`] - GitHub repositories, issues, pull requests, files, branches, code search, and review requests.
- [`todo`] - Per-thread MindRoom work plans with priorities, dependencies, assignments, templates, and automatic nudges for idle agents.
- [`bitbucket`] - One Bitbucket repository's details, commits, pull requests, and issues.
- [`atlassian`](https://docs.mindroom.chat/tools/atlassian/) - Jira and Confluence Cloud as each requester through per-user OAuth, documented on its own page.
- [`jira`] - Jira issue lookup, creation, JQL search, comments, and worklogs with one shared account.
- [`linear`] - Linear teams, issues, and issue updates.
- [`clickup`] - ClickUp spaces, lists, and tasks.
- [`confluence`] - Confluence spaces and page lookup, creation, and updates with one shared account.
- [`notion`] - Pages in one Notion database.
- [`trello`] - Trello boards, lists, cards, and card moves.
- [`todoist`] - Todoist tasks and projects.
- [`zendesk`] - Zendesk Help Center article search.

## Setup

`todo` works without credentials.
The dashboard shows every other tool on this page as needing configuration until its credentials are saved, except that `github` is also ready when `GITHUB_ACCESS_TOKEN` is set.
Most tools also read the environment variables named in their sections.
Connect GitHub per requester through OAuth, and use [`atlassian`](https://docs.mindroom.chat/tools/atlassian/) for per-requester Jira and Confluence Cloud access.
The other tools use one shared set of credentials, saved on the dashboard's Tools page or in the credential store rather than as inline YAML.
Missing Python dependencies are installed at first use, as described in [Automatic Dependency Installation](https://docs.mindroom.chat/tools/#automatic-dependency-installation).

## [`github`]

`github` covers repository search and stats, branches, files, issues, labels, pull requests, review requests, and code search.

| Option | Type | Required | Default | Notes |
| --- | --- | --- | --- | --- |
| `access_token` | `password` | `no` | `null` | Explicit GitHub token; takes precedence over OAuth. |
| `base_url` | `url` | `no` | `null` | GitHub Enterprise API root such as `https://github.example.com/api/v3`, not the web UI root. |

```yaml
agents:
  maintainer:
    tools:
      - github:
          base_url: https://github.example.com/api/v3
```

### OAuth Setup

Choose **Connect with GitHub** on the Tools dashboard to connect your own GitHub account through GitHub App user OAuth.
For a self-hosted installation, save the GitHub App client ID and client secret under the `github_oauth_client` credential service and register `/api/oauth/github/callback` on the installation's public URL as the app's callback URL.
MindRoom requests no classic OAuth scopes, so the token has the GitHub App's fine-grained permissions.
Each requester's GitHub connection is their own, regardless of the agent's worker scope, and `github` always runs in the primary MindRoom runtime.
Until a requester connects, `github` calls return an `OAuthConnectionRequired` result containing that requester's connect link.

To use one token for everyone instead, choose **Use access token** and save it, or set `GITHUB_ACCESS_TOKEN` in the runtime environment.
A saved `access_token` wins over `GITHUB_ACCESS_TOKEN`, which wins over a requester's OAuth connection, and blank or whitespace-only values count as unset.

## [`todo`]

`todo` keeps a work plan for the current Matrix room and thread, so separate threads carry independent plans.
It exposes `plan()`, `add_todo()`, `list_todos()`, `update_todo()`, `apply_template()`, and `list_templates()`, and has no configuration fields.
Plans are stored under `mindroom_data/todo/` and survive restarts.

```yaml
agents:
  maintainer:
    tools:
      - todo
```

```python
plan("[high] Inspect issue\nWrite failing test\nImplement fix")
add_todo("Run focused regression test", depends_on="a1b2c3d4", priority="high")
update_todo("a1b2c3d4", status="done")
apply_template("mindroom-dev", {"ISSUE_REF": "ISSUE-123", "REPO": "mindroom"})
```

- `plan()` creates one item per non-empty line, with optional `[low]`, `[medium]`, `[high]`, or `[critical]` prefixes.
- Each item has a priority, a dependency list, a status of `open`, `done`, or `cancelled`, and an optional assigned agent.
- An open item stays blocked until each of its dependencies is `done` or `cancelled`, so cancelling a prerequisite also unblocks the items that depend on it.
- `plan()`, `add_todo()` without an assignee, and template items without an assignee assign the work to the calling agent, so that agent is [auto-poked](#auto-poke) about it.
- `update_todo()` changes the title, priority, status, dependencies, or assignee, and `list_todos(show_all=True)` includes finished items.
- Assigning work to an agent, or changing work already assigned to one, is refused unless the requester may address that agent in this room under its [`access`](https://docs.mindroom.chat/authorization/#responder-access) policy.
- Each item remembers the requester, human or agent, who wrote its title.
- Reassigning an item without rewriting its title also requires that the title's author may address the new agent.

### Templates

`list_templates()` shows the available templates and `apply_template(name, params, dry_run=True)` previews an expansion without writing it.
MindRoom ships a `mindroom-dev` template, which includes a nested `parallel-review-loop` template.
Agents can add their own templates under `todo/templates` in their workspace, and a workspace template hides a built-in template of the same name.
A template file is named `<name>.yaml.j2` and needs a matching `name`, a string `version`, a `description`, and a nonempty `todos` list.
Each entry has a `title` with optional `priority`, `depends_on`, and `assigned_agent`, or a `sub_template` with optional `params` and `depends_on`.
`depends_on` lists one-based entry numbers within the same template.

```yaml
# todo/templates/release.yaml.j2
name: release
version: "1.0"
description: Cut a release.
todos:
  - title: "Update changelog for {{ VERSION }}"
  - title: "Tag {{ VERSION }} on {{ BRANCH | default('main') }}"
    depends_on: [1]
    priority: high
```

Values can use inline Jinja, such as `{{ NAME }}`, `{% if REPO == 'cinny' %}...{% endif %}`, and `{{ BRANCH | default('main') }}`.
Outside Linux, workspace templates may only substitute `{{ NAME }}`.
YAML aliases and collections nested more than 64 levels deep are refused.

Workspace templates have these limits:

| Limit | Value |
| --- | --- |
| Size of one template file | 64 KiB |
| Template text read by one `apply_template` call, sub-templates included | 64 KiB |
| Rendered output of one `apply_template` call | 65,536 characters |
| Rendering time of one `apply_template` call | 5 seconds |
| Todos created by one `apply_template` call | 100 |

### Auto-Poke

MindRoom wakes an idle agent that has open, unblocked work assigned to it, with no plugin or configuration needed.
An agent is poked once its actionable work in a thread has been quiet for the quiet period, and each scan pokes a given agent at most once.
A pending scheduled task for the same thread suppresses the poke.
Changed work is poked again after a cooldown, and unchanged work gets at most three more pokes, one hour apart.
The poke mentions only the assigned agent and quotes todo titles as plain text.

Work written by a human is poked on that human's behalf, so the agent applies its normal reply access and tool authorization to that human.
Work written by an agent, team, or the router is poked as the assigned agent's own turn.
Work is not poked when its author is a human the assigned agent may not currently reply to, or a non-agent account such as a configured bot account.
Items whose author was never recorded are skipped and logged once as `todo_poke_requester_unrecorded` until someone allowed to address the assigned agent updates them.

| Environment variable | Default | Behavior |
| --- | --- | --- |
| `MINDROOM_TODO_POKE_INTERVAL_SECONDS` | `120` | Scan interval in seconds; `0` disables auto-poke, and enabled values must be at least `1`. |
| `MINDROOM_TODO_POKE_QUIET_SECONDS` | `300` | Minimum quiet period in seconds before assigned work is poked. |

## [`bitbucket`]

`bitbucket` works on one configured repository: its details, commits, pull requests, pull request changes, and issues.
`list_repositories()` lists the whole workspace.
It authenticates with HTTP Basic using `username` plus `token`, or `username` plus `password` when no token is set.

| Option | Type | Required | Default | Notes |
| --- | --- | --- | --- | --- |
| `username` | `text` | `yes` | `null` | Atlassian account email for Bitbucket Cloud; the Basic-auth username for other deployments. Also read from `BITBUCKET_USERNAME`. |
| `password` | `password` | `no` | `null` | Basic-auth password for deployments that support it. Also read from `BITBUCKET_PASSWORD`. |
| `token` | `password` | `no` | `null` | Scoped Bitbucket Cloud API token; takes precedence over `password`. Also read from `BITBUCKET_TOKEN`. |
| `workspace` | `text` | `yes` | `null` | Bitbucket workspace name. |
| `repo_slug` | `text` | `yes` | `null` | Repository that every repository-scoped call uses. |
| `server_url` | `url` | `no` | `api.bitbucket.org` | Bitbucket host or full base URL; a bare host gets `https://`. |
| `api_version` | `text` | `no` | `2.0` | REST API version appended to `server_url`. |
| `timeout` | `number` | `no` | `30` | Per-request HTTP timeout in seconds. |

```yaml
agents:
  maintainer:
    tools:
      - bitbucket:
          username: buildbot@example.com
          # Store the scoped API token in the token credential field.
          workspace: mindroom
          repo_slug: docs
```

- For Bitbucket Cloud, [create a scoped API token](https://support.atlassian.com/bitbucket-cloud/docs/create-an-api-token/) and store it in `token`, with your Atlassian account email in `username`.
- Other deployments must accept the credentials through Basic authentication, because the tool has no bearer-token mode.
- `create_repository()` creates the repository at the configured `repo_slug`, so set `repo_slug` to the new repository's slug first.

## [`jira`]

`jira` provides `get_issue()`, `create_issue()`, `search_issues()` with JQL, `add_comment()`, and `add_worklog()` through one shared Jira account.
For per-requester Jira Cloud access, use [`atlassian`](https://docs.mindroom.chat/tools/atlassian/) instead.

| Option | Type | Required | Default | Notes |
| --- | --- | --- | --- | --- |
| `server_url` | `url` | `no` | `null` | Jira base URL such as `https://example.atlassian.net`; required unless `JIRA_SERVER_URL` is set. |
| `username` | `text` | `no` | `null` | Jira username or Atlassian account email. Also read from `JIRA_USERNAME`. |
| `password` | `password` | `no` | `null` | Password for self-hosted Jira. Also read from `JIRA_PASSWORD`. |
| `token` | `password` | `no` | `null` | Jira or Atlassian API token; used instead of `password` when both are set. Also read from `JIRA_TOKEN`. |
| `enable_get_issue` | `boolean` | `no` | `true` | Enable `get_issue()`. |
| `enable_create_issue` | `boolean` | `no` | `true` | Enable `create_issue()`. |
| `enable_search_issues` | `boolean` | `no` | `true` | Enable `search_issues()`. |
| `enable_add_comment` | `boolean` | `no` | `true` | Enable `add_comment()`. |
| `enable_add_worklog` | `boolean` | `no` | `true` | Enable `add_worklog()`. |
| `all` | `boolean` | `no` | `false` | Enable every function regardless of the flags above. |

```yaml
agents:
  delivery:
    tools:
      - jira:
          server_url: https://example.atlassian.net
          username: bot@example.com
          enable_add_worklog: false
```

- For Atlassian Cloud, use `username` plus `token`.
- Without `username` and a `token` or `password`, the tool connects anonymously, which most Jira sites reject.

## [`linear`]

`linear` reads the current user, teams, issues, assigned issues, workflow-state issues, and high-priority issues, and creates and updates issues.

| Option | Type | Required | Default | Notes |
| --- | --- | --- | --- | --- |
| `api_key` | `password` | `no` | `null` | Linear API key; required unless `LINEAR_API_KEY` is set. |

```yaml
agents:
  delivery:
    tools:
      - linear
```

- `get_issue_details()` accepts an issue UUID or a key such as `BLA-123`.
- `create_issue()` needs a `team_id` from `get_teams_details()`; to assign an issue to yourself, pass the ID from `get_user_details()` as `assignee_id`.

## [`clickup`]

`clickup` lists spaces, lists, and tasks, and creates, reads, updates, and deletes tasks.

| Option | Type | Required | Default | Notes |
| --- | --- | --- | --- | --- |
| `api_key` | `password` | `yes` | `null` | ClickUp API key from ClickUp Settings > Apps. Also read from `CLICKUP_API_KEY`. |
| `master_space_id` | `text` | `yes` | `null` | ClickUp team (workspace) ID whose spaces the tool works with. Also read from `MASTER_SPACE_ID`. |
| `timeout` | `number` | `no` | `30` | Per-request HTTP timeout in seconds. |

```yaml
agents:
  delivery:
    tools:
      - clickup:
          master_space_id: "90123456"
```

- Spaces and lists are matched by name, case-insensitively.
- `list_tasks()` returns tasks from every list in a space.
- `create_task()` always creates the task in the first list of the matched space, so check `list_lists()` when placement matters.
- `update_task()` passes any field updates through to the ClickUp API.

## [`confluence`]

`confluence` lists spaces and their pages, and reads, creates, and updates pages through one shared account.
Spaces can be named by title or key.
For per-requester Confluence Cloud access, use [`atlassian`](https://docs.mindroom.chat/tools/atlassian/) instead.

| Option | Type | Required | Default | Notes |
| --- | --- | --- | --- | --- |
| `url` | `url` | `no` | `null` | Confluence base URL; required unless `CONFLUENCE_URL` is set. |
| `username` | `text` | `no` | `null` | Confluence username or Atlassian account email; required unless `CONFLUENCE_USERNAME` is set. |
| `password` | `password` | `no` | `null` | Password for self-hosted Confluence. Also read from `CONFLUENCE_PASSWORD`. |
| `api_key` | `password` | `no` | `null` | Confluence API key; used instead of `password` when both are set. Also read from `CONFLUENCE_API_KEY`. |
| `verify_ssl` | `boolean` | `no` | `true` | Verify TLS certificates; disable only for self-signed internal deployments. |

```yaml
agents:
  docs:
    tools:
      - confluence:
          url: https://example.atlassian.net/wiki
          username: docs@example.com
```

- One of `api_key` or `password` is required; use `api_key` for Atlassian Cloud.
- Page bodies are read and written in Confluence storage format, such as `<p>Initial draft</p>`.

## [`notion`]

`notion` creates pages in one database, appends a paragraph to an existing page, and searches the database by tag.

| Option | Type | Required | Default | Notes |
| --- | --- | --- | --- | --- |
| `api_key` | `password` | `yes` | `null` | Notion internal integration token. Also read from `NOTION_API_KEY`. |
| `database_id` | `text` | `yes` | `null` | Notion database ID. Also read from `NOTION_DATABASE_ID`. |
| `enable_create_page` | `boolean` | `no` | `true` | Enable `create_page()`. |
| `enable_update_page` | `boolean` | `no` | `true` | Enable `update_page()`. |
| `enable_search_pages` | `boolean` | `no` | `true` | Enable `search_pages()`. |
| `all` | `boolean` | `no` | `false` | Enable every function regardless of the flags above. |

```yaml
agents:
  docs:
    tools:
      - notion:
          database_id: 0123456789abcdef0123456789abcdef
          enable_update_page: false
```

- Create the integration at [Notion Developers](https://www.notion.so/my-integrations) and share the database with it, or the tool cannot create or find pages.
- The database must have a title property named `Name` and a select property named `Tag`.
- `create_page()` sets a title, a tag, and one paragraph, and `update_page()` appends a paragraph rather than rewriting the page.

## [`trello`]

`trello` lists, creates, and inspects boards, lists, and cards, and moves cards between lists.

| Option | Type | Required | Default | Notes |
| --- | --- | --- | --- | --- |
| `api_key` | `password` | `no` | `null` | Trello API key. Also read from `TRELLO_API_KEY`. |
| `api_secret` | `password` | `no` | `null` | Trello API secret. Also read from `TRELLO_API_SECRET`. |
| `token` | `password` | `no` | `null` | Trello user token. Also read from `TRELLO_TOKEN`. |

```yaml
agents:
  planner:
    tools:
      - trello
```

- All three credentials are needed; if any is missing, Trello rejects the calls with an authentication error.
- `create_card()` finds the target list by name, case-insensitively, while `move_card()` needs the card ID and destination list ID from `get_cards()` and `get_board_lists()`.
- `list_boards()` accepts the filters `all`, `open`, `closed`, `organization`, `public`, and `starred`.

## [`todoist`]

`todoist` creates, reads, updates, completes, and deletes tasks, and lists active tasks and projects.

| Option | Type | Required | Default | Notes |
| --- | --- | --- | --- | --- |
| `api_token` | `password` | `no` | `null` | Todoist API token; required unless `TODOIST_API_TOKEN` is set. |

```yaml
agents:
  planner:
    tools:
      - todoist
```

- `create_task()` accepts an optional `project_id` from `get_projects()`, a natural-language `due_string` such as `tomorrow`, a `priority`, and `labels`.
- `update_task()` can also change the description, due date or time, due language, assignee, and section.
- `priority` runs from `1` to `4`, where `4` is the highest.
- `close_task()` marks a task complete, while `delete_task()` removes it permanently.

## [`zendesk`]

`zendesk` searches Help Center articles with `search_zendesk()` and returns their body text without titles, URLs, or HTML.
It cannot read or update tickets.

| Option | Type | Required | Default | Notes |
| --- | --- | --- | --- | --- |
| `username` | `text` | `no` | `null` | Zendesk username; required unless `ZENDESK_USERNAME` is set. |
| `password` | `password` | `no` | `null` | Zendesk password; required unless `ZENDESK_PASSWORD` is set. |
| `company_name` | `text` | `no` | `null` | Zendesk subdomain, as in `<company_name>.zendesk.com`, not the display name; required unless `ZENDESK_COMPANY_NAME` is set. |
| `enable_search_zendesk` | `boolean` | `no` | `true` | Enable `search_zendesk()`. |
| `all` | `boolean` | `no` | `false` | Enable every function regardless of the flag above. |
| `timeout` | `number` | `no` | `30` | Per-request HTTP timeout in seconds. |

```yaml
agents:
  support:
    tools:
      - zendesk:
          username: support@example.com
          company_name: acme
```

## Related Docs

- [Tools Overview](https://docs.mindroom.chat/tools/)
- [Per-Agent Tool Configuration](https://docs.mindroom.chat/tools/#per-agent-tool-configuration)
- [Dashboard](https://docs.mindroom.chat/dashboard/)

# Atlassian Cloud

The `atlassian` tool searches, reads, and updates Jira and Confluence Cloud as the person who asked.
Each requester connects their own Atlassian account through OAuth 2.0 (3LO), so agents see and change only what that person can in Atlassian.
Additional Atlassian sites can be added as separate, independently connected tools through a small plugin.
OAuth connection mechanics shared with other providers are on [OAuth Integration Framework](https://docs.mindroom.chat/oauth-framework/).

<video controls playsinline preload="metadata" aria-label="The agent answers a travel policy question from Confluence and Drive, with sources" style="width: 100%">
  <source src="https://github.com/user-attachments/assets/0dee9f09-80e1-4244-bac3-4da13072bab4#t=0.1" type="video/mp4" media="(prefers-color-scheme: dark)">
  <source src="https://github.com/user-attachments/assets/a2f812aa-bd2c-4996-bc13-f41b2f3a8f94#t=0.1" type="video/mp4">
</video>

The separate `jira` and `confluence` tools on [Project Management](https://docs.mindroom.chat/tools/project-management/) use a shared API token or password and also support Jira and Confluence Data Center.
Their function names do not collide with the `atlassian` functions, so an agent can have both.

## What It Does

| Function | Product | Changes data | Purpose |
| --- | --- | --- | --- |
| `jira_search_issues` | Jira | no | Search issues with JQL, with `next_page_token` paging. |
| `jira_get_issue` | Jira | no | Read one issue's summary fields and description, or the requested `fields` (`["*all"]` for every field), with the transitions available from its current status. |
| `jira_create_issue` | Jira | yes | Create an issue from a project key, summary, issue type, optional plain-text description, and optional extra fields. |
| `jira_update_issue` | Jira | yes | Update the summary, description, or other fields of an issue. |
| `jira_add_comment` | Jira | yes | Add a plain-text comment to an issue. |
| `jira_transition_issue` | Jira | yes | Move an issue through its workflow by transition ID, transition name, or target status name. |
| `confluence_search` | Confluence | no | Search with CQL, with `cursor` paging, naming who created, owns, and last edited each result and when it was created. |
| `confluence_get_page` | Confluence | no | Read a page with its storage-format body, space, current version number, and who created, owns, and last edited it. |
| `confluence_list_attachments` | Confluence | no | List a page's attachments with ID, title, media type, size, comment, and version, with `cursor` paging. |
| `confluence_download_attachment` | Confluence | no | Download one page attachment into the conversation, see [Attachments](#attachments). |
| `confluence_create_page` | Confluence | yes | Create and publish a page in a space, optionally under a parent page. |
| `confluence_update_page` | Confluence | yes | Replace a page's title and whole body as a new version. |
| `confluence_add_comment` | Confluence | yes | Add a footer comment to a page. |

Jira descriptions and comments are plain text, with blank lines separating paragraphs.
Confluence bodies use the Confluence storage format, which is XHTML such as `<p>Hello</p>`.
`confluence_update_page` needs the page's current version number plus one, taken from `confluence_get_page`.
`jira_transition_issue` matches an exact transition ID first, then a transition name, then a target status that only one transition leads to, ignoring case for names and statuses.
When several transitions share that name or target status, it returns `transition_ambiguous` with the candidates and changes nothing.
Every result is a JSON object with `status` set to `ok` or `error`, and successful results name the `product` and the `site` that answered.

## Set Up The Atlassian App

An administrator creates one Atlassian OAuth 2.0 (3LO) app for the MindRoom installation.

1. Open the [Atlassian developer console](https://developer.atlassian.com/console/myapps/) and create an **OAuth 2.0 integration**.
2. Under **Authorization**, configure OAuth 2.0 (3LO) with the callback URL `https://mindroom.example.com/api/oauth/atlassian/callback`, replacing the origin with your public MindRoom origin.
3. Under **Permissions**, add the Jira API and the Confluence API, and enable the scopes listed in [Scopes](#scopes).
4. Under **Distribution**, enable sharing so that people other than the app owner can connect.
5. Under **Settings**, copy the client ID and secret.

For a local installation, the callback URL is `http://localhost:8765/api/oauth/atlassian/callback`.
A hosted installation must set `MINDROOM_PUBLIC_URL` or `MINDROOM_BASE_URL`, from which MindRoom derives the callback URL, and the URL registered on the app must match exactly.
MindRoom always uses this callback and ignores any `redirect_uri` stored with the app credentials.

Store the client ID and secret in the `atlassian_oauth_client` credential service through the dashboard credentials page.
For non-interactive deployments, seed it at startup with a [credential seed](https://docs.mindroom.chat/oauth-framework/#credential-seeds):

```json
[
  {
    "service": "atlassian_oauth_client",
    "credentials": {
      "client_id": {"env": "ATLASSIAN_CLIENT_ID"},
      "client_secret": {"env": "ATLASSIAN_CLIENT_SECRET"}
    }
  }
]
```

## Scopes

MindRoom requests only the scopes that the enabled functions call, plus `offline_access` for token refresh.
Scopes are classic wherever the API accepts them; Confluence page and comment writes accept only granular scopes.

| Scope | Type | Used by |
| --- | --- | --- |
| `offline_access` | classic | Token refresh |
| `read:jira-work` | classic | `jira_search_issues`, `jira_get_issue`, `jira_transition_issue` |
| `write:jira-work` | classic | `jira_create_issue`, `jira_update_issue`, `jira_add_comment`, `jira_transition_issue` |
| `search:confluence` | classic | `confluence_search`, `confluence_get_page`, `confluence_list_attachments` |
| `read:confluence-content.all` | classic | `confluence_get_page`, `confluence_list_attachments` |
| `readonly:content.attachment:confluence` | classic | `confluence_download_attachment` |
| `read:space:confluence` | granular | `confluence_create_page`, to resolve a space key |
| `write:page:confluence` | granular | `confluence_create_page`, `confluence_update_page` |
| `write:comment:confluence` | granular | `confluence_add_comment` |

A stored connection that lacks any requested scope counts as disconnected, and the tool asks the requester to connect again.
Adding a product or enabling writes therefore requires each user whose connection lacks the new scopes to reconnect once, while removing a product or disabling writes does not.

## Configure The Default Connection

Add the tool to an agent:

```yaml
agents:
  assistant:
    tools:
      - atlassian:
          site_url: https://example.atlassian.net
```

| Option | Type | Default | Notes |
| --- | --- | --- | --- |
| `site_url` | `url` | `null` | Atlassian Cloud site as its `https://<name>.atlassian.net` URL; any path such as `/wiki` is ignored. |
| `cloud_id` | `text` | `null` | Atlassian cloud ID (a UUID) of the site; when `site_url` is also set, the site must match both. |

Both options are optional and can also be set for every agent through `defaults.tools` or the tool's dashboard settings.
A site URL that is not HTTPS or a cloud ID that is not a UUID keeps the tool from loading and logs a warning.

Which site the tool uses:

- With neither option set, Jira and Confluence calls each use the only site of that product the connected account can reach, so one Jira site and a different Confluence site work without a pin.
- When the account can reach several sites for the product being called, it returns `site_selection_required` with the available sites; set `site_url` or `cloud_id` to choose one.
- When the configured site is not reachable with the connected account, or `site_url` and `cloud_id` name different sites, it returns `site_not_found` with the `available_sites` Atlassian reports and never falls back to another site.

Atlassian reports every site by its atlassian.net URL, so a custom-domain `site_url` never matches; use the site's atlassian.net URL or its `cloud_id` instead.

## Connect An Account

When a requester has not connected Atlassian yet, every function returns `oauth_connection_required: true` with a `connect_url` for that requester.
The requester opens the link, signs in to Atlassian, approves the requested scopes, and retries the request.
Users can also connect and disconnect from the dashboard Integrations page.

Tokens belong to the requester and are never shared with other users or stored globally.
A call without a concrete requester always asks for a connection instead of using another account.
A connection that has not been used for 90 days expires, and the requester then reconnects.

## Add More Connections

Use `AtlassianConnectionConfig` to add another Atlassian site, such as a partner's Confluence, next to the default connection.
Each connection gets its own OAuth provider, stored credentials, site pin, requested scopes, and prefixed function names, and no credential ever crosses connections.

A plugin can use one module for both registrations:

```json
{
  "name": "atlassian-sites",
  "tools_module": "sites.py",
  "oauth_module": "sites.py"
}
```

```python
# sites.py
from mindroom.tool_system.atlassian_connections import (
    AtlassianConnectionConfig,
    atlassian_connection_oauth_provider,
    register_atlassian_connection_tools,
)

CONNECTIONS = (
    AtlassianConnectionConfig(
        name="partner",
        display_name="Partner Confluence",
        site_url="https://acme.atlassian.net",
        cloud_id="00000000-0000-0000-0000-000000000000",
        products=("confluence",),
    ),
)

for connection in CONNECTIONS:
    register_atlassian_connection_tools(connection)


def register_oauth_providers(settings, runtime_paths):
    return [atlassian_connection_oauth_provider(connection) for connection in CONNECTIONS]
```

Enable the plugin and add the connection's tool to agents:

```yaml
plugins:
  - ./plugins/atlassian-sites

agents:
  assistant:
    tools:
      - atlassian
      - partner_atlassian
```

| Field | Default | Notes |
| --- | --- | --- |
| `name` | required | Lowercase letters, digits, and underscores, starting with a letter, at most 16 characters. |
| `display_name` | required | Name shown on connect links and the Integrations page. |
| `site_url` | `null` | The site's `https://<name>.atlassian.net` URL, not a custom domain; only its origin is kept. |
| `cloud_id` | `null` | Cloud ID (a UUID) of the site; when `site_url` is also set, both must name the same site. |
| `products` | `("jira", "confluence")` | Non-empty, duplicate-free products whose functions and scopes this connection enables. |
| `write` | `True` | Set to `False` to register only read functions and request only read scopes. |
| `client_config_service` | `<name>_atlassian_oauth_client` | Credential service holding the connection's app client ID and secret; the name must end in `_oauth_client`. |

Every connection requires `site_url` or `cloud_id`, so a named connection always acts on one pinned site.
The `name` determines every identifier, so keep it stable once users have connected.

| Identifier | Value for `name="partner"` |
| --- | --- |
| Tool and provider ID | `partner_atlassian` |
| Callback URL | `https://mindroom.example.com/api/oauth/partner_atlassian/callback` |
| Token service | `partner_atlassian_oauth` |
| App client service | `partner_atlassian_oauth_client` |
| Function names | `partner_confluence_search`, `partner_confluence_get_page`, and so on |

By default, each connection uses its own Atlassian app, because Atlassian documents one callback URL per app.
Create an app for the connection as in [Set Up The Atlassian App](#set-up-the-atlassian-app), register the connection's callback URL on it, and store its client ID and secret in the connection's app client service.
A connection without its own client credentials stays unconfigured and never falls back to the default app.

To reuse the default app instead, set `client_config_service="atlassian_oauth_client"` and register the connection's callback URL on that app, if the developer console accepts an additional one.
Atlassian records consent per user and app, so with a shared app the default connection can also reach the other site, and an unpinned `atlassian` tool returns `site_selection_required` until it gets a `site_url` or `cloud_id`.
Revoking the shared app in Atlassian disconnects every connection that uses it.

Each connection has its own card on the Integrations page and its own connect link.

## Approvals For Writes

Use [tool approval](https://docs.mindroom.chat/tool-approval/) rules to require a Matrix approval card for the functions that change data while reads run immediately:

```yaml
tool_approval:
  rules:
    - match: "*jira_create_issue"
      action: require_approval
    - match: "*jira_update_issue"
      action: require_approval
    - match: "*jira_add_comment"
      action: require_approval
    - match: "*jira_transition_issue"
      action: require_approval
    - match: "*confluence_create_page"
      action: require_approval
    - match: "*confluence_update_page"
      action: require_approval
    - match: "*confluence_add_comment"
      action: require_approval
```

The leading `*` also matches the prefixed functions of additional connections, such as `partner_confluence_create_page`.
Under a stricter `tool_approval.default: require_approval`, add `auto_approve` rules for `*jira_search_issues`, `*jira_get_issue`, `*confluence_search`, `*confluence_get_page`, `*confluence_list_attachments`, and `*confluence_download_attachment` instead.
To give an agent read access only, hide the write functions with [`exclude_tools`](https://docs.mindroom.chat/tools/#filtering-toolkit-functions), or register a connection with `write=False` so that it never requests write scopes.

## Attachments

`confluence_list_attachments` returns attachment IDs such as `att123456`; when more results exist, it returns `has_more: true` with a `next_cursor` to pass back unchanged.

`confluence_download_attachment` stores the file in MindRoom's [attachment storage](https://docs.mindroom.chat/attachments/) for the current room and thread and returns a context attachment ID such as `att_0123456789abcdef`.
The ID is usable in the same turn, for example with `get_attachment(attachment_id)` to inspect the file, `get_attachment(attachment_id, mindroom_output_path=...)` to save it to the workspace, or `matrix_message` to send it.
In a later turn, download the attachment again.

Downloads are limited to the inline attachment size, 16 MiB by default, which an operator raises with [`MINDROOM_ATTACHMENT_INLINE_SAVE_MAX_BYTES`](https://docs.mindroom.chat/deployment/sandbox-proxy/#environment-variable-reference).
The 64 MiB limit for [registered files](https://docs.mindroom.chat/attachments/) still applies and cannot be raised.

## Limits

- Search and listing functions return at most 50 results per call.
- Each API call other than an attachment download must finish within 60 seconds and return at most 32 MiB; an attachment download must finish within 120 seconds.
- Page reads and attachment listings go through Confluence search, so a page or attachment created moments ago can be missing briefly.
- Archived and draft pages are not returned.
- Only Atlassian Cloud is supported.
- The tool does not upload attachments, delete content, or manage spaces and projects.

## Troubleshooting

| Result | Cause and fix |
| --- | --- |
| `scope_mismatch` | Atlassian rejected a request for a scope the app does not grant; reconnecting grants the same scopes again, so an administrator must add the scopes in [Scopes](#scopes) to the app. |
| `page_not_found` | The page does not exist, is not visible to the account, or is too new for search to have indexed it. |
| `not_a_page` | `confluence_get_page` reads pages only, and the ID belongs to a blog post or other content named in `content_type`. |
| `response_too_large` | The API response exceeded the size limit; narrow the request, for example with fewer fields or a smaller limit. |
| `attachment_too_large` | The attachment exceeds the download limit in [Attachments](#attachments); the message names the limit and the setting that raises it. |
| `attachment_unavailable` | The attachment does not exist or the connected account may not view it. |
| `attachment_context_unavailable` | The call ran outside a conversation with attachment storage. |

Connection and site results are described in [Configure The Default Connection](#configure-the-default-connection) and [Connect An Account](#connect-an-account).
When Atlassian rejects the connection's access token, the result includes a reconnect link.
`permission_denied` means the connected account lacks Atlassian access to that project, space, or item, which reconnecting does not fix.
Other Atlassian errors keep Atlassian's message with URLs removed.

## Security Notes

- The tool runs in the primary MindRoom runtime, so worker containers never receive the app client configuration or user tokens.
- The tool takes no local file paths and writes no workspace files; downloads go only to MindRoom's attachment storage.
- Tokens are sent only to Atlassian's API for the pinned site, never to another host.
- Error results never contain tokens, signed download links, or raw response bodies.

## Related Docs

- [OAuth Integration Framework](https://docs.mindroom.chat/oauth-framework/)
- [Project Management](https://docs.mindroom.chat/tools/project-management/)
- [Plugins](https://docs.mindroom.chat/plugins/)
- [Matrix & Attachments](https://docs.mindroom.chat/tools/matrix-and-attachments/)

# Calendar & Scheduling

Use these tools to give an agent access to Google Calendar, Google Tasks, or Cal.com bookings, or to let it schedule its own future work in MindRoom.

- [`google_calendar`] - Read Google Calendar events and availability, and optionally create, update, or delete events.
- [`google_tasks`] - List, create, update, complete, and delete Google Tasks.
- [`cal_com`] - Query Cal.com availability and manage bookings.
- [`scheduler`](https://docs.mindroom.chat/scheduling/#scheduler) - Schedule, edit, list, and cancel MindRoom tasks and reminders in the current conversation; documented on [Scheduling](https://docs.mindroom.chat/scheduling/).

## Google Setup

`google_calendar` and `google_tasks` use Google OAuth instead of API keys, with separate `google_calendar` and `google_tasks` connections.
Connect Google with [Google Services OAuth For Local Installs](https://docs.mindroom.chat/deployment/google-services-user-oauth/) or, for custom clients and scopes, [Google Services OAuth](https://docs.mindroom.chat/deployment/google-services-oauth/).
An agent's `worker_scope` decides whose Google account it uses; see [Where Connections Are Stored](https://docs.mindroom.chat/oauth-framework/#where-connections-are-stored).
To use a Google Workspace service account instead, see [Service Account](https://docs.mindroom.chat/deployment/google-services-oauth/#service-account).
If an agent calls either tool before the account is connected, the tool returns a connect link instead of a result.

## [`google_calendar`]

<video controls playsinline preload="metadata" aria-label="Two people plan a weekend trip with an agent that checks the calendar and books it" style="width: 100%">
  <source src="https://github.com/user-attachments/assets/4fea9538-5d22-43b4-9147-7e8b496400a8#t=0.1" type="video/mp4" media="(prefers-color-scheme: dark)">
  <source src="https://github.com/user-attachments/assets/ba40bcb7-19aa-4085-9c28-e99f1d171320#t=0.1" type="video/mp4">
</video>

`google_calendar` provides `list_events()`, `get_event()`, `fetch_all_events()`, `find_available_slots()`, `list_calendars()`, `check_availability()`, `get_event_attendees()`, `search_events()`, `create_event()`, `update_event()`, `delete_event()`, `quick_add_event()`, `move_event()`, and `respond_to_event()`.
`find_available_slots()` finds weekday openings around the user's timed events, within working hours inferred from their Google Calendar locale rather than their custom working-hours setting; all-day events do not block slots.
Dates without a time or offset, such as `2026-10-05`, are read as UTC, so the returned slots use UTC hours; convert them to the user's timezone before presenting them.
`list_calendars()` returns the IDs of the other calendars the connected account can use.

| Option | Type | Required | Default | Notes |
| --- | --- | --- | --- | --- |
| `calendar_id` | `text` | `no` | `primary` | Google Calendar ID to query or update. |
| `allow_update` | `boolean` | `no` | `true` | Expose `create_event()`, `update_event()`, `delete_event()`, `quick_add_event()`, `move_event()`, and `respond_to_event()`; set `false` for a read-only agent. |

```yaml
agents:
  assistant:
    tools:
      - google_calendar:
          calendar_id: primary
          allow_update: true
```

```python
list_events(limit=5)
find_available_slots(start_date="2026-04-01", end_date="2026-04-03", duration_minutes=30)
create_event(
    start_date="2026-04-02T15:00:00",
    end_date="2026-04-02T15:30:00",
    timezone="America/Los_Angeles",
    title="Deployment review",
    attendees=["ops@example.com"],
    notify_attendees=True,
    add_google_meet_link=True,
)
```

`create_event()` reads times without an offset in its `timezone` argument, which defaults to UTC rather than MindRoom's configured `timezone`, so pass the user's IANA timezone or include an offset.
`create_event()` emails invitations to attendees only when called with `notify_attendees=True`; by default guests are added without an email.

If the Google Calendar connection lacks any required Calendar permission, the tool stays unavailable until the user reconnects and grants it.

## [`google_tasks`]

`google_tasks` works with the connected user's Google Tasks lists.

| Function | Behavior |
| --- | --- |
| `google_tasks_list_task_lists()` | Returns up to 1,000 task lists with their IDs and titles. |
| `google_tasks_list_tasks()` | Returns up to 100 tasks per page from one list, including subtasks and tasks assigned to the user from Google Docs or Chat. Completed tasks, including those completed in Google's own apps, appear only with `show_completed=True`. |
| `google_tasks_create_task()` | Creates a task with optional notes, due date, and parent task. |
| `google_tasks_update_task()` | Changes only the fields you pass: title, notes, due date, or completion state. Pass an empty string as `notes` or `due` to remove that field. |
| `google_tasks_delete_task()` | Deletes one task. Deleting a task assigned from Google Docs or Chat also deletes the original assignment. |

Both list functions return a `nextPageToken` when more results remain.
Every function uses the user's default list, `@default`, when no `task_list_id` is given.
Due dates use `YYYY-MM-DD`, because Google Tasks stores a due day without a time.

| Option | Type | Required | Default | Notes |
| --- | --- | --- | --- | --- |
| `read_tasks` | `boolean` | `no` | `true` | Expose `google_tasks_list_task_lists()` and `google_tasks_list_tasks()`. |
| `manage_tasks` | `boolean` | `no` | `true` | Expose task creation, updates, completion, and deletion. |

```yaml
agents:
  assistant:
    tools:
      - google_tasks:
          read_tasks: true
          manage_tasks: true
```

```python
google_tasks_list_tasks()
google_tasks_create_task("Renew passport", notes="Bring two photos", due="2026-10-15")
google_tasks_update_task("dGFzay0x", completed=True)
```

## [`cal_com`]

`cal_com` uses the Cal.com v2 API at `https://api.cal.com/v2` to look up availability and manage bookings.
It provides `get_available_slots()`, `create_booking()`, `get_upcoming_bookings()`, `reschedule_booking()`, and `cancel_booking()`.
Slot lookup and new bookings use the configured `event_type_id`.
Returned times are converted from UTC into `user_timezone`.
Use the per-function `enable_*` flags when an agent should, for example, only check availability or only manage existing bookings.

| Option | Type | Required | Default | Notes |
| --- | --- | --- | --- | --- |
| `api_key` | `password` | `no` | `null` | Cal.com API key; set it through the dashboard or credential store, not inline YAML. |
| `event_type_id` | `number` | `no` | `null` | Cal.com event type ID used for slot lookup and new bookings. |
| `user_timezone` | `text` | `no` | `null` | IANA timezone for returned booking times; `America/New_York` when unset. |
| `timeout` | `number` | `no` | `30` | Per-request HTTP timeout in seconds. |
| `enable_get_available_slots` | `boolean` | `no` | `true` | Enable `get_available_slots()`. |
| `enable_create_booking` | `boolean` | `no` | `true` | Enable `create_booking()`. |
| `enable_get_upcoming_bookings` | `boolean` | `no` | `true` | Enable `get_upcoming_bookings()`. |
| `enable_reschedule_booking` | `boolean` | `no` | `true` | Enable `reschedule_booking()`. |
| `enable_cancel_booking` | `boolean` | `no` | `true` | Enable `cancel_booking()`. |
| `all` | `boolean` | `no` | `false` | Enable every Cal.com function regardless of the `enable_*` flags. |

Every function needs `api_key`, and slot lookup and new bookings also need `event_type_id`; supply them as stored credentials or through the `CALCOM_API_KEY` and `CALCOM_EVENT_TYPE_ID` environment variables of the MindRoom process.

```yaml
agents:
  scheduler_assistant:
    tools:
      - cal_com:
          event_type_id: 123456
          user_timezone: America/Los_Angeles
          enable_cancel_booking: false
```

```python
get_available_slots(start_date="2026-04-01", end_date="2026-04-07")
create_booking(
    start_time="2026-04-03T17:00:00+00:00",
    name="Alex Example",
    email="alex@example.com",
)
get_upcoming_bookings(email="alex@example.com")
```

## Related Docs

- [Tools Overview](https://docs.mindroom.chat/tools/)
- [Per-Agent Tool Configuration](https://docs.mindroom.chat/tools/#per-agent-tool-configuration)
- [Scheduling](https://docs.mindroom.chat/scheduling/)

# Memory & Storage

The `mem0` and `zep` tools connect an agent directly to the external Mem0 and Zep Cloud memory services.
They are separate from MindRoom's own memory: enabling them does not change `memory.backend`, automatic memory extraction, or the `memory` tool.
To make MindRoom's built-in memory use Mem0, see [Memory System](https://docs.mindroom.chat/memory/); to let an agent explicitly add, search, or delete MindRoom memories, see the [`memory` tool](https://docs.mindroom.chat/memory/#memory).
For conversation file attachments, see [Attachments](https://docs.mindroom.chat/attachments/).

API keys are `password` fields, so set them in the dashboard or through the environment variables below, never inline in `config.yaml`; see [Security Restrictions](https://docs.mindroom.chat/tools/#security-restrictions).

## [`mem0`]

`mem0` gives the agent `add_memory()`, `search_memory()`, `get_all_memories()`, and `delete_all_memories()` backed by the `mem0ai` client.
With an API key, it uses the hosted Mem0 Platform; without one, it uses Mem0's default setup, which stores memories locally but calls OpenAI for embeddings and memory extraction.
That default setup requires `OPENAI_API_KEY` even when the agent itself uses another provider; without it, the `mem0` tool fails to load.
Local memories live outside MindRoom's storage directory, in `/tmp/qdrant` with history in `~/.mem0/history.db`, so persist and back up those paths separately or the memories can be lost on reboot or container recreation.
A custom local Mem0 config object cannot be set through MindRoom.

Memories are stored per user.
Set `user_id` to share one memory store across all users; without it, each call uses the Matrix user who sent the request, and calls without a resolvable user return an error.

| Option | Type | Default | Notes |
| --- | --- | --- | --- |
| `api_key` | `password` | `null` | Mem0 Platform API key; also read from `MEM0_API_KEY`. |
| `user_id` | `text` | `null` | Fixed user scope for every call. |
| `org_id` | `text` | `null` | Mem0 Platform organization ID; also read from `MEM0_ORG_ID`. |
| `project_id` | `text` | `null` | Mem0 Platform project ID; also read from `MEM0_PROJECT_ID`. |
| `infer` | `boolean` | `true` | Let Mem0 infer facts from added content. |
| `enable_add_memory` | `boolean` | `true` | Enable `add_memory()`. |
| `enable_search_memory` | `boolean` | `true` | Enable `search_memory()`. |
| `enable_get_all_memories` | `boolean` | `true` | Enable `get_all_memories()`. |
| `enable_delete_all_memories` | `boolean` | `true` | Enable `delete_all_memories()`. |
| `all` | `boolean` | `false` | Enable every function regardless of the individual flags. |

```yaml
agents:
  assistant:
    tools:
      - mem0:
          user_id: assistant-memory
          enable_delete_all_memories: false
```

## [`zep`]

`zep` connects the agent to Zep Cloud for conversation memory and user-graph search.
It requires a Zep API key from `api_key` or `ZEP_API_KEY`; without one, the agent runs without the `zep` tool and logs `Could not load tool for agent construction`.

- `add_zep_message(role, content)` adds a message to the Zep thread.
- `get_zep_memory(memory_type="context")` returns the thread's context summary, or the raw message history with `memory_type="messages"`.
- `search_zep_memory(query, search_scope="edges")` searches the user graph and returns facts, or node summaries with `search_scope="nodes"`.

Set `user_id` and `session_id` to keep using the same Zep user and thread across conversations.
Without them, the tool generates a new Zep user and thread, so it does not find earlier memory.
A `user_id` that does not exist yet in Zep is created automatically.

| Option | Type | Default | Notes |
| --- | --- | --- | --- |
| `session_id` | `text` | `null` | Stable Zep thread ID; a new one is generated when unset. |
| `user_id` | `text` | `null` | Stable Zep user ID; a new user is generated and created when unset. |
| `api_key` | `password` | `null` | Zep API key; also read from `ZEP_API_KEY`. |
| `ignore_assistant_messages` | `boolean` | `false` | Exclude assistant-role messages from graph-memory extraction; they are still stored in the Zep thread. |
| `enable_add_zep_message` | `boolean` | `true` | Enable `add_zep_message()`. |
| `enable_get_zep_memory` | `boolean` | `true` | Enable `get_zep_memory()`. |
| `enable_search_zep_memory` | `boolean` | `true` | Enable `search_zep_memory()`. |
| `instructions` | `text` | `null` | Replace the default Zep usage instructions. |
| `add_instructions` | `boolean` | `false` | Add the Zep usage instructions to the agent's prompt. |
| `all` | `boolean` | `false` | Enable every function regardless of the individual flags. |

```yaml
agents:
  assistant:
    tools:
      - zep:
          user_id: assistant-user
          session_id: release-review
```

# Agent Orchestration

These built-in tools let an agent hand tasks to other agents, build and run reusable Dynamic Workflows, publish reports through public links, change MindRoom's configuration from chat, and keep Claude Code sessions alive across turns.

## Tools On This Page

- [`delegate`](#delegate) - Run a configured agent as a fresh subagent and wait for its answer.
- [`dynamic_workflow`](#dynamic_workflow) - Create, update, run, and inspect saved Dynamic Workflows.
- [`report_publishing`](#report_publishing) - Publish workflow reports or workspace static sites through revocable public links.
- [`config_manager`](#config_manager) - Inspect and patch the full configuration, and create, update, or validate agents and teams.
- [`self_config`](#self_config) - Let an agent read and update only its own configuration.
- [`claude_agent`](#claude_agent) - Run persistent Claude Agent SDK coding sessions.

Related tools documented elsewhere:

- [`oauth_connections`](https://docs.mindroom.chat/oauth-framework/#oauth_connections) - Issue a browser-confirmed reset for one OAuth connection.
- [`openclaw_compat`](https://docs.mindroom.chat/openclaw/#openclaw_compat) - Config-only preset that expands to native MindRoom tools.
- [`usage_stats`](https://docs.mindroom.chat/usage/#usage_stats) - Read-only summaries of retained usage.
- [`matrix_message`](https://docs.mindroom.chat/tools/matrix-message/#agent-conversations) - Start and continue visible Matrix conversations with other agents; messages return immediately instead of waiting for an answer.

## [`delegate`]

`delegate` gives an agent `run_subagent` and `continue_subagent`, which run another configured agent in a fresh session and return its answer inside the same tool call.
Use it when the caller needs a specialist's result before continuing; use [`matrix_message`](https://docs.mindroom.chat/tools/matrix-message/#agent-conversations) for a conversation that should be visible in Matrix.

### Agent Delegation

Set `delegate_to` on the calling agent to the agent names it may run as subagents; the dashboard labels this list **Allowed subagents**.
MindRoom adds the `delegate` tool automatically when `delegate_to` is non-empty, so you do not list it in `tools:`.
Every target must be a configured agent, or the config fails to load.
An agent may run a fresh copy of itself only when its own name is in `delegate_to`.
Subagents may delegate further through their own `delegate_to`, up to a maximum depth of 3.

```yaml
agents:
  lead:
    display_name: Lead
    role: Coordinate specialist agents
    model: sonnet
    delegate_to: [lead, code, research]

  code:
    display_name: Code
    role: Implement and debug code changes
    model: sonnet
    tools: [coding, shell]
    delegate_to: [research]  # can delegate further

  research:
    display_name: Research
    role: Gather sources and summarize findings
    model: sonnet
    tools: [duckduckgo]
```

### Running Subagents

```python
run_subagent(task: str, agent_name: str | None = None, model: str | None = None, minimal: bool = False) -> str
continue_subagent(subagent_id: str, message: str) -> str
```

The child starts with no history from the caller's conversation but keeps its own configured tools, workspace, memory, and tool policy.
Include the relevant facts, constraints, and expected output in `task`, because the child cannot see the caller's conversation.
Omitting `agent_name` selects the caller itself, which still requires its own name in `delegate_to`.
Set `model` to an alias from `models:` to override the child's model for that session; it takes precedence over thread and room model choices, and omitting it uses normal model selection.
Set `minimal=True` to run the child in [minimal mode](https://docs.mindroom.chat/tools/agent-cli/#minimal-subagents), which saves tokens when the child does not need its full system prompt.
The caller waits for the child to finish and receives its answer, a stable `Subagent ID`, and an audit reference.
Delegated runs do not create a Matrix thread and cannot ask the user interactive questions.
When a child in a Matrix conversation calls a tool that needs approval, the approval card appears in the source room and thread, and the delegation continues after the decision.
In Matrix conversations, a parent runs its subagents one at a time.
Delegated runs through the OpenAI-compatible API have fewer tools available; see [OpenAI-Compatible API](https://docs.mindroom.chat/openai-api/).
When the caller has a workspace, both calls accept the standard `mindroom_output_path` argument described in [Execution & Coding](https://docs.mindroom.chat/tools/execution-and-coding/#workspace-and-tool-output-files).

Use `continue_subagent` with the returned ID to send a follow-up into the same child session after its previous turn returns.
The ID works across parent turns and restarts, but only for the same caller, requester, and originating conversation.
Follow-ups keep the child's history, model, and mode, and recheck current permissions.
A follow-up sent while the child is still running or awaiting approval is refused; finish that turn first.
If MindRoom restarts during a child turn, that turn is reported as interrupted instead of being rerun, and a pending approval stays pending.

```python
run_subagent(task="Independently review the proposed design and return its three main risks.", model="sonnet")

run_subagent(
    agent_name="research",
    task="Compare SQLite and PostgreSQL for a single-host task queue with 20 concurrent writers. Return three risks and cite sources.",
)

# Copy the Subagent ID from the result.
continue_subagent(
    subagent_id="<returned-subagent-id>",
    message="Now assess how your recommendation changes with multiple hosts.",
)
```

Common errors:

- `Cannot delegate to '<name>'. Allowed subagents: ...` - the target is not in the caller's `delegate_to`.
- `Cannot delegate to '<name>': that agent is not allowed to reply to you.` - the requester is not allowed to use the target agent under its [access settings](https://docs.mindroom.chat/authorization/).
- `Cannot delegate: Unknown model '<model>'. Available models: ...` - `model` is not an alias in `models:`.
- `Cannot delegate: the maximum delegation depth was reached.` - the chain of subagents is already 3 deep.
- `Subagent is busy or awaiting approval. Finish its current turn before sending a follow-up.` - the child's previous turn has not finished.
- `Cannot delegate an empty task. Please provide a task description.` - `task` or `message` is empty.

### Delegation Records

Each child turn writes `run.json`, `events.jsonl`, and `transcript.md` to `.mindroom/delegations/YYYY-MM-DD/<delegation-id>/` in the child's workspace, dated by the delegation's start in UTC.
`run.json` and `events.jsonl` update as the child runs, and `transcript.md` is written when the turn finishes.
The caller receives a receipt at `.mindroom/delegation_receipts/YYYY-MM-DD/<delegation-id>.json` in its own workspace.
Sensitive fields are redacted, and large outputs are stored as referenced artifacts.
Each follow-up turn gets its own record, linked to earlier turns by `subagent_id` and `previous_delegation_id`.
These files are audit exports that MindRoom never reads, so editing or deleting them does not affect the delegation.

## [`dynamic_workflow`]

`dynamic_workflow` lets an agent save a reusable multi-step workflow, publish new revisions, run it, and inspect run records.
Add it to the `tools:` of each agent that should create and run workflows.

```yaml
agents:
  coordinator:
    display_name: Coordinator
    role: Build and run reusable Dynamic Workflows
    model: sonnet
    tools:
      - dynamic_workflow
```

The tool exposes `create_workflow()`, `validate_workflow()`, `update_workflow()`, `run_workflow()`, `get_workflow_run()`, `list_workflows()`, and `list_workflow_revisions()`, each returning JSON with a `status` field.
`validate_workflow()` reports every validation error in a spec without saving it.
Workflows belong to the calling agent; `scope="agent"` is the default and the only scope agent tools can use, and `room` or `tenant` returns an error.
`update_workflow(workflow_id, patch, reason)` merges `patch` into the current spec: nested objects merge, but a list such as `workflow` or `participants` replaces the old list, so pass the complete list when changing one entry.
Each `update_workflow()` publishes a new immutable revision, and each run uses the revision active when it starts.
Saved workflows and run records live under `MINDROOM_STORAGE_PATH/dynamic_workflows/`.
A run completes inside the tool call and writes `report.md`, `report.html`, and `step_outputs.json`, which the run payload lists.
Only the user who requested a run can read its record, publish it, or open its private report.
When `MINDROOM_PUBLIC_URL` is set, run payloads include a private report URL under `/reports/private/...` that opens for that user in the dashboard.
Use [`report_publishing`](#report_publishing) to share a completed run's report through a public link.

### Spec Shape

Workflow specs are JSON or YAML objects with `schema_version: 1` and `kind: workflow`.
The top-level fields are `id`, `name`, `description`, `kind`, `inputs`, `participants`, `workflow`, `outputs`, and `permissions`, and `id`, `name`, `participants`, and `workflow` are required.

- **`inputs`**: An object schema with `required` and `properties`; each property supports `type`, `description`, and `enum`.
- **`participants`**: Up to 8 entries with `kind` set to `ephemeral_agent` (the default) or `room_agent`.
  - An `ephemeral_agent` declares `id`, `name`, `role`, `description`, `model`, `tools`, and `instructions`.
    Its `model`, given as an alias or model ID, defaults to the caller's current model and must be that model; when `permissions.models` is set, it must also list it.
    Its `tools` may include any registered tool except `memory`, `delegate`, `self_config`, `skill_manage`, `compact_context`, `dynamic_workflow`, `dynamic_tools`, and `invite_router`, and each tool must also appear in `permissions.tools`.
    Granted tools run with the caller's credentials, worker routing, and plugin hooks.
  - A `room_agent` declares `id` and `agent` and reuses a configured agent that the requester can already use in the current room.
    It runs with its configured model and without tools, skills, knowledge, durable state, or context files.
- **`workflow`**: Up to 64 steps, each with a unique `id` and a `type`, run one at a time in order.
  - `agent_step` sends its rendered `prompt` to the named `participant`.
  - `transform_step` renders a `template` without calling a model.
  - `report_step` renders Markdown from `body_template` or copies a prior step via `from_step`, with an optional `title`.
  - Templates can reference `{input.<field>}` and earlier steps as `{steps.<step-id>}`.
- **`outputs`**: Entries with `id`, `type`, and `from_step`, where `type` is `text`, `markdown`, `json`, or `html_report`.
- **`permissions`**: Run limits and grants.
  - `max_runtime_seconds` is 1 to 3600 and defaults to 3600; a run that exceeds it fails.
  - `max_total_agents` is 1 to 16, defaults to 16, and caps the number of `agent_step` entries.
  - `max_concurrent_agents` is 1 to 8 and is only validated, because steps never run in parallel.
  - `models` lists the models participants may use, and `tools` lists the tools participants may be granted.
  - `data` must keep `matrix_history: none`, `attachments: none`, and `knowledge_bases: []`, because direct workflow data grants are not supported yet; participants can still reach such data through granted tools such as `matrix_message`.

```python
create_workflow(
    spec={
        "schema_version": 1,
        "id": "brief-report",
        "name": "Brief Report",
        "description": "Create a short HTML report from one topic.",
        "kind": "workflow",
        "inputs": {
            "type": "object",
            "required": ["topic"],
            "properties": {"topic": {"type": "string"}},
        },
        "participants": [
            {
                "id": "writer",
                "kind": "ephemeral_agent",
                "name": "Report Writer",
                "model": "claude-sonnet-5-5",
                "tools": ["duckduckgo", "website"],
            },
        ],
        "workflow": [
            {
                "id": "write",
                "type": "agent_step",
                "participant": "writer",
                "prompt": "Research the web and write a concise cited report about {input.topic}.",
            },
        ],
        "outputs": [{"id": "report_html", "type": "html_report", "from_step": "write"}],
        "permissions": {
            "max_runtime_seconds": 1800,
            "max_concurrent_agents": 4,
            "max_total_agents": 8,
            "models": ["claude-sonnet-5-5"],
            "tools": ["duckduckgo", "website"],
            "data": {"matrix_history": "none", "attachments": "none", "knowledge_bases": []},
        },
    },
    reason="initial report workflow",
)
run_workflow("brief-report", {"topic": "Agno factories"})
list_workflows()
get_workflow_run("brief-report", "run_...")
```

### Allowing participant tools

Workflow participants cannot pause for human approval, so a workflow is rejected when any function of a granted tool would require approval.
Inside a workflow, a function that no approval rule matches requires approval, even when `tool_approval.default` is `auto_approve`.
Set `allowed_tools` on the caller's `dynamic_workflow` entry to auto-approve the functions of listed toolkits for participants, or use `["*"]` for every eligible toolkit.

```yaml
agents:
  builder:
    display_name: Workflow Builder
    tools:
      - dynamic_workflow:
          allowed_tools: [duckduckgo, website]
```

Operator-authored [`tool_approval`](https://docs.mindroom.chat/tool-approval/) rules are checked first and the first match wins.
A matching `auto_approve` rule makes a function usable even outside `allowed_tools`, and a matching `require_approval` or script rule makes it unavailable.
`allowed_tools`, including `"*"`, never auto-approves `claude_agent`, `config_manager`, or `scheduler`, but an explicit operator `auto_approve` rule can.
Functions that ask for their own confirmation stay unavailable even under an operator `auto_approve` rule.
A function name shared by several granted toolkits is auto-approved only when every owning toolkit is eligible.

## [`report_publishing`]

`report_publishing` lets an agent publish a completed Dynamic Workflow report or a workspace static site through a revocable public link.
Add it to the agent's `tools:`; it is often enabled next to `dynamic_workflow`.

```yaml
agents:
  coordinator:
    display_name: Coordinator
    role: Build workflows and publish report links
    model: sonnet
    tools:
      - dynamic_workflow
      - report_publishing
```

The tool exposes `publish_report(source_type, source, confirm_public)` and `revoke_public_report(slug)`, each returning JSON with a `status` field.
`confirm_public=True` is required, so an accidental call publishes nothing.
A successful publish returns a `slug` and the public path `/reports/public/<slug>`, plus an absolute `public_url` when `MINDROOM_PUBLIC_URL` is set.
Anyone with the link can open it without signing in until it is revoked.
Only the user who requested the source run or published the link can revoke it.
The tool never accepts arbitrary filesystem paths; it publishes only these sources, and only when the current requester may read them:

- **`dynamic_workflow_run`**: `source` takes `workflow_id`, `run_id`, and optional `scope` (default `agent`), and the run must be completed.
- **`static_site`**: `source` takes a workspace-relative `path` and a required `title`.
  The path is a directory containing `index.html` plus optional CSS, JavaScript, images, fonts, or JSON, or a single HTML file served as `index.html`.
  The agent needs a workspace, which exists when it uses `memory_backend: file` or a `private:` configuration.
  Publishing copies the site, so later workspace edits need a new `publish_report()` call.
  A site may contain at most 200 files, 200 directories nested at most 32 levels deep, and 10 MiB in total, and it may contain only regular files, not symlinks.
  Static sites are served at `/reports/public/<slug>/` with a trailing slash so relative asset URLs resolve.
  Scripts can make the page interactive but cannot act as the signed-in dashboard user or call MindRoom APIs.
  Scripts, stylesheets, and fonts must be bundled in the site, while images may also load from external HTTPS URLs.
  Pages cannot use `fetch()` or other API connections, even for the site's own JSON files, so embed data in the HTML or a bundled script.

```python
publish_report(
    source_type="dynamic_workflow_run",
    source={"workflow_id": "brief-report", "run_id": "run_..."},
    confirm_public=True,
)
publish_report(
    source_type="static_site",
    source={"path": "public-demo", "title": "Public Demo"},
    confirm_public=True,
)
revoke_public_report("pub_...")
```

Published copies and link records live under `MINDROOM_STORAGE_PATH/report_publishing/`.
Set `MINDROOM_PUBLIC_URL` to the externally reachable dashboard origin, such as `https://mindroom.example.com`, so results include clickable absolute URLs.
If the dashboard frontend and the Python backend sit behind separate upstreams, route `/reports/public/*` to the backend without dashboard-login middleware.

## [`config_manager`]

`config_manager` is the full configuration control plane: it reads and patches any authored config field and creates or updates agents and teams.
It can change every agent and team, so give it only to agents that administrators drive; use [`self_config`](#self_config) for narrow self-tuning.

```yaml
agents:
  builder:
    display_name: Builder
    role: Create and maintain MindRoom agents and teams
    model: sonnet
    tools:
      - config_manager
```

The tool exposes four functions:

- **`get_info(info_type, name=None, agent_scope="current_room")`**: `info_type` is `mindroom_docs`, `config_schema`, `available_models`, `agents`, `teams`, `available_tools`, `tool_details`, `agent_config`, or `agent_template`.
  `agents` lists agents in the current room unless `agent_scope="all"`.
  `tool_details` takes a tool name and shows its config fields and status.
  `agent_config` returns one agent's redacted YAML.
  `agent_template` takes `researcher`, `developer`, `social`, `communicator`, `analyst`, or `productivity` and returns starter YAML.
- **`manage_config(operation, path="", changes=None, dry_run=False)`**: Works on the authored document in `config.yaml`, without unset defaults, using RFC 6901 JSON Pointer paths where `""` is the root.
  `operation="inspect"` returns one subtree as redacted YAML; a subtree too large to return asks for a narrower path.
  `operation="patch"` applies a batch of RFC 6902 `add`, `replace`, and `remove` changes all at once, using `-` as the last token to append to a list.
  `dry_run=True` validates a patch without saving it.
  The receipt lists the changed paths without echoing their values.
  When `config.yaml` uses `!include`, inspection works but patching is refused; edit the source files instead.
- **`manage_agent(operation, agent_name, ...)`**: `operation` is `create`, `update`, or `validate`, and the optional fields are `display_name`, `role`, `tools`, `instructions`, `model`, `rooms`, `knowledge_bases`, `include_default_tools`, `markdown`, `learning`, and `learning_mode`.
  Creating requires `display_name`, takes a lowercase name of letters, digits, and underscores, defaults `model` to `default`, and defaults `include_default_tools` to `true`.
  Tool names and knowledge base IDs must exist.
- **`manage_team(team_name, display_name, role, agents, mode="coordinate")`**: Creates a new team in `coordinate` or `collaborate` mode; it rejects unknown member agents and existing team names, and it cannot update a team.

```python
get_info("tool_details", name="claude_agent")
manage_config(operation="inspect", path="/authorization")
manage_config(
    operation="patch",
    changes=[
        {"op": "replace", "path": "/models/default/id", "value": "claude-sonnet-5-5"},
        {"op": "add", "path": "/agents/triage/instructions/-", "value": "Escalate anything urgent."},
    ],
)
manage_agent(
    operation="create",
    agent_name="triage",
    display_name="Triage",
    role="Sort incoming requests and hand them to the right specialist.",
    tools=["duckduckgo", "matrix_message"],
    model="default",
    rooms=["lobby"],
)
manage_team(
    team_name="incident_team",
    display_name="Incident Team",
    role="Coordinate incident response across ops and code agents.",
    agents=["ops", "code"],
    mode="coordinate",
)
```

### Access, Redaction, and Saving

These rules apply to both `config_manager` and `self_config`.
Reading configuration (`manage_config` inspection, `get_info("agent_config")`, `get_own_config()`) and every write require a requester listed in `administrators`; other requesters get an authorization error.
Redacted output masks every value inside fields the config schema marks secret, such as MCP server `env` and `headers`, plugin `settings`, and model `extra_kwargs`, except `${NAME}` environment references, which are shown as written.
It also masks free-form map entries whose key names look like credentials, and credential patterns such as URL passwords and bearer tokens in any text.
A write whose value contains the `***redacted***` marker or a masked URL password such as `user:***@host` is refused, so copying redacted output back cannot overwrite a hidden real value.
Every write is validated against the full runtime config before `config.yaml` is saved, and an invalid change saves nothing.
Saved changes take effect through the normal config hot reload after the current response.
When a plain list of tool names replaces an agent's tools, inline overrides for tools that stay in the list are kept.

## [`self_config`]

`self_config` lets an agent read and update only its own entry under `agents:`.
Enable it with `allow_self_config: true` on the agent, or for all agents with `defaults.allow_self_config: true`; MindRoom then adds the tool automatically.

```yaml
agents:
  research:
    display_name: Research
    role: Research and summarize external sources
    model: sonnet
    allow_self_config: true
    tools:
      - duckduckgo
      - wikipedia
```

`get_own_config()` returns the agent's redacted YAML.
`update_own_config()` changes only the fields you pass: `display_name`, `role`, `instructions`, `tools`, `model`, `rooms`, `markdown`, `learning`, `learning_mode`, `knowledge_bases`, `skills`, `include_default_tools`, `show_tool_calls`, `thread_mode`, `num_history_runs`, `num_history_messages`, `compress_tool_results`, `max_tool_calls_from_history`, and `context_files`.
Every `update_own_config()` call shows an approval card, even when `tool_approval.default` is `auto_approve`.
An agent cannot give itself `config_manager`, and `include_default_tools=True` is refused when `defaults.tools` contains `config_manager`.
Access, redaction, and saving follow the [shared rules above](#access-redaction-and-saving).

```python
get_own_config()
update_own_config(
    instructions=[
        "Cite sources for factual claims.",
        "Prefer concise summaries with clear takeaways.",
    ],
    tools=["duckduckgo", "wikipedia", "matrix_message"],
    thread_mode="room",
    context_files=["SOUL.md", "USER.md"],
)
```

## [`claude_agent`]

`claude_agent` keeps Claude Agent SDK coding sessions alive across turns, so an agent can hand multi-step coding work to Claude Code and keep talking to the same session.
It runs code in the primary runtime and cannot run in a worker, so enable it only for agents trusted with everything the primary runtime can reach; see [Security Posture](https://docs.mindroom.chat/architecture/security-posture/#file-access).

The tool exposes `claude_start_session()`, `claude_send()`, `claude_session_status()`, `claude_interrupt()`, and `claude_end_session()`.
`claude_send()` creates the session if needed, so `claude_start_session()` is optional.
Each agent gets one session per conversation, and a `session_label` opens additional independent sessions in the same conversation.
Calls to the same session run one after another.
`resume` (a Claude session ID) and `fork_session=True` apply only when a session is created; `fork_session` requires `resume`, and passing either for an existing session returns an error asking for another `session_label` or `claude_end_session()` first.
`claude_session_status()` reports age, idle time, and the Claude session ID once Claude has returned a result.
Errors from the Claude SDK include the last lines of Claude CLI stderr to help debug gateway or CLI problems.
Through the OpenAI-compatible API, keep the same `X-Session-Id` across requests to reuse one Claude session; see [Session continuity](https://docs.mindroom.chat/openai-api/#session-continuity).

| Option | Type | Default | Notes |
| --- | --- | --- | --- |
| `api_key` | password | `null` | Anthropic API key or gateway key; usually set in the dashboard or credentials rather than inline YAML. |
| `anthropic_base_url` | url | `null` | Anthropic-compatible gateway root URL, without a `/v1` suffix, because the Claude client appends its own API path. |
| `anthropic_auth_token` | password | `null` | Bearer token for Anthropic-compatible gateways. |
| `disable_experimental_betas` | boolean | `false` | Sets `CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS=1` for gateways that reject Claude beta headers. |
| `cwd` | text | `null` | Working directory for Claude. |
| `model` | text | `null` | Claude model; defaults to the agent's own model ID. |
| `permission_mode` | text | `default` | `default`, `acceptEdits`, `plan`, or `bypassPermissions`; other values fall back to `default`. |
| `continue_conversation` | boolean | `false` | Continue the same Claude conversation context across queries in one session. |
| `allowed_tools` | text | `null` | Comma-separated Claude Code tool names to allow. |
| `disallowed_tools` | text | `null` | Comma-separated Claude Code tool names to deny. |
| `max_turns` | number | `null` | Maximum Claude turns per query; minimum 1. |
| `system_prompt` | text | `null` | Extra system prompt passed to Claude. |
| `cli_path` | text | `null` | Path to the Claude CLI executable. |
| `session_ttl_minutes` | number | `60` | Idle sessions close after this many minutes; minimum 1. |
| `max_sessions` | number | `200` | Maximum live sessions per agent; at the limit, the least recently used idle session closes. Minimum 1. |

Credentials set in the dashboard and in `mindroom_data/credentials/claude_agent_credentials.json` fill the same fields.

```yaml
agents:
  code:
    display_name: Code Agent
    role: Coding assistant with persistent Claude sessions
    model: default
    tools:
      - claude_agent:
          model: claude-sonnet-5-5
          cwd: /workspace/project
          permission_mode: acceptEdits
          continue_conversation: true
          session_ttl_minutes: 180
          max_sessions: 20
```

Credentials for an Anthropic-compatible gateway such as LiteLLM:

```json
{
  "api_key": "sk-dummy",
  "anthropic_base_url": "http://litellm.local",
  "anthropic_auth_token": "sk-dummy",
  "disable_experimental_betas": true
}
```

```python
claude_send(
    prompt="Refactor the failing test and explain the diff.",
    session_label="bugfix",
)
claude_session_status(session_label="bugfix")
claude_interrupt(session_label="bugfix")
claude_end_session(session_label="bugfix")
```

# Automation & Platforms

Use these tools to invoke AWS Lambda functions, send email through Amazon SES, edit Airflow DAG files, run code in hosted E2B or Daytona sandboxes, call Composio-connected apps, or make HTTP requests to APIs that have no dedicated MindRoom tool.

## Tools On This Page

| Tool | Needs | Best for |
| --- | --- | --- |
| [`aws_lambda`] | AWS credentials | Listing and invoking Lambda functions. |
| [`aws_ses`] | AWS credentials and a verified SES sender | Sending plain-text email. |
| [`airflow`] | Nothing | Reading and writing Airflow DAG source files. |
| [`e2b`] | E2B API key | A hosted code interpreter with file transfer, commands, and temporary public URLs. |
| [`daytona`] | Daytona API key | A remote sandbox that keeps state and a working directory across calls. |
| [`composio`] | Composio API key and connected accounts | Calling selected actions from many apps through one Composio account. |
| [`custom_api`] | Nothing, or the target API's credentials | HTTP requests to public APIs. |

## Setup

Add the tool to an agent's `tools` list; see [Per-Agent Tool Configuration](https://docs.mindroom.chat/tools/#per-agent-tool-configuration) for inline options and `include_tools`/`exclude_tools`.
`password` fields, including `custom_api` `headers` and Daytona `sandbox_env_vars`, cannot be set inline; see [Security Restrictions](https://docs.mindroom.chat/tools/#security-restrictions).
`aws_lambda` and `aws_ses` have no key field and authenticate through the standard AWS credential chain: AWS environment variables, shared config and credentials files, or an instance or pod role.
The sandbox and platform tools read their key from the environment when none is stored:

| Tool | Environment variable |
| --- | --- |
| `e2b` | `E2B_API_KEY` |
| `daytona` | `DAYTONA_API_KEY` (and `DAYTONA_API_URL` for `api_url`) |
| `composio` | `COMPOSIO_API_KEY`, or cached Composio user data |

All these tools run in the primary runtime by default; `e2b`, `daytona`, and `composio` always do, even when listed in `worker_tools` (see [Worker Routing](https://docs.mindroom.chat/deployment/sandbox-proxy/#worker-routed-execution)).
Missing Python dependencies install on first use; see [Automatic Dependency Installation](https://docs.mindroom.chat/tools/#automatic-dependency-installation).
Toolkits with an `all` option enable every function when `all: true`, regardless of the individual `enable_*` flags.

## [`aws_lambda`]

`aws_lambda` provides `list_functions()` and `invoke_function(function_name, payload="{}")` in one AWS region.
`invoke_function()` sends `payload` as a JSON string and returns the Lambda status code and the decoded response payload.
`list_functions()` returns only the first page of results that AWS sends, so it is meant for simple invocation workflows rather than administering large accounts.

| Option | Type | Default | Notes |
| --- | --- | --- | --- |
| `region_name` | `text` | `us-east-1` | AWS region. |
| `enable_list_functions` | `boolean` | `true` | Enable `list_functions()`. |
| `enable_invoke_function` | `boolean` | `true` | Enable `invoke_function()`. |
| `all` | `boolean` | `false` | Enable both functions. |

```yaml
agents:
  automation:
    tools:
      - aws_lambda:
          region_name: us-west-2
```

## [`aws_ses`]

`aws_ses` provides `send_email(subject, body, receiver_email)`, which sends a plain-text email from `sender_name <sender_email>` to one recipient.
Empty subjects and bodies are refused.
It does not support HTML, templates, or attachments.
Verify the sender identity in SES before use, and set both `sender_email` and `sender_name`, because an unset name appears literally as `None` in the sender.

| Option | Type | Default | Notes |
| --- | --- | --- | --- |
| `sender_email` | `text` | `null` | Sender address; required in practice. |
| `sender_name` | `text` | `null` | Sender display name. |
| `region_name` | `text` | `us-east-1` | AWS region of the SES identity. |
| `enable_send_email` | `boolean` | `true` | Enable `send_email()`. |
| `all` | `boolean` | `false` | Enable `send_email()`. |

```yaml
agents:
  notifications:
    tools:
      - aws_ses:
          sender_email: alerts@example.com
          sender_name: MindRoom Alerts
          region_name: us-east-1
```

## [`airflow`]

`airflow` provides `read_dag_file(dag_file)` and `save_dag_file(contents, dag_file)` for editing DAG source files.
It does not talk to the Airflow scheduler or REST API, so it cannot trigger runs or inspect task state.
`dag_file` paths are relative to `dags_dir`, which defaults to the agent workspace and resolves relative to it.
`save_dag_file()` creates missing parent directories, and `read_dag_file()` refuses files larger than 64 MiB.
DAG paths follow the agent's [`file_access`](https://docs.mindroom.chat/architecture/security-posture/#file-access): with the default `workspace`, files outside the agent workspace are refused, so a DAG folder elsewhere needs `file_access: unrestricted`.
Point `dags_dir` at the folder your Airflow deployment actually watches.

| Option | Type | Default | Notes |
| --- | --- | --- | --- |
| `dags_dir` | `text` | `null` | DAG directory; relative values resolve from the agent workspace. |
| `enable_save_dag_file` | `boolean` | `true` | Enable `save_dag_file()`. |
| `enable_read_dag_file` | `boolean` | `true` | Enable `read_dag_file()`. |
| `all` | `boolean` | `false` | Enable both functions. |

```yaml
agents:
  airflow_editor:
    tools:
      - airflow:
          dags_dir: dags
```

## [`e2b`]

`e2b` runs code in a hosted E2B sandbox.
The tool creates one sandbox when it loads and reuses it for its calls.
It fails to load when sandbox creation fails or no key is available, which reports `E2B_API_KEY not set. Please set the E2B_API_KEY environment variable.`
It provides:

- Code and commands: `run_python_code()`, `run_command()`, `stream_command()`, `run_background_command()`, and `kill_background_command()`.
- Results of the most recent `run_python_code()` call: `download_png_result()` and `download_chart_data()`.
- Sandbox files: `upload_file()`, `download_file_from_sandbox()`, `list_files()`, `read_file_content()`, `write_file_content()`, and `watch_directory()`.
- Servers and lifecycle: `run_server()`, `get_public_url()`, `set_sandbox_timeout()`, `get_sandbox_status()`, `shutdown_sandbox()`, and `list_running_sandboxes()`.

`upload_file()` follows the agent's [`file_access`](https://docs.mindroom.chat/architecture/security-posture/#file-access) and refuses local files larger than 64 MiB, so fetch larger data from inside the sandbox instead.
Downloads always land inside the agent workspace at workspace-relative paths; absolute paths, `..`, and links leaving the workspace are refused, and agents without a workspace cannot download.

| Option | Type | Default | Notes |
| --- | --- | --- | --- |
| `api_key` | `password` | `null` | E2B API key. |
| `timeout` | `number` | `300` | Sandbox timeout in seconds. |
| `sandbox_options` | `text` | `null` | Extra E2B sandbox-creation options; the SDK expects a mapping that tool config cannot express, so leave it unset. |

```yaml
agents:
  remote_exec:
    tools:
      - e2b:
          timeout: 600
```

## [`daytona`]

`daytona` runs code and shell commands in a remote Daytona sandbox.
It provides `run_code()`, `run_shell_command()`, `create_file()`, `read_file()`, `list_files()`, `delete_file()`, and `change_directory()`.
With `persistent: true`, the same sandbox is reused across calls in one agent session; set `sandbox_id` to use a known sandbox across sessions.
The working directory persists in the session, so later relative paths resolve from it.
Change it with `change_directory()` or a `run_shell_command()` call that is only `cd <directory>`; a combined command such as `cd project && ls` fails instead.
When no reusable sandbox is found, the tool creates one, even with `auto_create_sandbox: false`.

`verify_ssl: false` disables certificate checks for every Daytona tool in the MindRoom process until it restarts, exposing the API key and sandbox traffic to anyone who can intercept the connection.
Set it only for a self-hosted Daytona API whose certificate you cannot otherwise trust.

| Option | Type | Default | Notes |
| --- | --- | --- | --- |
| `api_key` | `password` | `null` | Daytona API key. |
| `api_url` | `url` | `null` | Daytona API URL. |
| `sandbox_id` | `text` | `null` | Existing sandbox to use instead of a session sandbox. |
| `sandbox_language` | `text` | `python` | `python`, `javascript`, or `typescript`. |
| `sandbox_target` | `text` | `null` | Daytona target. |
| `sandbox_os` | `text` | `null` | Accepted but not applied to created sandboxes. |
| `auto_stop_interval` | `number` | `60` | Minutes of inactivity before a created sandbox stops; `0` disables auto-stop. |
| `sandbox_os_user` | `text` | `null` | OS user in the sandbox. |
| `sandbox_env_vars` | `password` | `null` | JSON object of string environment-variable names and values. |
| `sandbox_labels` | `text` | `{}` | JSON object of string label names and values. |
| `organization_id` | `text` | `null` | Daytona organization ID. |
| `timeout` | `number` | `300` | Timeout in seconds for sandbox operations. |
| `auto_create_sandbox` | `boolean` | `true` | Create a replacement sandbox when finding, creating, or starting one fails. |
| `verify_ssl` | `boolean` | `true` | Verify Daytona TLS certificates; see the warning above. |
| `persistent` | `boolean` | `true` | Reuse one sandbox across calls in the current agent session. |
| `sandbox_public` | `boolean` | `null` | Make created sandboxes public. |
| `instructions` | `text` | `null` | Custom toolkit instructions replacing the bundled write, run, and show-results guidance. |
| `add_instructions` | `boolean` | `false` | Add the toolkit instructions to the agent prompt. |

```yaml
agents:
  remote_dev:
    tools:
      - daytona:
          api_url: https://api.daytona.io
          auto_stop_interval: 30
          add_instructions: true
```

## [`composio`]

`composio` exposes the Composio actions listed in `actions` as agent tools.
Callable names are the lowercase action IDs; for example, `GITHUB_GET_THE_AUTHENTICATED_USER` becomes `github_get_the_authenticated_user`.
Before loading the tool, configure the API key and connect the apps' accounts for the chosen `entity_id` in Composio, because the agent cannot create connections itself.
A missing or empty `actions` list fails with `Composio requires a nonempty actions list.`
`composio` does not accept `include_tools` or `exclude_tools`; choose its functions with `actions`.
It is not confined by `file_access` and cannot run in a worker, so enable it only for agents trusted with the primary runtime (see [File access](https://docs.mindroom.chat/architecture/security-posture/#file-access)).

| Option | Type | Default | Notes |
| --- | --- | --- | --- |
| `actions` | `string[]` | required | Nonempty list of Composio action IDs, such as `GITHUB_GET_THE_AUTHENTICATED_USER`. |
| `api_key` | `password` | `null` | Composio API key. |
| `base_url` | `url` | `null` | Composio API base URL. |
| `entity_id` | `text` | `default` | Composio entity whose connected accounts run the actions. |
| `workspace_id` | `text` | `null` | Composio workspace ID. |
| `workspace_config` | `text` | `null` | Composio workspace configuration object; tool config cannot express it, so leave it unset. |
| `connected_account_ids` | `text` | `null` | Mapping of apps to connected account IDs; tool config cannot express it, so leave it unset. |
| `metadata` | `text` | `null` | Action metadata mapping; tool config cannot express it, so leave it unset. |
| `processors` | `text` | `null` | Request, response, and schema processor mapping; tool config cannot express it, so leave it unset. |
| `output_dir` | `text` | `null` | Directory for file-based results. |
| `output_in_file` | `boolean` | `false` | Write action output to files. |
| `lockfile` | `text` | `null` | Lockfile path for action-version locking. |
| `lock` | `boolean` | `true` | Enable lockfile-based coordination. |
| `max_retries` | `number` | `3` | Retries for failed Composio operations. |
| `verbosity_level` | `number` | `null` | Verbosity from `0` to `3`. |
| `allow_tracing` | `boolean` | `false` | Enable Composio tracing. |
| `logging_level` | `text` | `INFO` | `DEBUG`, `INFO`, `WARNING`, `ERROR`, or `CRITICAL`. |

```yaml
agents:
  integrations:
    tools:
      - composio:
          actions:
            - GITHUB_GET_THE_AUTHENTICATED_USER
```

## [`custom_api`]

`custom_api` provides `make_request(endpoint, method="GET", params=None, data=None, headers=None, json_data=None)` for public HTTP(S) APIs that have no dedicated MindRoom tool.
`method` accepts `GET`, `POST`, `PUT`, `DELETE`, or `PATCH`.
With `base_url` set, `endpoint` is a path joined to it; without `base_url`, `endpoint` must be a full URL.
Localhost, loopback, private-network, cloud metadata, and non-HTTP(S) destinations are refused, including through redirects, and there is no setting to allow them.

Authentication:

- `api_key` sends `Authorization: Bearer <api_key>`.
- A nonempty `username` and `password` pair sends HTTP Basic Auth instead of the bearer token.
- `headers` adds default headers, and per-call headers are merged over them.

Configured credentials (`api_key`, `username`/`password`, or `headers`) are sent only to the `base_url` origin, also across redirects, except a same-host upgrade from `http` to `https`.
A tool with credentials but no `base_url` sends nothing and returns `custom_api sends its configured api_key, username and password, and headers only to base_url; set base_url to call this API with them, or remove them to call arbitrary URLs`.

The result is JSON with `status_code`, response `headers`, and `data`, which holds the parsed JSON body or `{"text": ...}` for other bodies.
Non-2xx responses add `"error": "Request failed"`.
At most 10 redirects are followed.
A body larger than 8 MiB, or one the server compressed despite being asked not to, returns `status_code`, `headers`, and an `error` instead of `data`.

| Option | Type | Default | Notes |
| --- | --- | --- | --- |
| `base_url` | `url` | `null` | Base URL for `endpoint`; required to use `api_key`, `username`/`password`, or `headers`. |
| `username` | `text` | `null` | HTTP Basic Auth username. |
| `password` | `password` | `null` | HTTP Basic Auth password. |
| `api_key` | `password` | `null` | Bearer token. |
| `headers` | `password` | `null` | Default headers as a JSON object of strings, such as `{"X-Api-Key": "value"}`. |
| `verify_ssl` | `boolean` | `true` | Verify TLS certificates. |
| `timeout` | `number` | `30` | Request timeout in seconds. |
| `enable_make_request` | `boolean` | `true` | Enable `make_request()`. |
| `all` | `boolean` | `false` | Enable `make_request()`. |

```yaml
agents:
  api_bridge:
    tools:
      - custom_api:
          base_url: https://api.example.com/v1
          timeout: 20
```

## Related Docs

- [Tools Overview](https://docs.mindroom.chat/tools/)
- [Execution & Coding](https://docs.mindroom.chat/tools/execution-and-coding/)
- [Project Management](https://docs.mindroom.chat/tools/project-management/)
- [MCP](https://docs.mindroom.chat/mcp/)

# Location, Commerce, & Home

These tools let an agent look up places and weather, analyze a Shopify store, and control a Home Assistant smart home.

| Tool | Service | Functions |
| --- | --- | --- |
| [`google_maps`](#google_maps) | Google Maps | Place search, directions, geocoding, address validation, distances, elevation, timezones |
| [`openweather`](#openweather) | OpenWeather | Current weather, forecast, air pollution, location geocoding |
| [`shopify`](#shopify) | Shopify Admin API | Shop info, products, orders, customers, inventory, sales analytics |
| [`homeassistant`](#homeassistant) | Home Assistant | Entity states, device control, scenes, automations, service calls |

## Setup

Every tool on this page stays unavailable until its credentials are configured.
Store password fields such as `key`, `api_key`, and `access_token` through the dashboard or credential store, because they cannot be set inline in YAML (see [Security Restrictions](https://docs.mindroom.chat/tools/#security-restrictions)).
Missing Python dependencies install automatically on first use (see [Automatic Dependency Installation](https://docs.mindroom.chat/tools/#automatic-dependency-installation)).
`homeassistant` is connected through its own dashboard flow instead (see [Connecting Home Assistant](#connecting-home-assistant)).

## `google_maps`

`google_maps` provides `search_places()`, `get_directions()`, `validate_address()`, `geocode_address()`, `reverse_geocode()`, `get_distance_matrix()`, `get_elevation()`, and `get_timezone()`.
`search_places()` returns place details including name, address, rating, reviews, phone number, website, and opening hours.

| Option | Type | Default | Notes |
| --- | --- | --- | --- |
| `key` | `password` | `null` | Google Maps API key; falls back to the `GOOGLE_MAPS_API_KEY` process environment variable. |
| `search_places` | `boolean` | `true` | Enable `search_places()`. |
| `get_directions` | `boolean` | `true` | Enable `get_directions()`. |
| `validate_address` | `boolean` | `true` | Enable `validate_address()`. |
| `geocode_address` | `boolean` | `true` | Enable `geocode_address()`. |
| `reverse_geocode` | `boolean` | `true` | Enable `reverse_geocode()`. |
| `get_distance_matrix` | `boolean` | `true` | Enable `get_distance_matrix()`. |
| `get_elevation` | `boolean` | `true` | Enable `get_elevation()`. |
| `get_timezone` | `boolean` | `true` | Enable `get_timezone()`. |

The toolkit needs Google Application Default Credentials for place search in addition to the API key, so configure both.
`validate_address()` uses Google's Address Validation API, which must be enabled in the same Google Cloud project as the key.

```yaml
agents:
  local_guide:
    tools:
      - google_maps
```

```python
search_places("coffee shops near Pike Place Market")
get_directions("Seattle, WA", "Portland, OR", mode="driving")
reverse_geocode(47.6205, -122.3493)
validate_address("1600 Amphitheatre Pkwy, Mountain View, CA", region_code="US")
```

## `openweather`

`openweather` provides `get_current_weather()`, `get_forecast()`, `get_air_pollution()`, and `geocode_location()`.
`get_forecast()` returns 3-hour entries for up to 5 days.
Weather lookups use the first geocoding match for the location name, so ambiguous names such as "Springfield" can resolve to an unexpected place; add a region or country to the query.

| Option | Type | Default | Notes |
| --- | --- | --- | --- |
| `api_key` | `password` | `null` | OpenWeather API key; falls back to the `OPENWEATHER_API_KEY` process environment variable. A key is required for every call. |
| `units` | `text` | `metric` | `standard`, `metric`, or `imperial`. |
| `enable_current_weather` | `boolean` | `true` | Enable `get_current_weather()`. |
| `enable_forecast` | `boolean` | `true` | Enable `get_forecast()`. |
| `enable_air_pollution` | `boolean` | `true` | Enable `get_air_pollution()`. |
| `enable_geocoding` | `boolean` | `true` | Enable `geocode_location()`. |
| `all` | `boolean` | `false` | Enable all four functions regardless of the `enable_*` options. |
| `timeout` | `number` | `30` | Per-request timeout in seconds. |

```yaml
agents:
  weather:
    tools:
      - openweather:
          units: imperial
          enable_air_pollution: false
```

```python
get_current_weather("San Francisco")
get_forecast("Chicago", days=3)
geocode_location("Reykjavik", limit=3)
```

## `shopify`

`shopify` provides `get_shop_info()`, `get_products()`, `get_orders()`, `get_top_selling_products()`, `get_products_bought_together()`, `get_sales_by_date_range()`, `get_order_analytics()`, `get_product_sales_breakdown()`, `get_customer_order_history()`, `get_inventory_levels()`, `get_low_stock_products()`, `get_sales_trends()`, `get_average_order_value()`, and `get_repeat_customers()`.
List functions return at most 250 products or orders per call.
Analytics such as sales totals, trends, and average order value use at most 250 matching orders per query, so results for a busy store or a long date range can be incomplete.
Date filters such as `created_after` and `created_before` take `YYYY-MM-DD`.

| Option | Type | Default | Notes |
| --- | --- | --- | --- |
| `shop_name` | `text` | `null` | Required. Store subdomain, such as `my-store` from `my-store.myshopify.com`. Falls back to `SHOPIFY_SHOP_NAME`. |
| `access_token` | `password` | `null` | Required. Admin API access token. Falls back to `SHOPIFY_ACCESS_TOKEN`. |
| `api_version` | `text` | `2025-10` | Shopify Admin API version. |
| `timeout` | `number` | `30` | Request timeout in seconds. |

To get an access token:

1. Create and install an app through the [Shopify Dev Dashboard](https://shopify.dev/docs/apps/build/dev-dashboard/create-apps-using-dev-dashboard) with the `read_orders`, `read_products`, `read_customers`, and `read_analytics` scopes.
   Shopify returns only the last 60 days of orders unless the app also has the approved `read_all_orders` scope, so request it for older sales reports.
2. Obtain an Admin API access token through Shopify's authentication flow; for stores in your own organization, use the [client credentials grant](https://shopify.dev/docs/apps/build/authentication-authorization/client-credentials-grant), whose tokens expire after 24 hours.
3. Store the token as `access_token` through the dashboard or credential store.

MindRoom does not issue or refresh Shopify tokens, so replace an expired token yourself.
[Existing legacy custom apps](https://changelog.shopify.com/posts/legacy-custom-apps-can-t-be-created-after-january-1-2026) keep working with their tokens, but new ones cannot be created in Shopify Admin.

```yaml
agents:
  store_analyst:
    tools:
      - shopify:
          shop_name: my-store
          timeout: 45
```

```python
get_products(max_results=25, status="ACTIVE")
get_orders(max_results=50, created_after="2026-03-01", created_before="2026-03-31")
get_low_stock_products(threshold=10)
get_average_order_value(group_by="day", created_after="2026-03-01", created_before="2026-03-31")
```

## `homeassistant`

<video controls playsinline preload="metadata" aria-label="A voice note from a phone locks up, turns off the lights, and lowers the heating" style="width: 100%">
  <source src="https://github.com/user-attachments/assets/ae8bf099-0f11-4097-8de1-de17efbb7bc8#t=0.1" type="video/mp4" media="(prefers-color-scheme: dark)">
  <source src="https://github.com/user-attachments/assets/01e6b9c7-db21-49e9-897c-d03ced90ee14#t=0.1" type="video/mp4">
</video>

`homeassistant` provides `get_entity_state()`, `list_entities()`, `turn_on()`, `turn_off()`, `toggle()`, `set_brightness()`, `set_color()`, `set_temperature()`, `activate_scene()`, `trigger_automation()`, and `call_service()`.
`list_entities()` returns at most 50 entities, so pass a domain such as `light` or `sensor` to narrow it.
`set_brightness()` takes `0` to `255`, `set_color()` takes RGB channels in the same range, and `call_service()` takes extra service data as a JSON string.
`set_temperature()` uses the temperature unit configured in Home Assistant.

`homeassistant` requires `worker_scope` unset or `shared` (see [Shared-only integrations](https://docs.mindroom.chat/deployment/sandbox-proxy/#shared-only-integrations)).

```yaml
agents:
  home:
    tools:
      - homeassistant
```

```python
list_entities("light")
get_entity_state("climate.thermostat")
set_brightness("light.living_room", 128)
activate_scene("scene.movie_time")
call_service("notify", "send_message", data='{"message": "Dinner is ready"}')
```

### Connecting Home Assistant

Connect Home Assistant in the dashboard's **Tools** tab, using either method:

- **OAuth**: enter the Home Assistant URL and, as the Client ID, the MindRoom dashboard URL, such as `https://mindroom.example.com`; Home Assistant needs no application registration, but the Client ID must use the same host and port as the dashboard you connect from.
- **Access token**: in your Home Assistant profile, create a long-lived access token under **Long-Lived Access Tokens**, then enter the Home Assistant URL and the token.

Expired OAuth access tokens renew automatically.
A Home Assistant URL on a private, local, or loopback network, such as `http://homeassistant.local:8123`, is rejected unless you enable **Allow private or local URL**; enable it only for a trusted self-hosted instance.

| Error | Fix |
| --- | --- |
| `Home Assistant is not configured. Please connect through the dashboard.` | Connect Home Assistant as above. |
| `Invalid authentication token. Please reconnect Home Assistant.` | The token was revoked or OAuth renewal failed; reconnect. |
| `Private Home Assistant URLs require explicit opt-in. ...` | Enable **Allow private or local URL** and reconnect. |
| `Connection timeout - check if Home Assistant is accessible` | Make sure the MindRoom server can reach the Home Assistant URL. |

## Related Docs

- [Tools Overview](https://docs.mindroom.chat/tools/)
- [Per-Agent Tool Configuration](https://docs.mindroom.chat/tools/#per-agent-tool-configuration)

# Authorization

This page explains who may talk to agents, teams, and the router, who may administer MindRoom and manage credentials, and who may open the dashboard.
Use it to give people access to an agent, to work out why an agent ignored someone, or to map bridged identities to one person.

<video controls playsinline preload="metadata" aria-label="New colleagues are invited to a room, and the agent answers everyone in it" style="width: 100%">
  <source src="https://github.com/user-attachments/assets/a98cf35e-9c3d-40b8-9827-7f5d2a4dbc28#t=0.1" type="video/mp4" media="(prefers-color-scheme: dark)">
  <source src="https://github.com/user-attachments/assets/6ac07018-9961-465a-b38d-a3a34eba75b6#t=0.1" type="video/mp4">
</video>

See also [Rooms & Spaces](https://docs.mindroom.chat/rooms/), [Routing & Responder Selection](https://docs.mindroom.chat/configuration/router/), and [Threads, Replies & Participation](https://docs.mindroom.chat/configuration/threads/).

## Authority at a glance

Each kind of authority has its own setting, and no setting grants another kind.

| Question | Decided by |
| --- | --- |
| Who may make the router, an agent, or a team join a room? | That entity's `accept_invites` ([agents](https://docs.mindroom.chat/configuration/agents/), [teams](https://docs.mindroom.chat/configuration/teams/), [router](https://docs.mindroom.chat/configuration/router/)) |
| Who receives automatic invitations and Matrix room admin power? | `room_defaults` and `rooms.<key>.invite_users` / `admins` ([Room policy](https://docs.mindroom.chat/rooms/#room-policy)) |
| Who may converse with a responder? | That responder's [`access`](#responder-access) |
| Who may run administrative commands and manage any agent's credentials? | `administrators` |
| Who may manage one agent's shared credentials and OAuth connections? | `administrators` and `agents.<name>.credential_managers` |
| Who may open and change the dashboard? | [Deployment authentication](#dashboard-configuration) |
| May a tool action run? | Tool availability plus any [tool approval](https://docs.mindroom.chat/tool-approval/) rule |

Joining a room grants no conversation access, and conversation access grants no credential management.
Administrators are not invited automatically and get no Matrix room power.
Room admins are not platform administrators or credential managers.
Credential managers get no conversation access.

## Configuration

```yaml
administrators:
  - "@owner:example.com"

rooms:
  engineering:
    display_name: Engineering
    invite_users:
      - "@engineer:example.com"

agents:
  code:
    display_name: Code
    rooms: [engineering]
    accept_invites:
      - "@owner:example.com"
    access:
      current_room_members: false
      members_of_rooms: [engineering]
      users:
        - "@contractor:example.com"
    credential_managers:
      - "@credential-owner:example.com"

router:
  access:
    current_room_members: true

authorization:
  config_command_enabled: false
  aliases:
    "@owner:example.com":
      - "@telegram_owner:example.com"
```

| Field | Type | Default | Description |
| --- | --- | --- | --- |
| `administrators` | list of Matrix user IDs | `[]` | Platform administrators; concrete IDs only, no wildcards |
| `agents.<name>.credential_managers` | list of Matrix user IDs | `[]` | Users who may manage that agent's shared credentials and OAuth connections; concrete IDs only, no wildcards |
| `<responder>.access` | object or null | `null` | Conversation access for an agent, team, or the router; see [Responder access](#responder-access) |
| `authorization.config_command_enabled` | bool | `false` | Enables [`!config`](https://docs.mindroom.chat/chat-commands/#config) for administrators |
| `authorization.aliases` | map of Matrix user ID to list of IDs | `{}` | Bridge identities treated as one person; see [Bridge aliases](#bridge-aliases) |
| `bot_accounts` | list of Matrix user IDs | `[]` | Non-MindRoom bots; see [Bot accounts](#bot-accounts) |

## Responder access

`access` on an agent, team, or the router has three clauses, and a requester is allowed when any one matches.

- `current_room_members` (bool): allow joined members of the room the message is in.
- `members_of_rooms` (list of managed room keys): allow joined members of any listed managed room.
- `users` (list): allow these Matrix user IDs or glob patterns.

Defaults:

- For agents and teams, omitting `access` or its `members_of_rooms` grants members of the responder's own managed `rooms`.
- An explicit `members_of_rooms: []` turns that inferred grant off.
- Only managed room keys grant membership; raw Matrix room IDs or aliases in `rooms` grant nothing, and `members_of_rooms` entries that are not managed room keys fail validation.
- The router defaults to `current_room_members: true`.

Platform administrators and MindRoom's own internal identities pass every responder's `access` check.
[Bridge aliases](#bridge-aliases) are resolved before matching.
The same check covers every way of reaching a responder: messages, media, calls, reactions, approvals, external triggers, background scripts, delegation, attachments, and scheduled resumes.

A team's `access` covers requests to the team as a whole, so someone the team admits reaches every member agent through the team, even members whose own `access` would not admit them directly.

### Room membership grants

Only joined members count: a pending invitation does not, and leaving, being kicked, or being banned removes the grant.
When MindRoom cannot confirm membership of a referenced room, the grant does not apply.
The router tracks membership, so it must be joined to a room before `current_room_members` can allow anyone there.
In an ad-hoc room an agent joined first, have the agent use its `invite_router` tool (see [When the Router Is Missing](https://docs.mindroom.chat/tool-approval/#when-the-router-is-missing)) and retry after the router joins.

### Invitations

`accept_invites` decides only whether an entity joins a room it is invited to.
Accepting an invitation never grants permission to interact; every later message still goes through `access`.

### Agents mentioning other agents

When an agent or team reply, including a message an agent sends with `matrix_api`, mentions another agent or team, the mentioned entity acts for the person or configured bot account who requested the reply.
It responds only when its own `access` admits that requester, and it uses the requester's credentials, private instances, memory, learning, and approvals.
A mention wakes the mentioned entity only after the reply finishes (not when it was stopped or failed) and only while the requester is a joined member of the room.
Agents stop waking each other once a conversation has [`defaults.max_consecutive_agent_replies`](https://docs.mindroom.chat/configuration/agents/#defaults) consecutive agent or team messages since a person last wrote there, and resume after the next message from a person.
In a room-level conversation, the count covers the room's messages outside threads.
Commands in an agent's reply are limited as described in [Command Handling](https://docs.mindroom.chat/chat-commands/#command-handling), and a scheduled task's text never runs as a command.

## Requester identity and private state

MindRoom resolves [bridge aliases](#bridge-aliases) first, so a person reaching an agent through any configured alias gets the same conversations, private state, requester-scoped credentials, approvals, triggers, scripts, and usage.

An agent's `private` setting (see [Private Instances](https://docs.mindroom.chat/configuration/agents/#private-instances)) only decides whose state an interaction uses.
It does not grant access; MindRoom checks the agent's `access` before picking a requester's private instance.

## Bridge aliases

`authorization.aliases` maps bridge-created Matrix IDs to one canonical Matrix user before access, administrator, and credential checks.

```yaml
authorization:
  aliases:
    "@alice:example.com":
      - "@telegram_123:example.com"
      - "@signal_456:example.com"
```

Aliases apply only to people; managed agents, teams, the router, `bot_accounts`, and MindRoom's internal account are never remapped.
Each alias may appear only once, and a canonical ID cannot also be another user's alias.

Upgrading from the retired access fields is covered in [Membership Access Migration](https://docs.mindroom.chat/deployment/upgrades/#membership-access-migration).

## Bot accounts

`bot_accounts` lists non-MindRoom bots, such as bridge bots, that MindRoom treats like agents rather than people when deciding whether to respond, so they do not trigger [multi-human thread protection](https://docs.mindroom.chat/configuration/threads/#bot-accounts).
They still need `access` to talk to a responder.

```yaml
bot_accounts:
  - "@telegram_bot:example.com"
  - "@slack_bot:example.com"
```

## Dashboard configuration

Dashboard access follows deployment authentication, not the Matrix `administrators` list.

- Standalone: `MINDROOM_API_KEY` protects the dashboard and its API; without it, access is unauthenticated, so set it before exposing an instance beyond a trusted local network.
- Hosted (Supabase): the user's platform sign-in is validated, and the instance owner account is enforced when configured.
- [Trusted upstream auth](https://docs.mindroom.chat/deployment/trusted-upstream-auth/): every gateway-authenticated user may change configuration unless Connections is enabled, which adds an `administrators` check; see its [Security Boundary](https://docs.mindroom.chat/deployment/trusted-upstream-auth/#security-boundary).

Standalone deployments should set `MINDROOM_OWNER_USER_ID` so dashboard requests made with the API key act as the owner's Matrix identity for credential management.
Browser changes made with a dashboard sign-in or trusted upstream identity must come from the dashboard's own origin; see [Browser Mutation Protection](https://docs.mindroom.chat/deployment/trusted-upstream-auth/#browser-mutation-protection).

### Unauthenticated dashboard host names

Without a credential, the dashboard and the `/v1` API answer only requests addressed to `localhost`, a `*.localhost` name, an IP address, the hosts of `MINDROOM_PUBLIC_URL`, `MINDROOM_BASE_URL`, `MINDROOM_URL`, or `MINDROOM_SCRIPT_GATEWAY_URL`, or a host listed in `MINDROOM_DASHBOARD_ALLOWED_HOSTS`.
This blocks DNS-rebinding attacks from other web pages.
Any other name, such as `myserver.local`, fails with `Host '<host>' is not allowed without a credential; add it to MINDROOM_DASHBOARD_ALLOWED_HOSTS or configure authentication`.
Browser pages may call it only when served from the same host, from a `localhost` name or loopback address, or from a host set through the URL settings above or `MINDROOM_DASHBOARD_ALLOWED_HOSTS`.
Pages from any other origin fail with `Origin '<origin>' is not allowed without a credential`, and changes requested from another site fail with `Cross-site browser requests are not allowed without a credential`.

A reverse proxy in front of an unauthenticated dashboard or `/v1` API must pass the browser's `Host` header through unchanged, for example nginx `proxy_set_header Host $host;` or Apache `ProxyPreserveHost On`.
A proxy that rewrites `Host` to its upstream address, as nginx `proxy_pass` and Apache `mod_proxy` do by default, makes every request look addressed to an IP address and disables this protection; in that case set `MINDROOM_API_KEY`, and `OPENAI_COMPAT_API_KEYS` for `/v1`.

These host checks do not apply to requests authenticated by an API key, a platform session, or trusted upstream auth, or to routes with their own authorization, such as health probes, webhooks, computer sessions, and `/v1` with `OPENAI_COMPAT_API_KEYS`.

## Platform and credential authority

Platform administrators may:

- Run `!reload-plugins`, and `!config` when `authorization.config_command_enabled` is `true` (see [Chat Commands](https://docs.mindroom.chat/chat-commands/#config)).
- Manage any agent's credentials and the deployment-wide OAuth client configuration.
- Talk to every responder regardless of its `access`.

A credential manager may manage only the named agent's shared credentials and OAuth connections.
Requesters manage their own personal OAuth connections without being listed anywhere; see [Who Can Connect An Account](https://docs.mindroom.chat/oauth-framework/#who-can-connect-an-account).
Reading or changing an agent's shared credentials returns HTTP 403 to anyone who is neither an administrator nor that agent's credential manager.
Connections may show users with agent access whether a shared connection exists, without revealing the connected account or granting management.

## Tool approval and resource ownership

Conversation access lets a requester ask a responder to act, but the responder must still have the tool, and any required approval must still succeed.
An approval belongs to the person who started the action, and MindRoom rechecks that person's current access when it is approved.

Schedules belong to their room, while external triggers, background scripts, private workers, and requester-scoped credentials belong to the requester.
Attachments are scoped to their room and thread, so an authorized response may read attachments other participants uploaded in that conversation.
None of these ownership rules grants responder access.

# Tool Approval

<video controls playsinline preload="metadata" aria-label="An agent pauses for approval before it books a meeting" style="width: 100%">
  <source src="https://github.com/user-attachments/assets/6a2033ea-3354-4afd-9d58-fc617b18cd24#t=0.1" type="video/mp4" media="(prefers-color-scheme: dark)">
  <source src="https://github.com/user-attachments/assets/d62d98e8-c066-4e1f-8a8f-d840da7b0bd1#t=0.1" type="video/mp4">
</video>

Use the top-level `tool_approval` block to make chosen tool calls wait for a person to approve them in the Matrix conversation.
This example gates Slack message sending and file uploads, plus shell calls selected by a review script:

```yaml
tool_approval:
  default: auto_approve
  timeout_days: 7
  rules:
    - match: send_message*
      action: require_approval
    - match: upload_file
      action: require_approval
    - match: run_shell_command
      script: ./approval_scripts/shell_review.py
      timeout_days: 3
```

| Field | Type | Default | Meaning |
| --- | --- | --- | --- |
| `default` | `auto_approve` or `require_approval` | `auto_approve` | Decision for calls that no rule matches |
| `timeout_days` | number, greater than 0 and at most 36500 | `7` | How long an approval card stays open before it expires |
| `rules` | list | `[]` | Ordered rules; the first matching rule wins |
| `rules[].match` | string | Required | Case-sensitive glob over exposed function names, not toolkit identifiers, so the same function name in another toolkit also matches |
| `rules[].action` | `auto_approve` or `require_approval` | — | Fixed decision; set exactly one of `action` or `script` |
| `rules[].script` | string | — | Config-relative Python file defining `check(tool_name, arguments, agent_name) -> bool`, which may be `async`; approval is required only when it returns `True` |
| `rules[].timeout_days` | number, greater than 0 and at most 36500 | `tool_approval.timeout_days` | Expiry window for this rule |

## Approving and Denying

- React to the approval card with `✅` to approve the tool call.
- Reply to the card to deny the call; the start of the reply is recorded as the denial reason.
- Only the original human requester can approve or deny their pending call.
  When an agent acts on a request relayed by another agent's reply, the original human is asked.
- Tool calls requested by agents, the system, or configured bridge bots are denied instead of waiting for approval.
- An unanswered card expires after its `timeout_days` and the call is denied.

While approval is pending, the agent stops typing and the conversation can continue.
Pending approvals survive restarts and config reloads, and an approved call resumes the paused response.
The approved call runs only with the exact arguments shown on the card.

### Auto-Approval for a Few Minutes

Eligible cards also offer auto-approval for 5, 10, or 30 minutes.
It covers the original call, matching pending calls, and later calls for the same room, thread, requester, agent, and exact tool operation, with any arguments.
It is offered only in a thread and when the card shows the complete arguments, and never for tools that request confirmation themselves or for background-script approvals.
The requester can stop auto-approval from the originating card, and any config change or room membership change ends matching grants; calls already approved still run.

## Approval Card Contents

Cards show a redacted preview of the tool arguments, and clients can expand the complete redacted arguments when the preview is truncated.
Fields whose names mark them as secret, such as `password`, `credentials`, or an `Authorization` header, are hidden while their field name stays visible.
Inside text, only credentials in known token formats, such as `sk-…`, `ghp_…`, `github_pat_…`, `xoxb-…`, `AIza…`, and JSON Web Tokens, are replaced by numbered placeholders like `⟦secret-1⟧`; equal tokens get the same number.
Other inline secrets, such as `export DB_PASSWORD=hunter2` or an inline `Authorization: Basic …` header, stay visible, so keep secrets in credentials or secret-named fields.
Deny the call when a hidden field or a placeholder where a command, host, path, or delimiter belongs could change what runs.

A card cannot be approved when its arguments exceed 2 MB, nest deeper than 32 levels, or fail to upload; approving it denies the call with `Cannot approve: the tool arguments cannot be shown in full`.
Have the agent retry with a smaller payload, such as saving large content to a workspace file or sending it as an attachment, or auto-approve the tool with a script rule.

## Tools That Cannot Pause for Approval

The [OpenAI-compatible API](https://docs.mindroom.chat/openai-api/) has no approval cards, so it hides every tool function that matches a required-approval rule, including script rules.
Skill, knowledge-search, and learning functions such as `get_skill_script`, `search_knowledge_base`, and `update_user_memory` cannot pause for approval on any channel, so they are hidden when a required-approval rule or `default: require_approval` applies to them.
Add an `auto_approve` rule for such a function to keep it available.

## When the Router Is Missing

Approval cards need the router in the room, so a gated call in a room without the router fails with `Tool approval needs the router in this room.`
Every agent in a room has a built-in `invite_router` tool that invites the router into the current room, waits briefly for it to join, and reports when the invite is still pending so the agent retries after the router joins.
The router accepts that invite only when its [`router.accept_invites`](https://docs.mindroom.chat/configuration/router/) allows the inviting agent's Matrix account; for a team, that is the team's account rather than the member agent's.
Otherwise enable that setting or add the router to the room manually, then retry.

# OAuth Integration Framework

This page covers how MindRoom stores credentials and how users connect OAuth accounts for tools and MCP servers.
Use it to import or seed credentials, encrypt the credential store, connect or reset an OAuth account, decide who may manage a connection, and build OAuth providers in plugins.

## Credential Storage

### Automatic Credential Import

When MindRoom starts or `mindroom doctor` runs, it copies these variables from the process environment or the config-adjacent `.env` into the shared credentials store: `ANTHROPIC_API_KEY`, `AZURE_OPENAI_API_KEY`, `OPENAI_API_KEY`, `GOOGLE_API_KEY`, `OPENROUTER_API_KEY`, `DEEPSEEK_API_KEY`, `CEREBRAS_API_KEY`, `GROQ_API_KEY`, `ZAI_API_KEY`, `OLLAMA_HOST`, `GITHUB_TOKEN` (stored as `github_private`), `EMBEDDER_API_KEY` (stored as `embedder`), and `GOOGLE_APPLICATION_CREDENTIALS` (stored as `google_vertex_adc`).
Every one of these except `GOOGLE_APPLICATION_CREDENTIALS` also accepts a `_FILE` variant, read only when the plain variable is unset or empty.
Services declared through [Credential Seeds](#credential-seeds) are imported the same way.
Import never overwrites credentials saved through the dashboard or credentials with no recorded source.
When an imported value is first stored or changes, MindRoom logs a notice naming the service, the supplying variable, and whether it came from the process environment or `.env`; values are never logged.
To stop an import, remove the variable and any `_FILE` variant from both the process environment and `.env`, then delete the stored credential in the dashboard or with `DELETE /api/credentials/{service}`.

### Credential Seeds

Credential seeds create shared credential services at startup for deployment-managed credentials, such as an OAuth app's client ID and secret.
Set `MINDROOM_CREDENTIAL_SEEDS_FILE` to a JSON file path (relative paths resolve from the config directory), or `MINDROOM_CREDENTIAL_SEEDS_JSON` to the same JSON inline.

```json
[
  {
    "service": "example_oauth_client",
    "credentials": {
      "client_id": {"env": "EXAMPLE_CLIENT_ID"},
      "client_secret": {"env": "EXAMPLE_CLIENT_SECRET"}
    }
  }
]
```

- Each credential field is `{"env": "NAME"}`, `{"file": "path"}` (relative paths resolve from the config directory), `{"value": ...}`, or a plain literal.
- If an env ref such as `EXAMPLE_CLIENT_SECRET` is unset, MindRoom also checks `EXAMPLE_CLIENT_SECRET_FILE` and reads that file.
- If any declared field is missing or empty, MindRoom skips that seed instead of creating a partial credential.
- Env and file refs always produce strings, so a tool's boolean field accepts the exact strings `true` and `false` as well as JSON booleans, and any other value makes that tool fail to load.
- Seeds are reapplied on every startup but never replace credentials saved through the dashboard or with no recorded source.
- OAuth token services, whose names end with `_oauth`, cannot be seeded; connect them through OAuth instead.
  OAuth client configuration services, whose names end with `_oauth_client`, can be seeded.
- To stop seeding one service, remove its entry from each declaration that lists it and delete the stored credential with `DELETE /api/credentials/{service}`.

### Credential Storage Encryption

Set `MINDROOM_CREDENTIALS_ENCRYPTION_KEY` to encrypt stored credential files at rest.
The key must be a base64-encoded 32-byte value and must stay the same across restarts.
Generate one with:

```bash
python - <<'PY'
import base64
import os

print(base64.urlsafe_b64encode(os.urandom(32)).decode("ascii"))
PY
```

Once the key is set, existing plaintext credential files become unreadable and cannot be overwritten, so back them up and recreate them under encryption, or remove the key to read them again.
OAuth connections made before the key was set must be reconnected.
Encrypted credentials become readable again when their correct key is restored.
Dedicated Docker and Kubernetes workers never receive the key; tool settings reach them through [credential leases](https://docs.mindroom.chat/deployment/sandbox-proxy/#credential-leases).

## OAuth Framework

MindRoom runs the whole OAuth flow for OAuth-backed tools and MCP servers: connect links, the callback, token storage and refresh, status, disconnect, and reset.
Each connection is stored for one credential scope, so the right person's account is used for each agent.
Providers, built in or from plugins, supply only provider-specific details such as endpoints, scopes, and client configuration.

<video controls playsinline preload="metadata" aria-label="A user connects an account from the conversation, then the agent answers from it" style="width: 100%">
  <source src="https://github.com/user-attachments/assets/9c301417-f0ee-43d1-ac42-8c974d42888f#t=0.1" type="video/mp4" media="(prefers-color-scheme: dark)">
  <source src="https://github.com/user-attachments/assets/4689c253-d026-4dee-afb8-7f178ea793df#t=0.1" type="video/mp4">
</video>

### Connect An Account

Users connect an account from the dashboard **Tools** tab, the [Connections portal](https://docs.mindroom.chat/deployment/trusted-upstream-auth/#connections-portal), or a link an agent posts in the conversation (see [MindRoom-Managed OAuth Onboarding In Conversation](#mindroom-managed-oauth-onboarding-in-conversation)).
The API routes are:

| Method | Route | Purpose |
| --- | --- | --- |
| `POST` | `/api/oauth/{provider}/connect` | Return an authorization URL for the dashboard |
| `GET` | `/api/oauth/{provider}/authorize` | Browser-openable link that redirects to the provider; used by conversation links |
| `GET` | `/api/oauth/{provider}/callback` | Provider callback |
| `GET` | `/api/oauth/{provider}/success` | Page shown after a successful connection |
| `GET` | `/api/oauth/{provider}/status` | Connection status |
| `POST` | `/api/oauth/{provider}/disconnect` | Remove the stored token; the tool's editable settings stay |
| `GET`, `POST` | `/api/oauth/{provider}/reset` | Browser-confirmed reset (see [`oauth_connections`](#oauth_connections)) |

Add `agent_name` to target a connection for one agent's credential scope.
The dashboard connects to the agent's saved scope, so save a `worker_scope` change before connecting.

The provider callback URL is `<MINDROOM_PUBLIC_URL or MINDROOM_BASE_URL>/api/oauth/<provider>/callback`, or `http://localhost:<MINDROOM_PORT>/api/oauth/<provider>/callback` (port `8765` by default) when neither is set.
Register that URL on the provider's OAuth app, or store an explicit `redirect_uri` in the provider-specific client config service.

Conversation connect links for a scoped connection are valid for 10 minutes, work once, and are tied to the requester, provider, and agent scope that produced them.
For a personal (`user` or `user_agent`) connection, the browser must be signed in to MindRoom as that same requester; for a shared connection, the link itself is enough and no dashboard login is needed.
MindRoom rechecks the requester's authority when the link is used and rejects the link if the connection changed after it was issued.
An expired, reused, unauthorized, or outdated link saves no credentials; rerun the original request for a fresh link.
For an unscoped connection, or a tool call without a concrete requester, the link is reusable and requires dashboard login instead.
Standalone installs identify the dashboard user through `MINDROOM_OWNER_USER_ID` (see [Platform and credential authority](https://docs.mindroom.chat/authorization/#platform-and-credential-authority)), and hosted multi-user deployments through [trusted upstream auth](https://docs.mindroom.chat/deployment/trusted-upstream-auth/).

### Who Can Connect An Account

| Connection | Who may connect, check, or disconnect it |
| --- | --- |
| Personal (`user` or `user_agent` scope, or a provider that always stores per requester) on a non-private agent | The requester, when they have room-independent access to the agent or are a platform `administrator` or credential manager for it |
| Isolated connection on a requester-private agent | That requester |
| Shared or unscoped connection | A platform `administrator` or a user listed in `agents.<name>.credential_managers` |

Room-independent access comes from `access.users`, administrator authority, or a `members_of_rooms` grant; `access.current_room_members` and team access do not apply, because a browser request has no current room or team.
Managing a personal connection grants no general dashboard access and no authority over shared credentials.
With the Connections portal enabled, eligible users manage their connections there, and ordinary dashboard routes require administrator authority.
Unauthorized connect, authorize, status, disconnect, and callback requests return HTTP 403 before any credential is exposed or changed.
See [Authorization](https://docs.mindroom.chat/authorization/#platform-and-credential-authority) for administrators and credential managers.

### Where Connections Are Stored

Providers that store credentials per requester, such as GitHub and Atlassian, always save the token in the requester's `user` scope and never fall back to a shared or global token.
For other providers, the token follows the agent's effective execution scope: `private.per`, then `agents.<name>.worker_scope`, then `defaults.worker_scope`.

| Scope | Whose connection is used |
| --- | --- |
| `shared` | One connection for the agent, used by every authorized caller |
| `user` | The requester's connection, reused across their `user`-scoped agents |
| `user_agent` | One connection per requester and agent |
| _(unset)_ | One installation-level connection in the global credential store |

Private agents derive the scope from `private.per` and cannot also set `worker_scope`.
OAuth tokens and OAuth client configuration stay in the primary runtime and are never mirrored into worker containers, and GitHub and Google OAuth tools always run in the primary runtime.

### Built-In Providers

| Provider | Setup |
| --- | --- |
| GitHub (GitHub App user tokens) | [GitHub OAuth Setup](https://docs.mindroom.chat/tools/project-management/#oauth-setup) |
| Atlassian Cloud (Jira and Confluence) | [Atlassian Cloud](https://docs.mindroom.chat/tools/atlassian/) |
| Google Drive, Docs, Calendar, Sheets, Tasks, and Gmail | [Google Services OAuth](https://docs.mindroom.chat/deployment/google-services-oauth/) |
| Remote MCP servers with `auth.type: oauth` | [OAuth-Backed Remote MCP](https://docs.mindroom.chat/mcp/#oauth-backed-remote-mcp) |

A dynamically registered MCP client works from a hosted address only when `MINDROOM_PUBLIC_URL` or `MINDROOM_BASE_URL` is an HTTPS URL without query or fragment on a public DNS hostname, that hostname matches the address the browser used to open MindRoom, and the authorization server confirmed that callback when registering.
Otherwise the client works only when MindRoom is opened on `localhost`, and other addresses get `The provisioned OAuth client is available only when MindRoom is opened on localhost. Set MINDROOM_PUBLIC_URL (or MINDROOM_BASE_URL) and configure a custom OAuth client for remote access.`

### Provider And Credential Service Rules

These rules apply to built-in providers and to providers that plugins register (see [OAuth providers](https://docs.mindroom.chat/plugins/#oauth-providers) for the plugin module and examples).

- A provider's token `credential_service` must end with `_oauth`.
  Only the OAuth callback writes it, and the generic credentials API neither returns OAuth token fields nor accepts direct writes to these services.
- An optional `tool_config_service` holds the tool's editable dashboard settings separately from the token.
- `tool_config_oauth_fallback_fields` (tuple of strings, default `()`) names token-like fields, such as `access_token`, that the dashboard may save to and show from `tool_config_service` as manual credentials; otherwise the dashboard strips token fields from that service.
- `tool_config_oauth_fallback_env_vars` (tuple of strings, default `()`) names environment variables that, when all are set, make the dashboard show the provider's tools as configured without an OAuth connection.
- `client_config_services` lists, in lookup order, services holding the OAuth app's `client_id`, `client_secret`, and optional `redirect_uri`; every name must end with `_oauth_client`.
- `shared_client_config_services` are checked after those and supply one app ID and secret for several providers, such as `google_oauth_client`; they never supply `redirect_uri`, so each provider keeps its own callback URL.
- Public clients set `token_endpoint_auth_method: none` and need only `client_id`; confidential clients need both `client_id` and `client_secret`.
- Credential responses redact `client_secret` for client config services.
  Saving a confidential client may omit `client_secret` only when `client_id` is unchanged, so changing its `client_id` requires the matching new secret.
- Client config services cannot be copied through the generic copy route.
- Automatic discovery with `oauth_runtime_bootstrapper()` can register a client dynamically only when the provider declares a provider-specific `client_config_services` entry to store it in.
- `requester_scoped_credentials=True` always stores the provider's tokens in the requester's `user` scope.
- `pkce_code_challenge_method="S256"` enables PKCE; a custom `token_exchanger` receives `(provider, code, client_config, runtime_paths, code_verifier)` so it can send the verifier.
- `allowed_email_domains`, `allowed_hosted_domains`, and a custom `claim_validator` restrict which accounts may connect.
  If a restriction cannot be checked from the provider's verified claims, the connection fails and nothing is saved.

### Troubleshooting OAuth Connections

- **Credentials cannot be read**: when a stored token exists but cannot be read, `/status` returns `reset_required: true`, and tools tell the requester that the credentials "cannot be read" and to reset the connection from the dashboard (the **Tools** tab), then reconnect.
- **Provider token endpoint changed**: MindRoom sends refresh tokens only to the token endpoint recorded when the account was connected.
  If the provider now resolves a different endpoint, MindRoom deletes the stored credential and the user must reconnect; the log shows `oauth_credentials_refresh_failed` with `reason="token_endpoint_changed"` and both endpoint origins.
- **Connections stored as `<credential_service>_credentials.json`**: these older token files are not read; reconnect the account.

## MindRoom-Managed OAuth Onboarding In Conversation

This flow applies to tools whose catalog metadata names an `auth_provider`, including the Google tools and OAuth MCP servers.
Tools labeled `SetupType.OAUTH` without an `auth_provider` use their own setup instructions.
When `config_manager` creates or updates an agent with one of these tools, it returns a connect URL scoped to that agent and the current requester when available.
Present that URL directly instead of asking the configuring agent to call the new tool, because newly configured tools may not be available in the current run.
When the current agent already has a MindRoom-managed provider tool, call a safe status, read, or list operation to check its connection; for an OAuth MCP server, use its `*_connection_status` or `*_list_tools` operation.
A disconnected tool returns an `OAuthConnectionRequired` result with `oauth_connection_required: true`, a scoped `connect_url` when available, and `requires_host_browser: true` when the URL uses a loopback host.
Present `connect_url` directly instead of sending the user to the dashboard; use the dashboard only when no `connect_url` is available.
When `requires_host_browser` is true, explain that the loopback URL (`localhost`, `127.0.0.1`, or `::1`) must be opened in a browser on the computer where MindRoom is running, not on a phone or another computer.
After the user connects, have the target agent retry the safe operation or the original request.

## [`oauth_connections`]

`oauth_connections` lets an agent recover a stuck or revoked MindRoom-managed OAuth connection without broader credential-management controls.
It exposes only `reset_oauth_connection(provider_id)`, where the provider must back one of the agent's configured tools.
The call returns a browser link and changes nothing by itself.
After you confirm on that page, MindRoom removes its saved connection and opens the provider's sign-in page so you can reconnect.
The reset does not revoke access at the provider itself.

```yaml
agents:
  researcher:
    display_name: Researcher
    role: Work with connected documents and recover revoked connections
    model: sonnet
    worker_scope: user_agent
    tools:
      - oauth_connections
      - google_drive
```

### Who Can Reset A Connection

| Connection type | Who confirms the reset | What the reset affects |
| --- | --- | --- |
| Shared (`shared`) | An administrator or configured credential manager can request the link. Anyone with the complete link can confirm it once before it expires. | Everyone using this agent |
| Personal (`user`) | The same MindRoom user who requested the link, signed in to the dashboard | That user's connection across agents |
| Personal for one agent (`user_agent`) | The same MindRoom user who requested the link, signed in to the dashboard | That user's connection for this agent only |

Requesting a personal reset link needs the same authority as connecting that account (see [Who Can Connect An Account](#who-can-connect-an-account)), and on a private agent every connection is personal.
Some providers always use personal connections, regardless of the agent's scope.
Keep a shared reset link private, because confirming it disconnects the service for everyone using that agent until someone reconnects.

### Reset A Connection

1. Ask the agent to call `reset_oauth_connection()` for the affected provider.
2. Open the returned link within 10 minutes.
3. Review which agent and connection type will be affected, then confirm the reset.
4. Sign in at the provider and retry the original request.

Opening the link without confirming changes nothing.
MindRoom refuses an expired, unauthorized, or outdated link without deleting credentials; ask the agent for a new link.
Installation-level connections that are not assigned to an agent scope must be reset from the dashboard.
`oauth_connections` always runs in the primary MindRoom runtime, even if it appears in `worker_tools`, and normal `tool_approval` rules apply when the agent creates the link.

# Google Services OAuth For Local Installs

This page explains how to connect Google Drive, Docs, Calendar, Sheets, Tasks, and Gmail on a paired local installation.
Paired installations use MindRoom's Google OAuth client, so you do not need a Google Cloud project, callback URLs, or a client secret.
Pair first with `mindroom connect`, or let `mindroom run` pair automatically on first run (see [Hosted Matrix](https://docs.mindroom.chat/deployment/hosted-matrix/)).
The provisioned client works only when MindRoom is opened on a loopback address (`localhost`, `127.0.0.1`, or `::1`).
For an unpaired install, a remote or public address, organization-specific Google policies, or your own consent-screen branding, set up a custom client instead (see [Custom Google Cloud Setup](https://docs.mindroom.chat/deployment/google-services-oauth/#custom-google-cloud-setup)).
That page also lists the scopes each Google tool requests and how to restrict which Google accounts may connect.

## Choose Providers

Add only the Google tools your agents need.
Each tool connects and asks for Google approval separately.

```yaml
agents:
  personal:
    display_name: Personal
    role: Help with my Google workspace
    worker_scope: user_agent
    tools:
      - google_drive
      - google_docs
      - google_calendar
      - google_sheets
      - google_tasks
      - gmail
```

`worker_scope` decides whose Google account the agent uses.
With `user_agent`, each Matrix user connects their own account for this agent.
With `shared`, everyone allowed to use the agent acts through one connected account and may receive its Google data in replies.
See [Where Connections Are Stored](https://docs.mindroom.chat/oauth-framework/#where-connections-are-stored) for every scope.

## Connect

1. Ask the agent to do something safe with the Google tool, such as listing files or upcoming events.
   If the account is not connected, the agent replies with a connect link.
   When `config_manager` has just added the Google tool to an agent, use the connect link it returns instead.
2. Open the link in a browser on the computer where MindRoom runs, not on a phone or another computer, because the link points to `localhost`.
   If you are chatting from another device, open the conversation on the MindRoom computer or copy the complete link into a browser there.
3. Choose a Google account and approve the scopes for that one service.
4. Ask the agent to retry the request.

Without a connect link, open the dashboard **Tools** tab, choose the agent in the selector, and select **Connect** on the Google integration.
Only agents with a worker scope appear in the selector; for an agent without one, keep **Shared deployment credentials** selected.
The dashboard shows how Google data is handled before you continue.
For personal connections from the dashboard, the dashboard must know your Matrix identity; see [Connect An Account](https://docs.mindroom.chat/oauth-framework/#connect-an-account).
Link lifetime, who may connect, disconnecting, and resetting a connection are covered in the [OAuth Integration Framework](https://docs.mindroom.chat/oauth-framework/).

## Privacy

On a local installation, the provisioning service only supplies the OAuth client configuration.
Google sends the authorization response straight to your MindRoom process, which exchanges and stores the tokens, so the provisioning service does not receive your authorization code, tokens, or Google API data.
Google data an agent reads goes to your configured AI model provider and Matrix homeserver as part of the conversation.
Anyone with administrative or filesystem access to the installation's storage may be able to read the stored credentials and data.
See the [Privacy Policy](https://docs.mindroom.chat/privacy/#google-api-services) for the complete data-handling disclosure.

## Troubleshooting

- **`OAuth client configuration could not be resolved`**: MindRoom could not get a Google OAuth client, usually because the install is not paired or its pairing was revoked.
  Run `mindroom connect`, or configure a [custom client](https://docs.mindroom.chat/deployment/google-services-oauth/#custom-google-cloud-setup).
- **`The provisioned OAuth client is available only when MindRoom is opened on localhost...`**: open MindRoom on `localhost`, or configure a custom client for remote access.

# Google Services OAuth

This page covers the Google OAuth providers behind the Google Drive, Docs, Calendar, Sheets, Tasks, and Gmail tools: which scopes each requests and why, how to set up your own Google Cloud OAuth client, and how to restrict which Google accounts may connect.
Paired local installations need no Google Cloud setup and use MindRoom's provisioned client; see [Google Services OAuth For Local Installs](https://docs.mindroom.chat/deployment/google-services-user-oauth/).
Use a custom client when MindRoom is opened on any address other than a local loopback address such as `localhost` or `127.0.0.1`, when the installation is not paired, or when you need organization-specific Google policies or your own consent-screen branding.
The provisioned client works only on a loopback address; other addresses get `The provisioned OAuth client is available only when MindRoom is opened on localhost. Set MINDROOM_PUBLIC_URL (or MINDROOM_BASE_URL) and configure a custom OAuth client for remote access.`
How connections are made, stored per credential scope, disconnected, and reset is covered in the [OAuth Integration Framework](https://docs.mindroom.chat/oauth-framework/).

## Providers

Each Google tool has its own provider, so users connect and approve each service separately.

| Tool | Provider ID | Token service | Client config service | Settings service | Google scopes |
| --- | --- | --- | --- | --- | --- |
| Google Drive | `google_drive` | `google_drive_oauth` | `google_drive_oauth_client` | `google_drive` | `drive` |
| Google Docs | `google_docs` | `google_docs_oauth` | `google_docs_oauth_client` | `google_docs` | `documents` |
| Google Calendar | `google_calendar` | `google_calendar_oauth` | `google_calendar_oauth_client` | `google_calendar` | `calendar.events`, `calendar.calendarlist.readonly`, `calendar.freebusy`, `calendar.settings.readonly` |
| Google Sheets | `google_sheets` | `google_sheets_oauth` | `google_sheets_oauth_client` | `google_sheets` | `spreadsheets` |
| Google Tasks | `google_tasks` | `google_tasks_oauth` | `google_tasks_oauth_client` | `google_tasks` | `tasks` |
| Gmail | `google_gmail` | `google_gmail_oauth` | `google_gmail_oauth_client` | `gmail` | `gmail.modify` |

Scope names are relative to `https://www.googleapis.com/auth/`.
Every provider also requests the OpenID `openid`, email, and profile scopes.
Each provider's callback path is `/api/oauth/<provider-id>/callback`.

## Scope Rationale

MindRoom requests only the scopes needed for the operations it exposes to agents.

- `drive` lets an agent search, read, upload, create folders, move or rename files, and move files to trash across the connected account.
  It also authorizes permanent deletion at the Google API layer, but MindRoom exposes only trashing.
  The narrower `drive.file` scope would break account-wide search, because it covers only files created by or explicitly shared with the app.
- `documents` lets an agent create documents, read their full structure and content, and edit text without access to other Drive files.
  It also authorizes deleting documents, but the `google_docs` tool has no delete operation.
  `drive.file` is not enough here, because MindRoom opens documents by ID or URL rather than through Google Picker.
- `calendar.events` lets an agent read events and, when `allow_update` is enabled, create, update, delete, move, and respond to events, without access to calendar sharing or calendar deletion.
- `calendar.calendarlist.readonly` lets an agent list subscribed calendars without changing subscriptions.
- `calendar.freebusy` lets an agent check availability without seeing event details through that operation.
- `calendar.settings.readonly` lets an agent infer working hours from the Calendar locale without changing Calendar settings.
- `spreadsheets` lets an agent read and, when enabled, create or update spreadsheets the user names.
- `tasks` lets an agent list task lists and tasks and, when `manage_tasks` is enabled, create, update, complete, and delete tasks.
  It also authorizes creating and deleting task lists, but the `google_tasks` tool does not manage task lists.
- `gmail.modify` is the narrowest single Gmail scope that keeps mailbox search and reading, drafts and sending, replies, labels, archiving, and other organization.
  It does not allow permanent deletion that bypasses the trash, and MindRoom does not request the full `mail.google.com` scope.
- The OpenID email and profile scopes identify the connected account and enforce [account restrictions](#account-restrictions).

Google API data is used only for the agent operation the user requested.
Relevant results may be sent to the installation's configured AI model provider solely to generate that response, as disclosed before connection and in the [Privacy Policy](https://docs.mindroom.chat/privacy/).
MindRoom does not use Google user data to train or improve generalized, foundational, or frontier AI models.

## Custom Google Cloud Setup

1. In Google Cloud Console, create an OAuth client of type **Web application**.
2. Enable only the Google APIs for the tools you plan to use, such as the Google Docs API for `google_docs` and the Google Tasks API for `google_tasks`.
3. Add one authorized redirect URI per provider you enable.
   With the default local origin these are:

   ```text
   http://localhost:8765/api/oauth/google_drive/callback
   http://localhost:8765/api/oauth/google_docs/callback
   http://localhost:8765/api/oauth/google_calendar/callback
   http://localhost:8765/api/oauth/google_sheets/callback
   http://localhost:8765/api/oauth/google_tasks/callback
   http://localhost:8765/api/oauth/google_gmail/callback
   ```

   For a public deployment, replace the origin with your `MINDROOM_PUBLIC_URL` (see [callback URL rules](https://docs.mindroom.chat/oauth-framework/#connect-an-account)).
4. Save the client ID and secret in the dashboard with **Use custom client** on the Google integration, in a client config service, or through a credential seed, as described below.

A stored custom client always takes precedence over the provisioned client.

### Client Config Services

Store the client in a provider-specific service, such as `google_drive_oauth_client`, to apply it to that provider only.
Store it in `google_oauth_client` to apply one client to every Google provider.
A provider-specific service wins over `google_oauth_client`.

```json
{
  "client_id": "your-client-id.apps.googleusercontent.com",
  "client_secret": "your-client-secret",
  "redirect_uri": "https://mindroom.example.com/api/oauth/google_drive/callback"
}
```

`redirect_uri` is optional and only needed when the callback differs from the derived one.
Only provider-specific services honor it; `google_oauth_client` ignores it and each provider uses its own derived callback.
Secret redaction and the rules for editing a saved client are in [Provider And Credential Service Rules](https://docs.mindroom.chat/oauth-framework/#provider-and-credential-service-rules).

For non-interactive deployments, seed the shared client at startup with a [credential seed](https://docs.mindroom.chat/oauth-framework/#credential-seeds) file named by `MINDROOM_CREDENTIAL_SEEDS_FILE`:

```json
[
  {
    "service": "google_oauth_client",
    "credentials": {
      "client_id": {"env": "GOOGLE_CLIENT_ID"},
      "client_secret": {"env": "GOOGLE_CLIENT_SECRET"}
    }
  }
]
```

## Service Account

To use a Google Workspace service account instead of per-user OAuth, set `GOOGLE_SERVICE_ACCOUNT_FILE` to the service-account key file path in the environment or the config-adjacent `.env`.
Set `GOOGLE_DELEGATED_USER` to the account to impersonate through domain-wide delegation, and authorize the service account's client ID for the scopes in the [Providers](#providers) table in the Google Workspace Admin console.
The service account then serves every Google tool on this page instead of stored user connections, and the dashboard shows these services as connected.

## Production Verification Follow-up

Google classifies the `documents` and `tasks` scopes as sensitive and the `drive` scope as restricted.
Until Google approves a sensitive scope on your production project, users can see an unverified-app warning and test-user limits still apply, so do not enable that provider for general users before approval.
To add the Docs or Tasks provider to a project whose consent screen is already in verification:

1. Do not add the new scope to the production consent configuration while another verification submission is under review, unless you intend to change that submission.
2. Validate the integration in a separate Google Cloud testing project with test users: enable the API, add the scope, and register the callback.
3. After the production review completes, add the API and scope to the production project's Data Access configuration.
4. Submit the sensitive-scope verification follow-up with a scope justification and an end-to-end demo.

## Account Restrictions

Restrict which Google accounts may connect with comma-separated domain lists, set per provider in the environment or the config-adjacent `.env`:

| Variable | Accepts an account when |
| --- | --- |
| `<PROVIDER>_ALLOWED_EMAIL_DOMAINS` | Its verified email address is in one of the listed domains |
| `<PROVIDER>_ALLOWED_HOSTED_DOMAINS` | It belongs to one of the listed Google Workspace domains |

`<PROVIDER>` is the uppercased provider ID: `GOOGLE_DRIVE`, `GOOGLE_DOCS`, `GOOGLE_CALENDAR`, `GOOGLE_SHEETS`, `GOOGLE_TASKS`, or `GOOGLE_GMAIL`.
Each variable also accepts a `MINDROOM_OAUTH_` prefix, such as `MINDROOM_OAUTH_GOOGLE_GMAIL_ALLOWED_HOSTED_DOMAINS`.

```bash
GOOGLE_DRIVE_ALLOWED_EMAIL_DOMAINS=example.com
GOOGLE_GMAIL_ALLOWED_HOSTED_DOMAINS=example.com,example.org
```

A rejected account saves nothing, and the callback returns HTTP 400 with `OAuth account email domain is not allowed`, `OAuth account email ownership is not verified`, or `OAuth hosted domain claim is not allowed`.

# Security Posture

This page records MindRoom's security model, the `file_access` setting, and the behaviors that are intentional.
Use it to explain what an agent's tools can reach and to check a suspected vulnerability before reporting or fixing it.
If a finding contradicts an intentional behavior below, do not change the code; raise the posture with the maintainers and explain which scenario it fails to protect.

## Trust model

A worker container, reached through the sandbox proxy, is the only security boundary between an agent and the MindRoom runtime.

Tools that run arbitrary programs cannot be confined inside their own process, because a program started by a shell or Python tool ignores any in-process path or command check.
MindRoom therefore never filters the paths, commands, or imports of code-execution tools as a security measure; it isolates them only by routing them to a worker with `worker_tools`.

An agent whose code-execution tools run in the primary process is trusted with everything that process can reach, so restricting its other tools protects nothing.

An agent whose code-execution tools run in a worker must not reach more through its primary-process tools than its worker can.
Tools such as `browser`, `attachments`, `matrix_message`, `gmail`, and `google_drive` run in the primary process by default even then, so they follow the agent's [`file_access`](#file-access) setting, which by default keeps them inside the agent workspace.

Which Matrix user may drive an agent, act in a room, or approve a change is a separate question governed by access policy and requester authorization.
An agent or team mentioned in another entity's reply acts for the human or configured bot account that requested that reply, so neither can reach an entity whose `access` excludes them by asking a different entity to mention it.

## Protecting the primary from worker code

Hardening that protects the primary runtime and other tenants from untrusted worker code is always in scope.
Examples are symlinks or files planted in shared workspaces that the primary later follows, worker-writable metadata the primary trusts, Git config the primary executes, and secrets mounted or passed into workers.

### Config and state kept out of workers

Runners and dedicated workers never receive the primary's config file, its directory, its `.env`, a ConfigMap holding it, or the credential encryption key.
The config sent to runners with each request, and the config file projected into each Docker worker, hold only the fields runners resolve, such as agent execution scopes, file access, workspace and knowledge paths, tool names, and plugin paths.
Models, MCP servers, plugin settings, and every other section stay in the primary, and a worker-routed call carries only the called tool's own inline overrides.

Dedicated Docker and Kubernetes workers mount only agent workspaces, never the agent state roots around them, so sessions, memory, learning, Mem0 data, and private-instance identity records stay out of every worker.
The Kubernetes `static_runner` sidecar still mounts `agents` and `private_instances` read-write, so records the primary treats as authority live below the primary-only `tracking/` directory instead.
These are the invited-room and pending-invite ledgers, personal-room records, conversation modes, and the primary's copy of each private-instance owner record, together with their locks.
The thread exporter writes into a private instance only for the requester that the primary's owner record names, so an owner record planted in `private_instances` gets no export.

### Workspace mounts by worker scope

[Worker scopes](https://docs.mindroom.chat/deployment/sandbox-proxy/#worker-scopes) lists the workspaces each dedicated worker mounts.
A `user` worker mounts several agents' workspaces, so code working in one of them can read and write the others.

A workspace is mounted only when it is a real directory reached from the storage root without links.
Because Docker resolves mount targets again on every container start, the backend walks the targets inside the worker's writable root without following links before creating or restarting a worker, and refuses one that worker code replaced with a link.
Assigned knowledge outside the workspace reaches a worker only when its configured path is a real directory or file, reached without links, outside every directory other workers write.
Kubernetes mounts such knowledge read-only, because kubelet follows links inside the volume when it mounts, and Docker copies it into the worker's read-only config snapshot through no-follow descriptors.

### Workspace files the primary reads and writes

The primary treats everything inside a mounted workspace as worker-controlled.
Files it reads or writes there, such as skills, context files, delegation records, knowledge sources, call transcripts, callback scripts, script-run snapshots, todo templates, scheduled-run receipts, workspace knowledge links, and thread exports, are reached through `path_confinement` descriptors walked from the workspace root.
Those descriptors refuse links, never write to a file that has another hard link, open files non-blocking so a FIFO cannot stall the primary, and publish files by atomic replacement or exclusive creation.
The primary never takes a file lock inside a workspace, because worker code could hold it forever.
Delegation records keep their working state below `tracking/`, and the `run.json`, `events.jsonl`, and `transcript.md` in the child's workspace are exports the primary never reads.

In the primary, `mindroom_output_path`, attachment saves, Google Drive and E2B downloads, `file_generation` saves, `visualization` charts, workspace knowledge links, workspace todo templates, and `file` and `coding` reads, writes, and deletes open the authorized workspace as spelled rather than its resolved target, so they refuse a workspace replaced by a link after runtime resolution.
`file` and `coding` also pin their workspace when they are built, refusing one that is a link or that changed while it was resolved, and their listing and search refuse a pinned workspace that no longer resolves to itself.
No-follow applies to the workspace's final path component only, so a replaced parent such as `agents/<name>` is not refused.
With the default workspace names that is not exploitable in the hosted layout, because no primary-only directory there contains an entry named `workspace`; an authored `private.root` that matches a primary directory name, such as `credentials`, could be reached through a replaced parent.

Git commands the primary runs in a workspace, for knowledge checkouts and the `coding` tool's ignore check, use the hardened Git command and environment, so programs named in workspace Git config never run.

### Read limits on workspace files

Reads of worker-controlled files are capped per surface.

| Surface | Cap | Above the cap |
|---|---|---|
| Context files | 1 MiB | Truncated with a warning; context preload truncation shortens them further |
| Workspace `SKILL.md`, skill references and scripts | 1 MiB per file; names 64 characters; descriptions 1024 characters; 256 listed scripts and 256 references; per workspace 256 skills and 8 MiB of `SKILL.md` files and listings, including files that fail to load; 1,024 examined entries per `skills/`, `scripts/`, `references/`, or `skills/.history/<skill>/` directory, for skill loading and skill learning alike | An oversized file or a skill with a longer name is refused, a longer description or listing is truncated, and skills beyond the budget or count are skipped, each with a warning; entries past the first 1,024 in directory order are not examined |
| Call transcripts sent to Mem0 | 64 MiB | Truncated |
| Knowledge sources, including operator-managed ones | 64 MiB | Left out of the listing with a warning |
| Scheduled-run receipts, `file` and `coding` reads, `airflow` DAG reads | 64 MiB | Refused with a logged error |
| `e2b` uploads | 64 MiB | Refused with a tool error |
| Files a `file` content search reads | 500 KiB each, Agno's search limit | Skipped |
| `browser` upload snapshots, kept in the browser's temp directory until their tab closes | 256 MiB in total per browser | Refused with a tool error |
| `moviepy_video_tools` staged inputs | 1 GiB per video; 1 MiB per caption file | The call fails before staging more than the cap |

Thread-export files are read and built under the per-file, per-thread, and per-room limits documented in [Thread Exports](https://docs.mindroom.chat/thread-exports/).
Workspace todo templates have the size, render, and listing limits documented in [`todo`](https://docs.mindroom.chat/tools/project-management/#todo), and they render in a short-lived, memory-limited child process instead of the primary, because sandboxed Jinja alone does not bound their cost.

Workspace `SKILL.md` frontmatter, todo templates, and thread-export files are refused before parsing when their YAML uses aliases, deep nesting, or other structures that would let a small file cost the primary unbounded memory, stack depth, or parse time; [Skills](https://docs.mindroom.chat/skills/#skillmd-format-openclaw-compatible) lists the exact limits.

## Hosted tenant isolation

Hosted tenants share the `mindroom-instances` namespace, and a tenant can run code in its own primary and sandbox-runner sidecar by design.
The boundary between tenants is the cluster configuration around those pods.

[Multi-Tenant Architecture](https://docs.mindroom.chat/deployment/saas-platform/#multi-tenant-architecture) lists those controls.
The instance chart refuses dedicated Kubernetes workers because RBAC cannot confine a worker manager to one tenant's Deployments, Services, PVCs, and Secrets in a shared namespace.

## File access

`defaults.file_access` sets the default and `agents.<name>.file_access` overrides it for one agent.

| Value | Path tools may use |
|---|---|
| `workspace` (default) | Files inside the agent workspace and attachments available in the conversation |
| `unrestricted` | Any file the tool's process can reach; on a worker that is the worker container |

Every tool, including plugin tools, must declare one of these file access classes when it registers, so no tool silently defaults to taking no paths.

| Tool class | Tools | Behavior |
|---|---|---|
| Path tools | `attachments` (including `view_file`), `matrix_message`, `chat_ui` canvas pages (`show_canvas` with `path`), `gmail`, `google_drive`, `browser` uploads, `e2b` uploads, `openai` and `groq` audio files, `airflow`, `moviepy_video_tools` | Follow the agent's `file_access` and read through no-follow descriptors, so replaced workspace roots and swapped files are refused; `airflow` and `moviepy_video_tools` also write by atomic replacement without following links below the workspace, and MoviePy and FFmpeg work only on private staged copies, refusing video inputs that FFmpeg would open as playlists or manifests |
| Worker path tools | `file`, `coding` | Follow the agent's `file_access`; reads, writes, chunk edits, deletes, and every file a `file` content search reads go through no-follow descriptors, and writes replace the file atomically; listing and the other searches walk by path, as the known gap below describes |
| Unconfined tools | Code-execution tools (`shell`, `python`, `docker`, `script`, `claude_agent`) and tools whose queries, paths, or URLs reach local files without confinement (`duckdb`, `csv`, `pandas`, `sql`, `composio`, `postgres`, `redshift`, `browserbase`, `slack`) | Class `unconfined`: not confined by `file_access`, whatever the agent's setting; authored tool config may only state `file_access: unconfined` |
| Other tools | Everything else, including `web_browser_tools`, which opens only `http` and `https` URLs | Take no local file paths |

[`browser_mcp`](https://docs.mindroom.chat/tools/worker-computer/#native-playwright-mcp-provider) runs only in a worker and confines its upload, drop, screenshot, and PDF paths to the worker workspace whatever the agent's `file_access`.

MCP servers on the local `stdio` transport are unconfined too, because they are operator-launched programs; remote `sse` and `streamable-http` servers take no local file paths.
Whether a tool executes code is a separate metadata flag from its file access class; only the code-execution tools above carry it.
A worker isolates the unconfined tools that support worker routing (`shell`, `python`, `docker`, `csv`, `postgres`, `redshift`).
The rest require the primary runtime and cannot run in a worker (`claude_agent`, `script`, `duckdb`, `pandas`, `sql`, `composio`, `browserbase`, `slack`), so enable them only for agents trusted with everything the primary runtime can reach.
MindRoom logs a warning when an agent routes code-execution tools to a worker while primary-process tools stay unconfined, meaning unconfined tools or path tools under `file_access: unrestricted`, because those tools can then read runtime secrets the worker was meant to keep away.
The model sees the effective file access and the unconfined tools in its tool execution environment description.

## Known gaps

These are tracked gaps, not intentional behaviors; fix them rather than documenting around them.

- The unconfined non-code tools `composio`, `postgres`, `redshift`, `browserbase`, and `slack` do not yet follow `file_access`; a separate change will confine their explicit path and URL arguments.
- SQL-capable tools (`duckdb`, `csv`, `sql`, and `pandas` query helpers) embed file paths inside queries, so guarding explicit path arguments cannot confine them; they stay unconfined until a query-level mechanism exists.
- The listing and search functions of `file` and `coding` (`list_files`, `search_files`, `grep`, `find_files`, and `ls`) refuse a workspace that no longer resolves to the pinned directory and paths that lead outside it, but then walk and read by path.
  This matters only when an operator routes these tools to the primary process while worker code writes the same workspace, such as the Kubernetes `static_runner` sidecar with `worker_tools` that leave out `file` and `coding`.
  Code that swaps the workspace, a checked directory, or a file for a link between that check and the walk can make that one call list or return any file the primary process can read, and a planted FIFO can stall a `coding` content search.
  By default these tools run in a worker, where they see only what the worker already mounts.
  The `file` tool's `search_content` reads each file it finds through no-follow descriptors, so under `file_access: workspace` it returns only workspace content.
- Knowledge Git commands refuse a worktree whose path goes through a link, but Git then reopens that worktree by path.
  For a Git-backed knowledge base inside a workspace a worker writes, swapping the knowledge folder for a link between that check and the Git command can make that one sync check out or update files at the link target with the primary's permissions; the next sync refuses the link.

## Intentional behaviors

Do not report or "fix" these; they are deliberate.

- An operator who runs MindRoom without workers, for example inside a dedicated LXC container or VM, has chosen full trust; `file_access: unrestricted` matches that choice.
- `file_access: unrestricted`, or unconfined tools in the primary process, are allowed together with worker routing; they log a warning instead of failing.
- Hosted tenants may set `file_access: unrestricted`; it exposes only their own instance.
- With `file_access: workspace`, the browser, attachments, `matrix_message`, `chat_ui` canvases, Gmail, and Google Drive may still use every file in the agent workspace.
- A `chat_ui` canvas can leak what the user types into it through WebRTC, which browsers do not let a page block, so agents should only show pages they wrote; see [Privacy and safety](https://docs.mindroom.chat/canvases/#privacy-and-safety).
- The browser and `matrix_message` also accept `att_*` IDs of attachments available in the conversation; Gmail and Google Drive take file paths only, so an agent first saves a received attachment into the workspace with `get_attachment(mindroom_output_path=...)` and then passes that workspace path.
- `mindroom_output_path`, attachment saves, Google Drive downloads, `file_generation` saves, `visualization` charts, and report publishing always write inside the workspace regardless of `file_access`, because they produce MindRoom-owned output.
- Writes into `.git` directories stay blocked for `file` and `coding` in both modes, because MindRoom runs Git in checkouts that may sit inside agent workspaces.
- A skill review may resend the reviewed conversation unredacted to the provider that just received it, and a digest replay redacts credentials only on a best-effort basis; see [What a review sees](https://docs.mindroom.chat/skills/#what-a-review-sees).
  The check that refuses skill files holding a literal credential is a heuristic for common formats; a miss is not a vulnerability, because skills live in the agent's own workspace next to the conversation history that already held the value.
- Automatic skill learning trusts the `metadata.mindroom` frontmatter flags and the provenance and usage in `skills/.usage.json` inside the workspace, which worker code can change; that only lets it make the learner edit or archive skills worker code could already change itself.
- `skill_manage`, used in chat by agents that list it or learn skills and by skill reviews, runs in the primary process and writes only below the agent's workspace `skills/` directory through no-follow descriptors, regardless of `file_access`, like MindRoom-owned output.
- A team's `access` authorizes its exact member agents for team requests, in Matrix and the OpenAI-compatible API, even members whose own `access` would not admit the requester directly; adding an agent to a team is a deliberate grant.
- A response's CLI grant, in minimal mode or for a standard-mode agent whose shell can reach MindRoom, is in the environment of its shell commands, so other code in the same worker can use that requester's tools through it until the response ends; code that shares a worker already shares its trust.
- A non-private agent's workspace is shared by every requester's runtime for that agent, including `user` and `user_agent` workers, so files one requester leaves there (such as `.mindroom/worker-env.sh`, `.pth` files, or Git config) can run in another requester's runtime; use `private` agents when requesters need isolation from each other.
- Likewise, a non-private agent's primary-process `browser` profiles and default artifacts live in that agent's state root and are shared by all of its requesters, so one requester can use web sessions another signed in and upload screenshots another took; they never cross agents or the requesters of a private agent.
- A browser egress proxy in a sandbox runner receives destination hostnames, because an approved-egress proxy decides by name, so rebinding between the relay's check and that proxy's own lookup is the proxy's responsibility; in the primary the relay tunnels to the address it validated.
- A proxy environment the primary cannot follow, such as a SOCKS proxy or `auto_proxy`, makes the relay dial directly within the browser's destination policy instead of refusing to start, because no MindRoom egress policy depends on that proxy there; a sandbox runner refuses.
- A shared knowledge base whose path lies inside an agent workspace, such as that agent's thread exports, is bound without following links below the workspace, so even an operator-made link there is refused; shared knowledge outside every agent workspace still follows operator links.
- A team approval continuation checks only that each approved call keeps the arguments its card showed, not whether the saved team run holds other calls ready to run, because team runs live below `teams/`, which no worker mounts.
- The Kubernetes `static_runner` sidecar mounts `agents` and `private_instances` read-write as the primary's user, so tool code it runs can read and change every agent's sessions, memory, learning data, and private-instance contents; the shared sidecar is not an isolation boundary between agents or requesters, and dedicated Kubernetes workers are the option that is.
- After an upgrade from workers that mounted whole state roots, startup stops those workers before serving, failing until none remain, and warns; it does not scan for or repair links they may have planted above workspaces, because a scan on every start is costly and an automatic repair could itself follow a planted entry, so an operator runs the [upgrade check](https://docs.mindroom.chat/deployment/upgrades/#workspace-only-worker-mounts).
- Conversation OAuth connect and reset links for shared-scope credentials work without a dashboard login; the short-lived single-use link and the recheck of the issuing requester's credential-management permission authorize them, because some deployments give users no dashboard access.
- Anyone who can use an agent may connect or reset their own requester-owned OAuth connection (user or user-agent credential scope) through its chat links, as on the Connections portal; the browser must authenticate as the link's requester, and shared-scope connections still require an administrator or credential manager.

## Reviewing security findings

Classify every finding before proposing a change.

1. Name the boundary it crosses: worker to primary, tenant to tenant, Matrix user to agent, or none.
2. Check whether the agent involved is trusted under the trust model above.
3. Check the intentional behaviors list.
4. Only a finding that crosses a real boundary and matches no intentional behavior is a bug.

A fix that restricts a trusted setup, or that blocks workspace and attachment flows an agent legitimately needs, is not a security fix.
When a maintainer rejects a finding as intended behavior, add it to the list above in the same change so the next reviewer does not rediscover it.

# Plugins

> [!WARNING]
> **Plugins execute arbitrary Python code in the same process as MindRoom.**
> A malicious plugin has full access to your credentials, Matrix sessions, file system, and network.
> Only install plugins you trust and have reviewed.

Plugins extend MindRoom with custom tools, [hooks](https://docs.mindroom.chat/hooks/), OAuth providers, and skills without changing MindRoom itself.
Use one to add an integration MindRoom does not ship, react to or transform events, or connect an additional Google or Atlassian account.
A plugin is a directory with a `mindroom.plugin.json` manifest, loaded from the paths listed under `plugins:` in `config.yaml`.

## Installing plugins

Vendor a plugin from GitHub into `<config dir>/plugins/<repo name>`:

```bash
mindroom plugins install ping-hook-plugin
mindroom plugins install mindroom-ai/ping-hook-plugin@v1.2.0
```

The spec is `NAME`, `OWNER/REPO`, or `OWNER/REPO@REF`.
Bare names install from the `mindroom-ai` organization, and `@REF` pins a branch, tag, or commit; without it the repository's default branch is used.
A plugin is installed only after it passes the [compatibility check](#compatibility-checks).
The installed directory records the repository, requested reference, and exact commit in `.mindroom-plugin.lock.json`.
Installing into an existing directory fails with `Plugin directory already exists: ... Use 'mindroom plugins update' instead.`
Plugin Python dependencies are not installed; the command prints a note when the plugin has a `pyproject.toml`.

Then add the plugin to `config.yaml`:

```yaml
plugins:
  - path: plugins/ping-hook-plugin
```

Update vendored plugins:

```bash
mindroom plugins update ping-hook-plugin
mindroom plugins update --all
mindroom plugins update ping-hook-plugin --ref v1.3.0
```

An update fetches the latest commit of the pinned reference, or repins with `--ref`, and skips plugins already at that commit.
Installing a new commit replaces the whole plugin directory and discards local edits to it, so copy any changes you want to keep first.
A failed update leaves the installed version untouched.
Both commands accept `--path` to select the config file whose directory is used and `--plugins-dir` to override the vendor directory.
Set `GITHUB_TOKEN` to authenticate GitHub requests, which raises API rate limits and allows private repositories.

### Community plugins

The [mindroom-ai](https://github.com/mindroom-ai) organization maintains these open-source plugins.

| Plugin | Provides | Description |
| --- | --- | --- |
| [ping-hook-plugin](https://github.com/mindroom-ai/ping-hook-plugin) | Hooks | Minimal example that answers `!ping-hook` with a pong; a good starting point for learning hooks. |
| [shell-guard-plugin](https://github.com/mindroom-ai/shell-guard-plugin) | Hooks | Blocks dangerous shell commands, such as `systemctl restart mindroom-chat`, through `tool:before_call` gating. |
| [voice-enrich-plugin](https://github.com/mindroom-ai/voice-enrich-plugin) | Hooks | Warns the model about possible transcription errors in voice-transcribed messages. |
| [location-enrich-plugin](https://github.com/mindroom-ai/location-enrich-plugin) | Hooks | Adds real-time GPS location from [Dawarich](https://dawarich.app/) to prompts, with place matching and movement classification. |
| [restart-resume-plugin](https://github.com/mindroom-ai/restart-resume-plugin) | Hooks | Re-activates threads tagged `pending-restart` after a bot restart. |
| [thread-snooze-plugin](https://github.com/mindroom-ai/thread-snooze-plugin) | Hooks and tools | Temporarily resolves a thread and wakes it at a specified time. |
| [thread-goal-plugin](https://github.com/mindroom-ai/thread-goal-plugin) | Hooks and tools | Per-thread goals stored in Matrix room state that survive context compaction and restarts. |
| [workloop-plugin](https://github.com/mindroom-ai/workloop-plugin) | Hooks and tools | External workloop; not needed for per-thread todo plans and auto-poke, which MindRoom provides natively. |
| [openviking-plugin](https://github.com/mindroom-ai/openviking-plugin) | Hooks and tools | Long-term memory through [OpenViking](https://github.com/volcengine/OpenViking) with automatic extraction, recall, and compaction archiving. |

## Configure plugins

List plugins under `plugins:` in `config.yaml`.
An entry is either a path string or an object with options, and both forms can be mixed:

```yaml
plugins:
  - ./plugins/my-plugin
  - python:my_skill_pack
  - path: ./plugins/personal-context
    enabled: true
    settings:
      dawarich_url: http://dawarich.local
      api_key: secret
    hooks:
      enrich_with_location:
        priority: 20
      audit_messages:
        enabled: false
```

### Entry formats

| Field | Type | Default | Description |
| --- | --- | --- | --- |
| `path` | string | *required* | Plugin directory or Python package spec (see [Path resolution](#path-resolution)) |
| `enabled` | bool | `true` | Set to `false` to disable the plugin without removing the entry |
| `settings` | dict | `{}` | Free-form values passed to the plugin's hooks and OAuth module; masked as secret in the dashboard |
| `hooks` | dict | `{}` | Per-hook overrides keyed by hook function name |

Each hook override supports:

| Field | Type | Default | Description |
| --- | --- | --- | --- |
| `enabled` | bool | `true` | Set to `false` to disable that hook |
| `priority` | int | `null` | Override the hook's execution priority |
| `timeout_ms` | int | `null` | Override the hook's timeout in milliseconds |

Adding, removing, or changing a `plugins:` entry takes effect through config hot reload.

### Path resolution

- Absolute paths and paths starting with `~` are used as-is.
- Relative paths resolve against the directory containing `config.yaml`.
- A bare name without `/` that does not start with `.` uses a matching config-relative directory if one exists, otherwise an importable Python package.

## Python package plugins

Plugins can ship inside installed Python packages:

```yaml
plugins:
  - my_skill_pack
  - python:my_skill_pack
  - pkg:my_skill_pack:plugins/demo
  - module:my_skill_pack:plugins/demo
```

The `python:`, `pkg:`, and `module:` prefixes always resolve a package, and `:sub/path` after the package name points to a subdirectory inside it.
The resolved directory must contain `mindroom.plugin.json`.

## Live development (hot reload)

Plugins hot-reload automatically.
Saving any file inside a configured plugin directory makes the new hooks and tools live for the next event, usually 1-2 seconds after the save.
No service restart or agent session reset is needed.
Edits to `__pycache__/`, `*.pyc`, `*.pyo`, editor swap and backup files (`*.swp`, `*.swo`, `*~`, `.#*`, `*.tmp`), `.git/`, and tool caches (`.ruff_cache/`, `.mypy_cache/`, `.pytest_cache/`) are ignored.

> [!IMPORTANT]
> Every plugin reload, automatic or manual, interrupts all unfinished [background scripts](https://docs.mindroom.chat/tools/background-scripts/), including scripts that do not use the changed plugin.

Watch reloads in the logs:

```bash
journalctl -u mindroom.service -f | grep -E 'Reloading plugins|Plugin reload complete'
```

Keep in mind:

- Only plugins already listed under `plugins:` are watched; a new plugin directory on disk is not loaded until you add it to `config.yaml`.
- Callbacks already running finish on the old code; the new code applies to new events.
- An editor that saves a file in two writes can briefly trigger an import error, followed by a successful reload on the second write.
- Reload cancels `asyncio` tasks the plugin keeps in module-level variables; close connections and other resources yourself.

### Broken plugins

`mindroom run` startup does not crash on a broken plugin.
It logs `Failed to load plugin, skipping` (or `Plugin path does not exist, skipping`) and disables that plugin's tools.
If a broken tools module hides which tools it would have registered, unknown tool names in agent configs are also disabled with a warning.
Two plugins whose manifests share a `name` stop the whole configured plugin set from loading.
`mindroom config validate` and `mindroom plugins check` report the same problems as errors instead.

When a save breaks a plugin during hot reload, MindRoom logs `Plugin reload failed; active plugin set degraded` and keeps the other plugins active, or logs `Plugin reload failed; all plugins deactivated` when no valid set remains.
The next valid save reloads normally.
For hooks that raise while handling an event, see [Errors and timeouts](https://docs.mindroom.chat/hooks/#errors-and-timeouts).

MindRoom logs `Loading non-bundled plugin` once for each plugin outside the MindRoom source tree; this is informational.

### `!reload-plugins`

Force-reload every configured plugin from disk, for example when the watcher missed a change:

```
!reload-plugins
```

The reply lists the active plugins and the number of cancelled background tasks:

```
✅ Reloaded N plugins; cancelled K tasks; active: <plugin names>
```

Only users listed in `administrators` in `config.yaml` may run it; others get `❌ Admin only.`
A failed reload replies `❌ Plugin reload failed: <error>`.
`!reload_plugins` is an alias.

## Compatibility checks

Validate a plugin against the installed MindRoom version before deployment:

```bash
mindroom plugins check ./my-plugin
```

The check strictly validates the manifest, imports declared modules, validates tool, hook, and OAuth registrations, and verifies that declared skill directories exist.
It prints the discovered tools, hooks, and skill directories, and exits nonzero on failure.
It does not parse `SKILL.md` contents or evaluate skill eligibility, and it does not touch your running configuration.

## Plugin structure

```
my-plugin/
├── mindroom.plugin.json   # Required manifest
├── tools.py               # Tool factories (optional)
├── oauth.py               # OAuth providers (optional)
├── hooks.py               # Event hooks (optional)
└── skills/                # Skill directories (optional)
    └── my-skill/
        └── SKILL.md
```

Only the manifest `name` is required; a plugin may provide any combination of tools, hooks, OAuth providers, and skills.

## Manifest format

```json
{
  "name": "my-plugin",
  "tools_module": "tools.py",
  "oauth_module": "oauth.py",
  "hooks_module": "hooks.py",
  "skills": ["skills"]
}
```

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `name` | string | **yes** | Plugin identifier using only lowercase ASCII letters, digits, `-`, and `_`; must be unique among configured plugins |
| `tools_module` | string | no | Relative path to the module with `@register_tool_with_metadata` factories |
| `oauth_module` | string | no | Relative path to the module defining `register_oauth_providers(settings, runtime_paths)` |
| `hooks_module` | string | no | Relative path to the module with `@hook` functions; when omitted, `tools_module` is scanned for hooks |
| `skills` | list of strings | no | Relative directories containing skill subdirectories |

Every declared module file and skill directory must exist.
Unknown fields are ignored.
Pointing `tools_module` and `hooks_module` at the same file is allowed.

## Tools module

A tools module registers tool factories with `@register_tool_with_metadata`.
Each factory returns a **Toolkit class**, not an instance, and MindRoom constructs it when building agents.

### Minimal example

```python
from __future__ import annotations

from typing import TYPE_CHECKING

from mindroom.tool_system.declarations import (
    SetupType,
    ToolCategory,
    ToolFileAccess,
    ToolStatus,
)
from mindroom.tool_system.registration import register_tool_with_metadata

if TYPE_CHECKING:
    from agno.tools import Toolkit


@register_tool_with_metadata(
    name="greeter",
    file_access=ToolFileAccess.NONE,
    display_name="Greeter",
    description="A simple greeting tool",
    category=ToolCategory.DEVELOPMENT,
    status=ToolStatus.AVAILABLE,
    setup_type=SetupType.NONE,
)
def greeter_tools() -> type[Toolkit]:
    from agno.tools import Toolkit

    class GreeterTools(Toolkit):
        def __init__(self) -> None:
            super().__init__(name="greeter", tools=[self.greet])

        def greet(self, name: str) -> str:
            """Greet someone by name."""
            return f"Hello, {name}!"

    return GreeterTools
```

Assign the tool to agents like any built-in tool:

```yaml
plugins:
  - ./plugins/my-greeter

agents:
  assistant:
    tools:
      - greeter
```

### Decorator fields

All `@register_tool_with_metadata` arguments are keyword-only.

**Required fields:**

| Field | Type | Description |
| --- | --- | --- |
| `name` | string | Tool identifier used in agent `tools:` lists |
| `display_name` | string | Name shown in the dashboard |
| `description` | string | Brief description of the tool |
| `category` | `ToolCategory` | Dashboard grouping: `COMMUNICATION`, `DEVELOPMENT`, `EMAIL`, `ENTERTAINMENT`, `INFORMATION`, `INTEGRATIONS`, `PRODUCTIVITY`, `RESEARCH`, `SMART_HOME`, or `SOCIAL` |
| `file_access` | `ToolFileAccess` | How the tool reaches local files (see below) |

`ToolFileAccess` values:

- `NONE`: the tool takes no local file paths.
- `AGENT`: the tool resolves every model-supplied path through `mindroom.file_access.resolve_agent_file`, so it follows the agent's `file_access` setting.
- `UNCONFINED`: the tool reaches local files in a way `file_access` does not confine, such as running programs, queries, or model-chosen paths.

See [Security Posture](https://docs.mindroom.chat/architecture/security-posture/#file-access) for how each class is treated.

**Optional fields:**

| Field | Type | Default | Description |
| --- | --- | --- | --- |
| `status` | `ToolStatus` | `AVAILABLE` | `AVAILABLE` or `REQUIRES_CONFIG`; whether the dashboard shows the tool as ready or needing setup |
| `setup_type` | `SetupType` | `NONE` | `NONE`, `API_KEY`, `OAUTH`, or `SPECIAL`; which setup flow the dashboard shows |
| `config_fields` | list of `ConfigField` | `None` | Constructor parameters configurable through the dashboard (see [ConfigField](#configfield)) |
| `agent_override_fields` | list of `ConfigField` | `None` | Constructor parameters an agent can set in its own `tools:` entry but `defaults.tools` cannot |
| `dependencies` | list of strings | `None` | Python packages the tool requires (see [Dependencies](#dependencies)) |
| `docs_url` | string | `None` | Link to external documentation |
| `icon` | string | `None` | Dashboard icon name, such as `"FaGoogle"` or `"Home"` |
| `icon_color` | string | `None` | Tailwind color class for the icon, such as `"text-blue-500"` |
| `helper_text` | string | `None` | Markdown help shown in the dashboard setup panel |
| `auth_provider` | string | `None` | OAuth provider ID for OAuth-backed tools (see [OAuth providers](#oauth-providers)) |
| `oauth_fallback_fields` | tuple of strings | `()` | `config_fields` names that let the dashboard offer manual credentials as an alternative to OAuth and count the tool as configured once they are all set; requires `setup_type=OAUTH`; list token-named fields such as `access_token` in the provider's `tool_config_oauth_fallback_fields` too |
| `managed_init_args` | tuple of `ToolManagedInitArg` | `()` | MindRoom-managed values passed to the constructor (see [Managed init args](#managed-init-args)) |
| `default_execution_target` | `ToolExecutionTarget` | `PRIMARY` | Default location, `PRIMARY` or `WORKER`; worker routing configuration can override it |
| `requires_primary_runtime` | bool | `False` | Never route the tool to a worker, even when `worker_tools` lists it |
| `consumes_workspace_paths` | bool | `False` | The tool opens workspace files by path; when it runs in a worker, attachments are saved to the worker workspace so the tool can open them |
| `requires_room_context` | bool | `False` | The tool needs a live Matrix room, so it is hidden where none exists, such as the MCP gateway, the OpenAI-compatible API, and room-less runs, and it always runs in the primary runtime |
| `executes_code` | bool | `False` | The tool runs arbitrary programs; MindRoom warns at startup when such a tool runs in a worker while the same agent keeps unconfined tools in the primary process |
| `supports_toolkit_filters` | bool | `True` | Accept the [`include_tools` and `exclude_tools`](https://docs.mindroom.chat/tools/#filtering-toolkit-functions) inline overrides |
| `worker_inert_agent_functions` | tuple of strings | `()` | Functions whose injected `agent` parameter is unused and can receive `None` in a worker |

Set `requires_primary_runtime=True` when the toolkit depends on primary-process services, keeps state or an open resource between calls, or reads or changes an injected `Agent`, `Team`, or `RunContext`.
A worker builds a fresh toolkit for every call and accepts only JSON values and paths as arguments.
If an SDK function accepts an `agent` parameter but never uses it, list it in `worker_inert_agent_functions` instead; never do this for functions that depend on agent identity, configuration, or session state.
`requires_room_context` and `requires_primary_runtime` describe where a tool can run; they do not grant or replace tool authorization.

### Dependencies

Plugin tool dependencies are never installed automatically.
When a listed package is missing, the tool fails to load with `Missing dependencies for tool '<name>': <packages>` instead of failing mid-conversation.
Document the dependencies in the plugin README so users can install them, for example `pip install openviking-client aiohttp`.
Built-in tools install their own dependencies as described in [Automatic Dependency Installation](https://docs.mindroom.chat/tools/#automatic-dependency-installation).

### ConfigField

Each `ConfigField` describes one constructor parameter that users can set through the dashboard or credentials store.

| Field | Type | Default | Description |
| --- | --- | --- | --- |
| `name` | string | *required* | Constructor keyword argument, such as `"api_key"` |
| `label` | string | *required* | Label shown in the dashboard |
| `type` | string | `"text"` | `text`, `password`, `url`, `number`, `boolean`, `select`, or `string[]` |
| `required` | bool | `True` | Whether the field must be set before the tool can be used |
| `default` | any | `None` | Initial value in the dashboard form |
| `placeholder` | string | `None` | Placeholder text in the input |
| `description` | string | `None` | Help text for the field |
| `options` | list | `None` | For `select`: a list of `{"label": "...", "value": "..."}` dicts |
| `validation` | dict | `None` | Validation rules such as min, max, or pattern |
| `authored_override` | bool | `True` | Set to `False` to forbid setting the field inline in `config.yaml`; `password` fields are never allowed inline (see [Per-Agent Tool Configuration](https://docs.mindroom.chat/tools/#per-agent-tool-configuration)) |

A `ConfigField` `default` only pre-fills the dashboard form and is not passed to the constructor.
When no value is stored or configured, the constructor's Python default applies, so give optional parameters matching defaults, such as `units: str = "metric"` below.

```python
from mindroom.tool_system.declarations import (
    ConfigField,
    SetupType,
    ToolCategory,
    ToolFileAccess,
    ToolStatus,
)
from mindroom.tool_system.registration import register_tool_with_metadata

@register_tool_with_metadata(
    name="weather",
    file_access=ToolFileAccess.NONE,
    display_name="Weather",
    description="Get current weather data",
    category=ToolCategory.INFORMATION,
    status=ToolStatus.REQUIRES_CONFIG,
    setup_type=SetupType.API_KEY,
    config_fields=[
        ConfigField(name="api_key", label="API Key", type="password"),
        ConfigField(
            name="units",
            label="Units",
            type="select",
            required=False,
            default="metric",
            options=[
                {"label": "Metric (°C)", "value": "metric"},
                {"label": "Imperial (°F)", "value": "imperial"},
            ],
        ),
    ],
)
def weather_tools() -> type[Toolkit]:
    ...
```

### Managed init args

Declare MindRoom-managed constructor values with `managed_init_args`.
Only declared values are passed; MindRoom does not detect them from parameter names.

| Value | Constructor kwarg | Description |
| --- | --- | --- |
| `RUNTIME_PATHS` | `runtime_paths` | Storage paths, environment values, and data directory access |
| `CREDENTIALS_MANAGER` | `credentials_manager` | Read and write the per-tool credentials store |
| `RUNTIME_CONFIG` | `runtime_config` | The active MindRoom config, when available |
| `AGENT_NAME` | `agent_name` | The constructing agent's name, when available |
| `FILE_ACCESS` | `file_access` | The constructing agent's effective `file_access` setting |
| `WORKER_TARGET` | `worker_target` | Resolved worker routing context (scope, execution identity, worker key) |
| `TOOL_OUTPUT_WORKSPACE_ROOT` | `tool_output_workspace_root` | Workspace root used for managed tool-output saves |
| `WORKER_TOOLS_OVERRIDE` | `worker_tools_override` | Effective worker-routed tool override |
| `CURRENT_ROOM_ID` | `current_room_id` | Active Matrix room ID when available |
| `AGENT_STATE_ROOT` | `agent_state_root` | The constructing agent's state root in the primary runtime, requester-scoped for private agents; `None` inside workers |

```python
from agno.tools import Toolkit
from mindroom.tool_system.declarations import ToolCategory, ToolFileAccess, ToolManagedInitArg
from mindroom.tool_system.registration import register_tool_with_metadata


@register_tool_with_metadata(
    name="needs_runtime",
    file_access=ToolFileAccess.NONE,
    display_name="Needs Runtime",
    description="Example tool that needs runtime paths",
    category=ToolCategory.DEVELOPMENT,
    managed_init_args=(ToolManagedInitArg.RUNTIME_PATHS,),
)
def needs_runtime_tools() -> type[Toolkit]:
    class NeedsRuntimeTools(Toolkit):
        def __init__(self, *, runtime_paths):
            self.runtime_paths = runtime_paths
            super().__init__(name="needs_runtime", tools=[])

    return NeedsRuntimeTools
```

### Tool runtime context

Inside a Matrix-connected agent run, `mindroom.tool_system.runtime_context.get_tool_runtime_context()` returns the current `ToolRuntimeContext`, or `None` outside one.
It carries `room_id`, `thread_id`, `resolved_thread_id`, `requester_id`, `agent_name`, the Matrix client, the active config, and runtime paths.
`thread_id` is the thread the inbound message named, while `resolved_thread_id` is the conversation thread after plain replies are resolved to their thread.

### Dispatch-time worker targets

A tool that builds other registered tools while handling a call, for example a sub-toolkit from `get_tool_by_name`, should use the same worker target as agent toolkit construction so scoped credentials and OAuth MCP sessions resolve identically:

```python
from mindroom.tool_system.runtime_context import get_tool_runtime_context

context = get_tool_runtime_context()
worker_target = context.resolve_worker_target()
```

`resolve_worker_target()` raises `ValueError` in team and router dispatches, which have no single agent scope; catch it if the tool can run inside a team.

## OAuth providers

An OAuth module defines providers, and MindRoom core handles the connect, callback, credential storage, status, and disconnect flows described in the [OAuth framework](https://docs.mindroom.chat/oauth-framework/#oauth-framework).
Declare the module in the manifest:

```json
{
  "name": "drive-plugin",
  "tools_module": "tools.py",
  "oauth_module": "oauth.py"
}
```

Then define `register_oauth_providers(settings, runtime_paths)`, which receives the plugin entry's `settings`:

```python
from __future__ import annotations

from mindroom.oauth import OAuthProvider


def register_oauth_providers(settings, runtime_paths):
    del runtime_paths
    return [
        OAuthProvider(
            id="acme_drive",
            display_name="Acme Drive",
            authorization_url="https://accounts.acme.example/oauth/authorize",
            token_url="https://accounts.acme.example/oauth/token",
            scopes=("files.read",),
            credential_service="acme_drive_oauth",
            tool_config_service="acme_drive",
            client_config_services=(
                settings.get("client_config_service", "acme_drive_oauth_client"),
            ),
            allowed_email_domains=tuple(settings.get("allowed_email_domains", [])),
            allowed_hosted_domains=tuple(settings.get("allowed_hosted_domains", [])),
        ),
    ]
```

The [OAuth framework](https://docs.mindroom.chat/oauth-framework/#oauth-framework) owns the service naming rules and the PKCE option.

- `tool_config_service` is optional and holds the tool's editable dashboard settings, such as size limits or capability toggles, separately from the tokens.
- `allowed_email_domains` and `allowed_hosted_domains` restrict which accounts may connect; read them from plugin `settings` so each deployment sets its own.

Never write tokens or client secrets to `config.yaml`, prompt files, logs, or tool responses.

Providers that publish protected-resource metadata can discover their endpoints and optionally register a client automatically:

```python
from mindroom.oauth import OAuthDiscoveryConfig, OAuthProvider, oauth_runtime_bootstrapper

provider = OAuthProvider(
    id="acme_drive",
    display_name="Acme Drive",
    authorization_url="",
    token_url="",
    scopes=("files.read",),
    credential_service="acme_drive_oauth",
    client_config_services=("acme_drive_oauth_client",),
    token_endpoint_auth_method="none",
    pkce_code_challenge_method="S256",
    runtime_bootstrapper=oauth_runtime_bootstrapper(
        OAuthDiscoveryConfig(
            resource="https://api.acme.example",
            token_endpoint_auth_method="none",
            pkce_code_challenge_method="S256",
        ),
    ),
)
```

OAuth-backed tools set `setup_type=SetupType.OAUTH` and `auth_provider="<provider_id>"`.
When credentials are missing, return an instruction with a browser-openable connect link so the user can connect and retry the request.
Build the link with `oauth_connect_url(provider, runtime_paths, worker_target=...)` and the message with `build_oauth_connect_instruction(provider, connect_url)`, both from `mindroom.oauth.service`.

### Additional Google workspaces

Use `GoogleWorkspaceConfig` to add another Google account alongside the built-in Google tools.
Each workspace gets its own OAuth providers, stored connections, Connections page cards, and prefixed tool and function names, while reusing the built-in Google tool implementations.

One module can register both the tools and the OAuth providers:

```json
{
  "name": "google-workspaces",
  "tools_module": "workspaces.py",
  "oauth_module": "workspaces.py"
}
```

```python
# workspaces.py
from mindroom.tool_system.google_workspaces import (
    GoogleWorkspaceConfig,
    google_workspace_oauth_providers,
    register_google_workspace_tools,
)

WORKSPACES = (
    GoogleWorkspaceConfig(
        name="secondary",
        display_name="Secondary",
        client_config_service="secondary_google_oauth_client",
        allowed_hosted_domains=("secondary.example",),
        services=("gmail",),
    ),
)

for workspace in WORKSPACES:
    register_google_workspace_tools(workspace)


def register_oauth_providers(settings, runtime_paths):
    return tuple(
        provider
        for workspace in WORKSPACES
        for provider in google_workspace_oauth_providers(workspace)
    )
```

| Field | Description |
| --- | --- |
| `name` | Prefix for tool, provider, and function names: a lowercase letter followed by up to 15 lowercase letters, digits, or `_`; keep it stable because it identifies stored connections |
| `display_name` | Label prefix in the dashboard, such as **Secondary Gmail** |
| `client_config_service` | Credential service holding the workspace's OAuth client; must end with `_oauth_client` |
| `allowed_hosted_domains` | At least one Google hosted domain; signing in with a personal account or another organization's account fails without saving credentials |
| `services` | Any of `gmail`, `google_calendar`, `google_drive`, `google_docs`, `google_sheets`, and `google_tasks`, without duplicates; defaults to all six |

To set up a workspace:

1. Provision its OAuth client through a [shared credential seed](https://docs.mindroom.chat/oauth-framework/#credential-seeds) with `client_id` and `client_secret` under the `client_config_service`.
2. For each service, enable the matching Google API and register its callback on that client, such as `https://your-host/api/oauth/secondary_google_gmail/callback`.
3. Enable the plugin and add the prefixed tools, such as `secondary_gmail` beside `gmail`, to the agent's tools.

When a workspace tool needs a login, it returns its own chat authorization link.
Workspace tools never fall back to the default Google client or a global service account, and like the built-in Google tools they always run in the primary runtime.
Functions are prefixed, such as `secondary_get_latest_emails` and `secondary_send_email`, so approval rules, script-tool allowlists, and function filters must use those names; a rule for `send_email` does not match `secondary_send_email`.

### Additional Atlassian connections

Use `AtlassianConnectionConfig` the same way to add another Atlassian Cloud site alongside the built-in `atlassian` tool.
See [Atlassian Cloud](https://docs.mindroom.chat/tools/atlassian/#add-more-connections) for the plugin example, fields, and callback setup.

## MCP via plugins (advanced)

Configure MCP servers directly in `config.yaml` as described in [MCP](https://docs.mindroom.chat/mcp/).
Use a plugin only when you need a custom wrapper around Agno `MCPTools`:

```python
from agno.tools.mcp import MCPTools
from mindroom.tool_system.declarations import (
    SetupType,
    ToolCategory,
    ToolFileAccess,
    ToolStatus,
)
from mindroom.tool_system.registration import register_tool_with_metadata


class FilesystemMCPTools(MCPTools):
    def __init__(self, **kwargs):
        super().__init__(
            command="npx -y @modelcontextprotocol/server-filesystem /path/to/dir",
            **kwargs,
        )


@register_tool_with_metadata(
    name="mcp_filesystem",
    # The filesystem server reads local paths that file_access cannot confine.
    file_access=ToolFileAccess.UNCONFINED,
    display_name="MCP Filesystem",
    description="Tools from an MCP filesystem server",
    category=ToolCategory.DEVELOPMENT,
    status=ToolStatus.AVAILABLE,
    setup_type=SetupType.NONE,
)
def mcp_filesystem_tools():
    return FilesystemMCPTools
```

Add the plugin and assign `mcp_filesystem` to agents as in the [minimal example](#minimal-example).
MindRoom connects and disconnects the MCP session around each agent run.

## Plugin skills

Each directory listed in the manifest `skills` array becomes a skill search root, and each skill subdirectory needs a `SKILL.md`.
See [Skills](https://docs.mindroom.chat/skills/) for the `SKILL.md` format, precedence, and the per-agent `skills:` allowlist that plugin skills also require.

## Hooks

Plugins can ship typed event hooks for message enrichment, response transformation, lifecycle observation, tool-call gating, reactions, schedules, and custom events.
See [Hooks](https://docs.mindroom.chat/hooks/) for the `@hook` decorator, events, execution modes, timeouts and errors, the hook context and Matrix helpers, and testing.

# Hooks

Hooks are async functions in a [plugin](https://docs.mindroom.chat/plugins/) that run when MindRoom events happen.
Use them to add context to prompts, rewrite or suppress replies, allow or decline tool calls, and react to messages, reactions, schedules, room membership, sessions, compaction, startup, shutdown, and config reloads.
Each hook has a timeout, and a failing hook is logged without crashing the bot.
The [Plugins](https://docs.mindroom.chat/plugins/) page covers manifests, `plugins:` entries, installation, and hot reload.

## Quick start

Create a plugin directory with a manifest and a hook:

```
plugins/location-context/
  mindroom.plugin.json
  plugin.py
```

```json
{"name": "location-context", "tools_module": "plugin.py"}
```

```python
# plugin.py
from mindroom.hooks import hook


@hook("message:enrich", priority=20)
async def enrich_with_location(ctx):
    location = await fetch_location(ctx.settings["dawarich_url"])
    if location:
        ctx.add_metadata("location", f"User is at {location}", persist=False)
```

```yaml
# config.yaml
plugins:
  - path: ./plugins/location-context
    settings:
      dawarich_url: http://dawarich.local
```

Whenever an agent is about to answer a message, this hook adds the user's location to that turn's context, and `persist=False` keeps it out of session history.

MindRoom finds `@hook` functions in the manifest's `hooks_module`, or in `tools_module` when `hooks_module` is omitted; see [Manifest format](https://docs.mindroom.chat/plugins/#manifest-format).

## Configure hooks per deployment

Plugin `settings` reach every hook in that plugin as `ctx.settings`.
The `hooks:` map disables a hook or overrides its `priority` or `timeout_ms` by hook name, without changing code:

```yaml
plugins:
  - path: ./plugins/personal-context
    settings:
      dawarich_url: http://dawarich.local
      weather_api_key: ${OPENWEATHER_API_KEY}
    hooks:
      enrich_with_weather:
        enabled: false
      enrich_with_location:
        priority: 10
        timeout_ms: 500
```

A per-hook override takes precedence over the value in the `@hook` decorator.
[Entry formats](https://docs.mindroom.chat/plugins/#entry-formats) lists the plugin entry and override fields.
If a name under `hooks:` matches no hook in that plugin, MindRoom logs a warning and ignores the override.

## The `@hook` decorator

```python
from mindroom.hooks import hook


@hook(
    "message:enrich",
    name="enrich_weather",       # Hook name (defaults to function name)
    priority=20,                 # Lower runs first (default: 100)
    timeout_ms=500,              # Override default timeout for this event
    agents=["code", "research"], # Only run for these agents
    rooms=["!room:localhost"],   # Only run in these rooms
)
async def enrich_weather(ctx):
    ...
```

| Parameter | Type | Default | Description |
| --- | --- | --- | --- |
| `event` | `str` | *required* | Event name to listen for |
| `name` | `str` | function name | Hook name used by `hooks:` overrides; must be unique within a plugin, and duplicates are skipped with a warning |
| `priority` | `int` | `100` | Execution order; lower values run first |
| `timeout_ms` | `int \| None` | per-event default | Override the event's default timeout |
| `required` | `bool` | `False` | For `agent:started` only: the bot does not start if this hook fails or times out |
| `agents` | `Iterable[str] \| None` | `None` (all) | Only fire for these agent or team names |
| `rooms` | `Iterable[str] \| None` | `None` (all) | Only fire for these room IDs |

Hook functions must be `async`.
Hooks with equal priority run in `plugins:` list order, then in source order.
A hook with `agents` set never fires for events that carry no agent: `schedule:fired`, `reaction:received`, `config:reloaded`, and custom events.
A hook with `rooms` set never fires for `config:reloaded` or for custom events emitted outside a room.

## Execution modes

Each event runs its hooks in one of four modes.

### Observer

Hooks run one after another and only observe, except for the event's mutable fields such as `suppress`.
A failing hook is skipped and the next hook still runs; side effects it already made are not undone.
Custom events and most built-in events are observers.

```python
from mindroom.hooks import hook


@hook("message:received")
async def log_inbound(ctx):
    ctx.logger.info("Message received", body=ctx.envelope.body)


@hook("message:after_response")
async def track_response(ctx):
    save_metric(ctx.result.response_event_id, ctx.result.delivery_kind)
```

### Collector

Used by `message:enrich` and `system:enrich`.
Hooks run concurrently, at most 10 at a time, so total latency is roughly that of the slowest hook rather than the sum.
Each hook contributes `EnrichmentItem` entries, and a failing or timed-out hook loses only its own items.
Items are merged in hook order.

### Transformer

Used by `message:before_response` and `message:final_response_transform`.
Hooks run one after another on a mutable `ctx.draft`, and each may replace `ctx.draft.response_text`.
Only `message:before_response` may suppress the reply.
When a `message:before_response` hook fails, changes it made to the draft before failing are kept.
When a `message:final_response_transform` hook fails, its changes are discarded and the next hook receives the previous draft.

```python
from mindroom.hooks import hook


@hook("message:before_response", priority=10)
async def add_disclaimer(ctx):
    ctx.draft.response_text += "\n\n*Generated automatically.*"


@hook("message:before_response", priority=20)
async def redact_secrets(ctx):
    ctx.draft.response_text = scrub_api_keys(ctx.draft.response_text)


@hook("message:final_response_transform", priority=10)
async def add_links(ctx):
    ctx.draft.response_text = linkify_references(ctx.draft.response_text)
```

### Gate

Used by `tool:before_call`.
Hooks run one after another, and the first hook that calls `ctx.decline(reason)` stops the chain.
The tool does not run, and the model receives the reason in the tool result.
A gate hook that fails or times out does not block the tool call.

```python
from mindroom.hooks import hook


@hook("tool:before_call", priority=10)
async def block_secret_reads(ctx):
    if ctx.tool_name == "read_file" and "secret" in str(ctx.arguments.get("path", "")):
        ctx.decline("Sensitive files must stay unread.")
```

## Built-in events

| Event | Mode | Context type | When it fires | Mutable fields | Default timeout (ms) |
| --- | --- | --- | --- | --- | --- |
| `message:received` | Observer | `MessageReceivedContext` | After authorization, deduplication, and voice transcription; before command parsing, routing, and attachment registration | `suppress` | 15000 |
| `message:enrich` | Collector | `MessageEnrichContext` | After routing picks the agent or team; before AI generation | `add_metadata()` | 2000 |
| `system:enrich` | Collector | `SystemEnrichContext` | After `message:enrich`; before AI generation | `add_instruction()` | 2000 |
| `message:before_response` | Transformer | `BeforeResponseContext` | After AI generation; before the reply first becomes visible in Matrix | `draft.response_text`, `draft.suppress` | 200 |
| `message:final_response_transform` | Transformer | `FinalResponseTransformContext` | After a streamed reply finishes successfully, before its final edit | `draft.response_text` | 200 |
| `message:after_response` | Observer | `AfterResponseContext` | After the final Matrix send or edit | None | 3000 |
| `message:cancelled` | Observer | `CancelledResponseContext` | After a response ends any way other than clean success: cancellation, interruption, suppression, or delivery failure | None | 3000 |
| `agent:started` | Observer | `AgentLifecycleContext` | After the bot logs in, sets presence, and registers callbacks | None | 5000 |
| `bot:ready` | Observer | `AgentLifecycleContext` | After the bot finishes room joins and initial sync | None | 5000 |
| `agent:stopped` | Observer | `AgentLifecycleContext` | During orderly shutdown | None | 5000 |
| `session:started` | Observer | `SessionHookContext` | Once when a response creates a new persisted session | None | 5000 |
| `compaction:before` | Observer | `CompactionHookContext` | After the messages to compact are chosen; before the compacted session is saved | None | 15000 |
| `compaction:after` | Observer | `CompactionHookContext` | After compaction is saved, with token counts and the generated summary | None | 5000 |
| `schedule:fired` | Observer | `ScheduleFiredContext` | Before a scheduled task posts its message | `message_text`, `suppress` | 1000 |
| `reaction:received` | Observer | `ReactionReceivedContext` | For reactions not consumed by a built-in handler (tool approval, stop, config confirmation, interactive question) | None | 500 |
| `room:member_joined` | Observer | `RoomMemberJoinedContext` | On the router, after a human joins a room | None | 3000 |
| `room:member_left` | Observer | `RoomMemberLeftContext` | On the router, after a human leaves a room | None | 3000 |
| `config:reloaded` | Observer | `ConfigReloadedContext` | After a new config is applied and affected entities restart | None | 5000 |
| `tool:before_call` | Gate | `ToolBeforeCallContext` | Immediately before each tool call | `decline()` | 200 |
| `tool:after_call` | Observer | `ToolAfterCallContext` | After each tool call returns, raises, or is declined | None | 300 |

Custom events default to 1000 ms.

### Event notes

- **Replies**: `message:before_response` runs only for AI-generated replies, before any reply text is visible.
  Streamed replies usually show text before they finish, so this hook does not see them; set `defaults.enable_streaming: false` when every reply must pass through it, as with the disclaimer and redaction examples above.
  Once a streamed reply is visible it cannot be suppressed; use `message:final_response_transform` for one text-only replacement after a streamed reply succeeds.
  `message:final_response_transform` cannot suppress, redact, or delete the reply, or change its metadata.
- **`message:cancelled`**: `ctx.info.failure_reason` tells cancellation, interruption, suppression, and delivery failure apart.
- **`agent:started` with `required=True`**: use a short, separate required hook for initialization the bot must not run without, and keep optional work such as backfill or welcome messages in ordinary hooks.
  A failure or timeout stops the remaining startup hooks and fails that bot's startup like any other startup error.
- **Sessions and compaction**: `ctx.scope.key` identifies the history scope, and `ctx.session_id` identifies the persisted session within it.
  In compaction hooks, `ctx.messages` holds the raw `agno.models.message.Message` objects being compacted, unsanitized, including attachments, media, tool calls and arguments, provider metadata, citations, reasoning, and metrics.
- **`schedule:fired`**: `ctx.thread_id` is the thread the message will be delivered to, which can differ from `ctx.workflow.thread_id` when the task starts a new thread or posts at room level.
  The event fires for visible and silent schedules.
  Set `ctx.suppress = True` to cancel the fire; an empty or whitespace-only `ctx.message_text` produces a visible scheduled-task failure notice instead.
  The hook can run more than once for the same occurrence, so side-effecting hooks should use `ctx.correlation_id`, which is stable per occurrence, as an idempotency key.
- **Room membership**: both events exclude configured agents, the internal `mindroom_user`, and `bot_accounts`.
  `room:member_joined` fires only for live joins, not for members already present at startup or for profile changes; `room:member_left` fires when a joined user leaves on their own, not when they are kicked or banned.
  `room:member_left` reports the `display_name` and `avatar_url` the user had while joined.
  Either event can be delivered more than once for the same join or leave after a restart, so handlers that create rooms, send invites, or write state must be idempotent.

## Message enrichment (`message:enrich`)

`message:enrich` adds context to the user's message for the current turn.
Add items with `ctx.add_metadata()`:

```python
@hook("message:enrich")
async def enrich_with_profile(ctx):
    profile = load_profile(ctx.envelope.requester_id)
    ctx.add_metadata(
        "user_profile",
        f"Name: {profile.name}, Timezone: {profile.tz}",
        cache_policy="stable",
    )
```

Hooks can also return an `EnrichmentItem` or a list of them:

```python
from mindroom.hooks import EnrichmentItem, hook


@hook("message:enrich")
async def enrich_with_time(ctx):
    return EnrichmentItem(key="time", text=f"Current time: {now()}", persist=False)
```

`EnrichmentItem` fields are `key`, `text`, `cache_policy` (`"volatile"`, the default, for values that change often such as weather or time, or `"stable"` for values that rarely change such as a profile or timezone), `persist` (default `True`), and `minimal_required` (default `False`).

Persisted items are appended to the user message and kept in session history, so later turns see them exactly as rendered:

```xml
<mindroom_message_context>
<item key="user_profile" cache_policy="stable">Prefers concise answers, timezone Europe/Amsterdam</item>
</mindroom_message_context>
```

Items with `persist=False` go into a separate context message for the current turn only.
Use `persist=False` for live data such as location or weather, so session history and provider prompt caches stay reusable.
Use stable keys and deterministic text for persisted items.

## System prompt enrichment (`system:enrich`)

`system:enrich` runs after `message:enrich` and adds instructions to the agent's system prompt for the current turn.
For teams, the same instructions go to the team and to each member agent.
Add items with `ctx.add_instruction()`, or return `EnrichmentItem` objects as with `message:enrich`:

```python
from mindroom.hooks import SystemEnrichContext, hook


@hook("system:enrich", priority=40)
async def inject_room_tags(ctx: SystemEnrichContext) -> None:
    tags = await get_room_tags(ctx.envelope.room_id)
    if tags:
        ctx.add_instruction(
            "room_tags",
            f"Existing thread tags in this room: {', '.join(sorted(tags))}",
            cache_policy="stable",
        )
```

Items are rendered in a `<mindroom_system_context>` block, with `"stable"` items first and `"volatile"` items last, each group sorted by key.
`persist` has no effect here because system enrichment always applies to the current turn only.
Changing the system prompt invalidates more of the provider's prompt cache, so use `system:enrich` only for room- or turn-scoped instructions that need system-level priority, and `message:enrich` with `persist=False` for live data.
The key `matrix_message_target` is reserved; MindRoom replaces hook items that use it.

## Enrichment in minimal mode

In [minimal mode](https://docs.mindroom.chat/tools/agent-cli/), system enrichment and non-persisted message enrichment are available to the agent through the CLI's enrichment context document instead of the prompt.
Return an `EnrichmentItem` with `minimal_required=True` when the item must stay in the prompt without a tool call; `ctx.add_metadata()` and `ctx.add_instruction()` cannot set this flag.
Persisted message enrichment and standard mode are unaffected.

```python
@hook("system:enrich")
async def required_instruction(ctx):
    return EnrichmentItem(
        key="required_policy",
        text="Use the approved project directory for all file changes.",
        minimal_required=True,
    )
```

## Custom events

Plugins can define their own namespaced events and emit them from tool code:

```python
from mindroom.hooks import hook


@hook("todo:item_completed")
async def audit_completion(ctx):
    append_jsonl(ctx.state_root / "events.jsonl", {"item_id": ctx.payload["item_id"]})
```

```python
from mindroom.tool_system.runtime_context import emit_custom_event

# Inside a tool method:
await emit_custom_event("my-plugin", "todo:item_completed", {"item_id": "123"})
```

- Event names must match `^[a-z0-9_.-]+(:[a-z0-9_.-]+)+$`, so they need at least one colon.
- The namespaces `message`, `system`, `agent`, `bot`, `compaction`, `schedule`, `reaction`, `room`, `config`, `session`, and `tool` are reserved.
- Custom events run as observers.
- Only code running during a tool call in the primary MindRoom process can emit them, which includes `tool:before_call` and `tool:after_call` hooks and custom-event hooks triggered by that call.
- Hooks running outside a tool call and tools running in workers cannot emit them.
- Emissions nested more than 3 levels deep are dropped with a warning.

## Errors and timeouts

Each hook runs under its timeout.
A timeout cannot interrupt blocking code such as a CPU-bound loop, so a hook stuck in one stalls MindRoom until it returns.
An exception or timeout is logged with the plugin and hook name and affects only that hook for that event, as described per mode under [Execution modes](#execution-modes), except for a required `agent:started` hook.
MindRoom does not retry a failed hook, so implement retries inside the hook if it needs them.
A failing hook is invoked again on the next matching event, with no quarantine or cooldown, so fixing the code under [hot reload](https://docs.mindroom.chat/plugins/#live-development-hot-reload) takes effect on the next event.

## Plugin state

`ctx.state_root` is a per-plugin directory at `<storage root>/plugins/<plugin name>/` (by default `mindroom_data/plugins/<plugin name>/`), created on first access.
Organizing it per room or per user is up to the plugin.

```python
import json

from mindroom.hooks import hook


@hook("reaction:received")
async def pin_message(ctx):
    if ctx.reaction_key != "\U0001f4cc":
        return
    pins_file = ctx.state_root / "pins.json"
    pins = json.loads(pins_file.read_text()) if pins_file.exists() else []
    pins.append({"room": ctx.room_id, "event": ctx.target_event_id})
    pins_file.write_text(json.dumps(pins))
```

## Context reference

### Base fields

All contexts except the two tool contexts include these fields:

| Field | Type | Description |
| --- | --- | --- |
| `event_name` | `str` | The event that triggered this hook |
| `plugin_name` | `str` | Name of the plugin owning this hook |
| `settings` | `dict[str, Any]` | Plugin `settings` from `config.yaml` |
| `config` | `Config` | Current MindRoom config (read-only) |
| `runtime_paths` | `RuntimePaths` | Storage paths and environment values |
| `logger` | `BoundLogger` | Plugin-scoped structured logger |
| `correlation_id` | `str` | Unique ID per inbound event |
| `runtime_started_at` | `float \| None` | Unix timestamp of the latest bot start, useful for ignoring plugin state recorded before it |
| `state_root` | `Path` | Plugin state directory |

`ToolBeforeCallContext` and `ToolAfterCallContext` have the same fields and helpers except `runtime_started_at` and `get_latest_agent_message_snapshot()`, and their `config` and `runtime_paths` can be `None`.

### Event-specific fields

| Context | Fields |
| --- | --- |
| `MessageReceivedContext` | `envelope`, `suppress` |
| `MessageEnrichContext`, `SystemEnrichContext` | `envelope`, `target_entity_name`, `target_member_names` (team members, or `None` for an agent) |
| `BeforeResponseContext`, `FinalResponseTransformContext` | `draft` |
| `AfterResponseContext` | `result` |
| `CancelledResponseContext` | `info` with `envelope`, `visible_response_event_id`, `response_kind`, `failure_reason` |
| `AgentLifecycleContext` | `entity_name`, `entity_type` (`"agent"`, `"team"`, or `"router"`), `rooms` (configured), `matrix_user_id`, `joined_room_ids`, `stop_reason` (`"restart"`, `"entity_removed"`, or `"shutdown"` on `agent:stopped`) |
| `SessionHookContext` | `agent_name`, `scope`, `session_id`, `room_id`, `thread_id` |
| `CompactionHookContext` | `agent_name`, `scope`, `session_id`, `room_id`, `thread_id`, `messages`, `token_count_before`, `token_count_after`, `compaction_summary` |
| `ScheduleFiredContext` | `task_id`, `workflow`, `room_id`, `thread_id`, `created_by`, `message_text`, `suppress` |
| `ReactionReceivedContext` | `room_id`, `event_id`, `sender_id`, `reaction_key`, `target_event_id`, `thread_id` |
| `RoomMemberJoinedContext`, `RoomMemberLeftContext` | `agent_name`, `room_id`, `event_id`, `user_id`, `sender_id`, `display_name`, `avatar_url`, `membership`, `prev_membership` |
| `ConfigReloadedContext` | `changed_entities`, `added_entities`, `removed_entities`, `plugin_changes` |
| `CustomEventContext` | `payload`, `source_plugin`, `room_id`, `thread_id`, `sender_id` |
| `ToolBeforeCallContext` | `tool_name`, `arguments`, `agent_name`, `room_id`, `thread_id`, `requester_id`, `session_id`, `declined`, `decline_reason`, `decline(reason)` |
| `ToolAfterCallContext` | `tool_name`, `arguments`, `agent_name`, `room_id`, `thread_id`, `requester_id`, `session_id`, `result`, `error`, `blocked`, `duration_ms` |

Tool hooks receive copies of `arguments`, and `tool:after_call` receives copies of `result` and `error`, so changing them does not affect the call.
`blocked` is true when the tool did not run, for example because a gate hook declined it.

### Message objects

```python
MessageEnvelope(
    source_event_id: str,
    target: MessageTarget,
    body: str,
    attachment_ids: tuple[str, ...],
    mentioned_agents: tuple[str, ...],
    agent_name: str,
    origin: TurnOrigin,
    hook_source: str | None,                 # "<plugin>:<event>" for hook-sent messages
    dispatch_policy_source_kind: str | None,
)
# Derived properties: room_id (from target), requester_id, sender_id, source_kind (from origin).
# target.source_thread_id is the raw inbound thread ID, target.resolved_thread_id the delivery thread,
# and target.session_id the conversation's persistence key.

TurnOrigin(
    transport_sender_id: str,
    requester_id: str,
    sender_entity_name: str | None,
    requester_entity_name: str | None,
    sender_kind: SenderKind,       # "user" or "managed_entity"
    requester_kind: SenderKind,
    intent: TurnIntent,            # see below
    source_kind: str,
    trust: TurnTrust,              # "external", "trusted_internal", or "trusted_user_relay"
)

ResponseDraft(
    response_text: str,
    response_kind: str,  # "ai", "team", "router", "system"
    tool_trace: list[ToolTraceEntry] | None,
    extra_content: dict[str, Any] | None,
    envelope: MessageEnvelope,
    suppress: bool = False,
)

FinalResponseDraft(
    response_text: str,
    response_kind: str,  # "ai", "team", "router", "system"
    envelope: MessageEnvelope,
)

ResponseResult(
    response_text: str,
    response_event_id: str,
    delivery_kind: str,  # "sent" or "edited"
    response_kind: str,
    envelope: MessageEnvelope,
)
```

`TurnIntent` is one of `"user_message"`, `"managed_message"`, `"router_handoff"`, `"router_notice"`, `"scheduled_fire"`, `"external_trigger"`, `"hook_message"`, `"hook_dispatch"`, or `"trusted_internal_relay"`.
`origin.may_dispatch_without_mention` is true for `hook_dispatch`, `external_trigger`, `router_handoff`, and `scheduled_fire` turns.
`origin.may_answer_interactive_prompt` is true only for human-requested `user_message`, `router_handoff`, and `trusted_internal_relay` turns.
`dispatch_policy_source_kind` is usually `None`; when it is `"active_thread_follow_up"`, `source_kind` still holds the original modality such as `"message"` or `"voice"`.
`TurnOrigin`, `TurnIntent`, `SenderKind`, `TurnTrust`, `ACTIVE_THREAD_FOLLOW_UP_SOURCE_KIND`, and `TRUSTED_INTERNAL_RELAY_SOURCE_KIND` are exported from `mindroom.hooks` for comparisons.

### Sending messages

**`await ctx.send_message(room_id, text, *, thread_id=None, extra_content=None, trigger_dispatch=False)`** sends a Matrix message and returns its event ID, or `None` when no sender is available.

- For contexts that come from a message, the original human requester is carried along, so routing, permissions, and memory attribution use that human rather than the relaying bot.
- In `schedule:fired`, omitting `thread_id` posts to `ctx.thread_id`, while an explicit `thread_id=None` posts at room level.
- A plain send triggers agents only when it would as a normal message, for example when it mentions an agent.
- `trigger_dispatch=True` sends the message as `source_kind` `"hook_dispatch"`, which agents may answer without a mention, subject to normal permissions and routing.
- A hook-sent message passes through `message:received` once, skipping the plugin that sent it if the send came from `message:received`.
  Messages sent by hooks in response to hook-sent messages are still posted, but they skip `message:received` and do not trigger commands or agent replies, which prevents feedback loops.

### Room state and recent messages

**`await ctx.query_room_state(room_id, event_type, state_key=None)`** reads Matrix room state.
With a `state_key`, it returns that event's content `dict`, or `None` when the event is missing or Matrix returns an error.
Without one, it returns a `{state_key: content}` dict for every event of that type, or `None` on a Matrix error.

**`await ctx.put_room_state(room_id, event_type, state_key, content)`** writes one room state event and returns `True` on success or `False` on a Matrix error.

Both return `None` or `False` when no Matrix client is available.
When the current bot gets a Matrix error, MindRoom retries the call as the router.
Network errors raise in the hook.

**`await ctx.get_latest_agent_message_snapshot(room_id, sender, *, thread_id=None)`** returns the latest visible message from `sender` in the given thread, with `content` and `origin_server_ts`.
`thread_id=None` reads only the unthreaded room conversation and ignores threaded replies.
It reads local conversation data without contacting the homeserver and looks only at the 50 most recent messages in that scope.
It returns `None` when the sender has no visible message in that window or no reader is available.

### Matrix admin

`ctx.matrix_admin` is a limited admin interface backed by the router account, or `None` when unavailable.
Hooks never get the raw Matrix client.

| Method | Returns |
| --- | --- |
| `get_joined_rooms()` | The account's joined room IDs, or `None` on failure |
| `retain_room(room_id)` | Nothing; synchronous, see below |
| `resolve_alias(alias)` | Room ID, or `None` if the alias does not exist |
| `create_room(name=..., alias_localpart=..., topic=..., power_user_ids=...)` | New room ID, or `None` on failure |
| `invite_user(room_id, user_id)` | `bool` success |
| `force_join_user(room_id, user_id)` | `bool` success; joins a local user through the homeserver admin API |
| `kick_user(room_id, user_id, reason=None)` | `bool` success |
| `get_room_members(room_id)` | Set of joined user IDs, or `None` when the room cannot be read |
| `get_profile_avatar(user_id)` | Avatar content URI, or `None` |
| `get_room_state_event(room_id, event_type, state_key)` | `(True, content)` when found, `(True, None)` when Matrix confirms it is missing, `(False, None)` on other errors |
| `add_room_to_space(space_room_id, room_id)` | `bool` success |
| `put_room_state(room_id, event_type, state_key, content)` | `bool` success |

Network errors raise in the hook.
Rooms created with `create_room` stay with the creating bot across room cleanup and restarts.
When reconciling a room the plugin created earlier, verify the bot is still a member and call `retain_room(room_id)` to keep it the same way; it changes no Matrix membership and raises `OSError` if it cannot save.
Retention applies only to bots whose [`accept_invites`](https://docs.mindroom.chat/configuration/agents/) policy is enabled.

## Testing

Hook functions are plain async functions, so a test can await one with a stub context:

```python
from types import SimpleNamespace

import pytest

from mindroom.hooks import hook


@hook("message:received")
async def suppress_spam(ctx):
    if "spam" in ctx.envelope.body:
        ctx.suppress = True


@pytest.mark.asyncio
async def test_suppress_spam():
    ctx = SimpleNamespace(envelope=SimpleNamespace(body="buy spam now"), suppress=False)
    await suppress_spam(ctx)
    assert ctx.suppress is True
```

## Limits

- Hooks cannot replace core routing, authorization, or deduplication.
- Hooks never receive the Matrix client directly.

# External Triggers

External triggers let a process outside MindRoom wake an agent or team with one HTTP request, without keeping an agent turn alive while it waits.

Use them for watchers that detect a meaningful change, such as a campground site opening or a Git branch moving, and for background tasks that should report back when they finish.

A watcher sends a signed event to `POST /api/triggers/<trigger_id>`, and MindRoom posts a Matrix message that mentions the target agent or team in the target room.
The message contains the event's `title` and `message`, followed by its `data` as a JSON block.
MindRoom does not run watcher code or poll external systems; the watcher decides when to send.

There are two kinds of triggers:

- Reusable signed triggers, created with the `external_trigger_manager` tool, authenticate each request with an Ed25519 signature.
- Single-use [agent callbacks](#agent-callbacks), created with the `callback_manager` tool, give a background task a Bash/curl script that wakes the originating agent and thread once.

Triggers are created and managed by tool calls in a live Matrix conversation, not in `config.yaml`.
Trigger records are stored in the primary runtime's control state, `MINDROOM_CONTROL_STATE_PATH` when set and `control_state/` under the storage root otherwise.
Agent workers and sandbox runners do not receive that path.

## Configuration

Add `external_trigger_manager` to agents that may create triggers.

```yaml
agents:
  ops:
    display_name: Ops
    role: Watch external systems and report actionable changes.
    model: default
    rooms: [lobby]
    tools:
      - external_trigger_manager

external_trigger_policy:
  enabled: true
  max_triggers_per_owner: 20
  admin_users:
    - "@admin:example.org"
```

`external_trigger_policy` is optional; every field has a default.

| Field | Type | Default | Valid values | Meaning |
| --- | --- | --- | --- | --- |
| `enabled` | bool | `true` | | When `false`, every trigger endpoint, including callbacks, answers not found, and `callback_manager` refuses to mint callbacks. |
| `default_replay_window_seconds` | int | `300` | 30 to 3600 | Signature age accepted by new signed triggers that do not set their own window. |
| `max_replay_window_seconds` | int | `3600` | 30 to 3600 | Cap on any trigger's replay window. |
| `default_max_body_bytes` | int | `65536` | 1024 to 262144 | Request body limit for new triggers that do not set their own. |
| `max_body_bytes` | int | `262144` | 1024 to 262144 | Cap on any trigger's body limit. |
| `max_triggers_per_owner` | int | `20` | 1 to 1000 | Trigger records one owner may hold, including unused callbacks. |
| `admin_users` | list of Matrix IDs | `[]` | | Trigger-only administrators. |

Each default must not exceed its matching cap.
Lowering a cap also limits existing triggers.

Top-level `administrators` and `admin_users` can list, enable, disable, rotate, and delete any owner's triggers, and can target other agents, teams, and rooms.
`admin_users` grants only this trigger authority, not wider administrator rights.
Both lists match canonical identities after `authorization.aliases` resolution.

To stop users from creating triggers without review, add [tool approval](https://docs.mindroom.chat/tool-approval/) rules for `create_trigger`, `rotate_trigger_key`, `disable_trigger`, and `delete_trigger`.

## Signed Trigger Setup Flow

1. Generate a signing key where the watcher runs.

   ```bash
   mindroom trigger keygen --private-key-file /etc/mindroom/triggers/campground.key
   ```

   The command prints `private_key`, `public_key`, and `public_key_fingerprint`.
   Keep the private key only in the watcher runtime.

2. In a Matrix conversation with the agent, give it the printed `public_key` and ask it to call `external_trigger_manager.create_trigger`.

   ```json
   {
     "trigger_id": "campground",
     "public_key": "BASE64_PUBLIC_KEY_FROM_KEYGEN",
     "key_id": "campground-main",
     "description": "Campground availability watcher",
     "allowed_kinds": ["campground.availability"],
     "replay_window_seconds": 300,
     "max_body_bytes": 65536
   }
   ```

   The tool returns the endpoint path `/api/triggers/<trigger_id>` and the public key fingerprint, never key material.

3. Have the watcher send events with `mindroom trigger send` and the same `key_id`, as shown in [Sending Events](#sending-events).

### `create_trigger` arguments

| Argument | Default | Meaning |
| --- | --- | --- |
| `trigger_id` | required | Endpoint name; ASCII letters, digits, `_`, and `-` only. |
| `public_key` | required | Ed25519 public key as raw base64, an OpenSSH `ssh-ed25519 ...` line, or PEM. |
| `key_id` | `default` | Key ID the sender must present. |
| `description` | `""` | Human-readable purpose. |
| `target_agent` | current agent | Agent or team to wake; only administrators can choose another. |
| `target_room_id` | current room | Room to post in; only administrators can choose another. |
| `target_thread_id` | none | Post every delivery into this existing thread. |
| `new_thread` | `false` | Post each delivery as a new room-level message so the agent answers in a fresh thread with a fresh session. |
| `allowed_kinds` | any | Accepted `kind` values; others are rejected. |
| `replay_window_seconds` | policy default | Maximum signature age, capped by policy. |
| `max_body_bytes` | policy default | Request body limit, capped by policy. |

`target_thread_id` and `new_thread` are mutually exclusive.
Without either, deliveries post to the room's main timeline.

The requester who asks for the trigger becomes its owner and must be a human user, not a bot account.
An administrator who creates a trigger for another agent or room still owns it.
Shared agents and teams can only be targeted in rooms already configured for them.
A [private agent](https://docs.mindroom.chat/configuration/agents/#private-instances) creating its own trigger may target its current room even when that room is not listed in `rooms`.
Triggers never create rooms or make agents join rooms.

### Managing triggers

The `external_trigger_manager` tool also provides these functions, available to the trigger owner and administrators:

- `list_triggers` lists the requester's triggers, or every trigger for administrators.
- `disable_trigger(trigger_id, enabled=False)` disables a trigger; pass `enabled=True` to re-enable it.
- `rotate_trigger_key(trigger_id, public_key, key_id)` replaces the signing key.
- `delete_trigger(trigger_id)` removes the trigger.

## Sending Events

Send a signed event when the watcher detects a real change.

```bash
mindroom trigger send campground \
  --url http://127.0.0.1:8765 \
  --key-file /etc/mindroom/triggers/campground.key \
  --key-id campground-main \
  --kind campground.availability \
  --event-id reserveamerica:yosemite:site-42:2026-07-04 \
  --title "Campground site opened" \
  --message "Site 42 is available for July 4." \
  --data-json '{"campground":"Yosemite","site":"42","date":"2026-07-04"}'
```

`--url` defaults to `MINDROOM_URL`, then `http://127.0.0.1:8765`.
See [`mindroom trigger send`](https://docs.mindroom.chat/cli/#trigger-send) for every option.
Use `--no-verify-tls` only for local development against a trusted endpoint.

The JSON request body has a required non-empty `kind` and `message`, and optional `event_id`, `title`, `thread_key`, and `data` object; other fields are rejected.

### Signing requests without the CLI

A watcher can sign requests itself with the Ed25519 private key.
Sign the UTF-8 string below, joined with `\n`, where `<path>` is the request path such as `/api/triggers/campground`, `<timestamp>` is Unix seconds, `<nonce>` is a fresh random string, and the last line is the lowercase hex SHA-256 of the exact body bytes.

```text
MINDROOM-TRIGGER-V1
POST
<path>
<timestamp>
<nonce>
<sha256-hex-of-body>
```

Send the base64 signature with the headers `X-MindRoom-Trigger-Key-Id`, `X-MindRoom-Trigger-Timestamp`, `X-MindRoom-Trigger-Nonce`, and `X-MindRoom-Trigger-Signature`.
The timestamp must not be in the future and must be within the trigger's replay window.

## Grouping Deliveries Into One Thread

On a `new_thread` trigger, every delivery opens a new thread.
That suits independent events but is noisy for a conversation that arrives as many events, such as replies in one support ticket.

Pass the same `thread_key` to group deliveries: the first delivery with a key opens a thread, and later deliveries with that key post into it, so the agent keeps one session for the whole conversation.

```bash
mindroom trigger send support-inbox \
  --key-file /etc/mindroom/triggers/support.key \
  --kind support.message \
  --event-id ticket-812:msg-3 \
  --thread-key ticket-812 \
  --message "Customer replied on ticket 812."
```

Choose a key that names the upstream conversation, not the message, such as a ticket ID, an upstream thread timestamp, or a direct-message channel ID.
A key stays bound to its thread for 7 days after its most recent delivery; after that, or after `rotate_trigger_key`, the next delivery with the key opens a new thread.
If two deliveries with a new key arrive together, one may receive `409 External trigger thread is being opened by another delivery`; retry it as a fresh signed request with the same `event_id` and it joins the thread.
`thread_key` has no effect on triggers with a fixed `target_thread_id` or without `new_thread`.

## Reusable Trigger Idempotency

Give the same external event the same `--event-id`, such as a reservation ID, Git commit SHA, release tag, webhook delivery ID, or a hash of the changed state.
After a successful delivery, a request with the same `event_id` is answered as a duplicate and posts nothing for the next 24 hours.
If `--event-id` is omitted, the CLI generates a random one, so repeated sends are not deduplicated.

To retry, send a fresh signed request with the same `event_id`; replaying identical request bytes and headers is rejected because each nonce is single-use.

`event_id`, `thread_key`, and the signature nonce are each limited to 256 bytes, so use short ASCII identifiers.
Each trigger holds at most 10,000 recent nonces, 10,000 recent `event_id` values, and 10,000 active `thread_key` values; beyond that, requests receive `429` until older entries expire.
Rotating a trigger's key or deleting the trigger forgets its delivered `event_id` values.

Deduplication requires every API process that accepts triggers to share one control-state filesystem.

## Delivery Requirements

A request is delivered only when all of these hold:

- The trigger exists, is enabled, and `external_trigger_policy.enabled` is true.
- The owner is still allowed to talk to the target agent in the target room.
- The owner, or one of the owner's `authorization.aliases`, is currently joined to the target room; bot accounts do not count.
- The router and the target agent or team are running and joined to the target room.

The triggered turn runs on behalf of the trigger owner, not the router.
For a private agent (`private.per`), the trigger wakes the owner's private instance, so a trigger created by `@alice:example.org` uses Alice's private state.
An administrator's trigger for a private agent wakes the administrator's private state.

## Troubleshooting Responses

A successful request returns `202` with `accepted: true`, the `event_id`, and the posted Matrix event ID, or `duplicate: true` when the `event_id` was already delivered.

| Status | Detail | Cause and fix |
| --- | --- | --- |
| `401` | `Invalid external trigger signature` | Wrong private key, `key_id` mismatch, missing signature headers, or a timestamp outside the replay window; check the watcher's clock. |
| `403` | `External trigger owner is not authorized for this target room` | The owner lost access to the target agent in that room. |
| `403` | `External trigger owner is not joined to the target room` | The owner left the room; rejoin it. |
| `404` | `External trigger not found` | Unknown, disabled, deleted, or consumed trigger, triggers disabled in policy, or a wrong callback token. |
| `409` | `External trigger nonce has already been used` | The same signed request was sent twice; sign a new one. |
| `409` | `External trigger event is already in progress` | Another request with this `event_id` is still being delivered. |
| `413` | `External trigger body exceeds configured limit` | Shrink the body or raise `max_body_bytes`. |
| `422` | `External trigger kind is not allowed` | `kind` is not in the trigger's `allowed_kinds`; other `422` responses list invalid body fields. |
| `429` | `External trigger replay limit reached` | Too many recent events for one trigger; wait for older entries to expire. |
| `503` | `External trigger target runtime is not available` | The router or target bot is not running or not joined to the room yet; retry later. |

## Watcher Behavior

A polling watcher should store the last observed state and compare before sending.
A webhook watcher should deduplicate webhook delivery IDs before calling MindRoom.

## Security Modes

### Kubernetes Hardened Mode

Keep the trigger private key out of the agent's reach.
Run always-on watchers as a sidecar of the MindRoom runtime pod, a CronJob, or a separate deployment, and mount the private key only into that watcher container, never into `sandbox-runner` or the agent workspace.
A watcher script may live in the agent's workspace as long as the key does not.
A worker pod can send triggers when the watcher is meant to live only as long as that worker.
Set `MINDROOM_URL` to the MindRoom service URL or pass `--url`.
If the watcher calls `mindroom trigger send`, its image needs the `mindroom` CLI.

### Personal VM Or Unsandboxed Mode

On a personal VM, a cron job running as the MindRoom user can call `http://127.0.0.1:8765`.
This does not hide the private key from an agent with unsandboxed shell access as that same user.
To hide the key from agent code, run the watcher as a separate OS user or keep the agent's code tools in a sandbox.

## Agent Callbacks

Agent callbacks let an agent hand a background task a script that wakes the same agent in the same thread when the task finishes.

1. The agent starts a background Codex session or another long-running task.
2. It calls `mint_callback` with a short label for that task.
3. It includes the returned instruction in the background task's prompt.
4. The background task runs the script with a short result summary when it finishes.

### Configuration

Enable the tool on agents that launch background work:

```yaml
agents:
  orchestrator:
    role: Launch and supervise coding agents.
    tools:
      - callback_manager
```

No callback-specific configuration exists.
Callbacks follow `external_trigger_policy` and count toward `max_triggers_per_owner` until used or deleted.

### Tool Result

`mint_callback(label)` returns a script path and an instruction like this:

```text
When finished, run: bash /path/to/callback_1234.sh "<short result summary>"
```

The script needs only Bash and curl.
It posts the summary to the room and thread where the callback was minted, which wakes the agent.
After a successful delivery the callback is used up and the script deletes itself; if delivery fails, the script remains so it can be run again.
Unused callbacks never expire, so delete abandoned ones with `external_trigger_manager.delete_trigger`.

### Network Access

The script calls the address of the MindRoom API server that was running when the callback was minted, including a non-default `--api-port`, or `http://127.0.0.1:8765` when none was running.
When the background process must reach MindRoom at another address, set `MINDROOM_URL` in MindRoom's own environment before the agent mints the callback, because the address is written into the script.
Point `MINDROOM_URL` only at a trusted MindRoom endpoint because the script sends its token there.

### Security

Each script contains a random bearer token that can only wake the agent, room, and thread captured when it was minted.
A missing or wrong token gets the same not-found response as an unknown trigger.
Callback deliveries pass the same [delivery requirements](#delivery-requirements) as signed triggers.
Use signed triggers for reusable integrations that need a stable identity.

# OpenAI-Compatible API

MindRoom serves an OpenAI-compatible chat completions API at `/v1`, so any chat frontend that speaks the OpenAI protocol (LibreChat, Open WebUI, LobeChat, ChatBox, BoltAI, and others) can use MindRoom agents and teams as selectable models.
Each request runs the selected agent with its configured tools, instructions, memory, and knowledge bases, without Matrix.

## Setup

### 1. Choose authentication

Add one of these to `.env`:

```bash
# Require API keys (recommended)
OPENAI_COMPAT_API_KEYS=sk-my-secret-key-1,sk-my-secret-key-2

# Or allow unauthenticated access (local development only)
OPENAI_COMPAT_ALLOW_UNAUTHENTICATED=true
```

With neither set, every `/v1` request returns 401.
Unauthenticated access answers only requests addressed to allowed host names and refuses cross-site browser requests; see [Unauthenticated dashboard host names](https://docs.mindroom.chat/authorization/#unauthenticated-dashboard-host-names).
A client that reaches an unauthenticated `/v1` by another host name, such as LibreChat in Docker using `http://host.docker.internal:8765/v1`, needs that name in `MINDROOM_DASHBOARD_ALLOWED_HOSTS`; with `OPENAI_COMPAT_API_KEYS` set, any host name works.

### 2. Start MindRoom

```bash
uv run mindroom run
```

The API is served by the bundled dashboard/API server at `http://localhost:8765/v1/`; change the port with `--api-port`, and note that `--no-api` turns the API off.

> [!IMPORTANT]
> If the dashboard and `/v1/*` share a domain behind a reverse proxy, route `/v1/*` to the MindRoom runtime as well as `/api/*`.
> Otherwise OpenAI-compatible requests reach the dashboard and fail.

### 3. Verify

```bash
# List available models
curl -H "Authorization: Bearer sk-my-secret-key-1" \
  http://localhost:8765/v1/models

# Chat (non-streaming)
curl -H "Authorization: Bearer sk-my-secret-key-1" \
  -H "Content-Type: application/json" \
  -d '{"model":"general","messages":[{"role":"user","content":"Hello"}]}' \
  http://localhost:8765/v1/chat/completions

# Chat (streaming)
curl -N -H "Authorization: Bearer sk-my-secret-key-1" \
  -H "Content-Type: application/json" \
  -d '{"model":"general","messages":[{"role":"user","content":"Hello"}],"stream":true}' \
  http://localhost:8765/v1/chat/completions
```

## Client Configuration

Any OpenAI-compatible client works: set the base URL to `http://localhost:8765/v1` and the API key to one of `OPENAI_COMPAT_API_KEYS`.
MindRoom implements `GET /v1/models` and `POST /v1/chat/completions`.

### LibreChat

Add to `librechat.yaml`:

```yaml
endpoints:
  custom:
    - name: "MindRoom"
      apiKey: "${MINDROOM_API_KEY}"
      baseURL: "http://localhost:8765/v1"
      models:
        default: ["general"]
        fetch: true
      modelDisplayLabel: "MindRoom"
      titleConvo: true
      titleModel: "general"
      dropParams: ["stop", "frequency_penalty", "presence_penalty", "top_p"]
      headers:
        X-Session-Id: "{{LIBRECHAT_BODY_CONVERSATIONID}}"
```

The `X-Session-Id` header keeps each LibreChat conversation in one MindRoom session; see [Session continuity](#session-continuity).

### Open WebUI

1. Go to **Admin Settings > Connections > OpenAI > Manage**.
2. Set API URL to `http://localhost:8765/v1`.
3. Set API Key to one of your `OPENAI_COMPAT_API_KEYS`.
4. Agents appear in the model picker.

## Model selection

Each available agent appears in `/v1/models` with its config name as the model ID (for example `code`), its `display_name` as the name, and its `role` as the description.
Only shared agents that are unscoped or use `worker_scope: shared` are available.
Agents with `private`, `worker_scope: user`, or `worker_scope: user_agent` are not listed, and neither is any agent whose `delegate_to` targets, directly or transitively, include such an agent.
Requesting one of them returns HTTP 400 with code `unsupported_worker_scope`, and the message names the agents that block the request.

## Auto-routing

Select the `auto` model to let the router pick the best available agent for each message.
`auto` considers agents only; select `team/<team_name>` to run a team.
The response's `model` field names the chosen agent, and the session belongs to that agent.
When a [router judgment](https://docs.mindroom.chat/configuration/router/#responder-selection-judgments) finds no suitable agent, the request fails with HTTP 400 and code `no_suitable_responder`; choose a model explicitly or rephrase.
When routing itself fails, the request goes to the first available agent.
`auto` is listed only when at least one agent is available.

## Teams

Teams appear as `team/<team_name>` models, and selecting one runs the team in its configured `coordinate` or `collaborate` mode.
A team is not listed when any member agent, or any agent a member delegates to, uses a requester-private scope (see [Model selection](#model-selection)).
For mapped callers, the team's own `access` decides whether they can use it, and that grant covers its member agents for the request.

## Session continuity

MindRoom picks the session for each request from its headers:

1. `X-Session-Id`, when present.
2. `X-LibreChat-Conversation-Id`, combined with the selected model.
3. Otherwise a new session for every request.

Requests with the same session keep the agent's conversation history and long-lived tool sessions, such as a [`claude_agent`](https://docs.mindroom.chat/tools/agent-orchestration/#claude_agent) coding session, so set `X-Session-Id` for continuity.
Sessions are separate per API key and per mapped requester, so two keys sending the same `X-Session-Id` never share a session.
With unauthenticated access, all callers share one set of sessions.

## Messages and parameters

The last `user` message is the prompt, and earlier `user` and `assistant` messages are passed as conversation history; `tool` messages are ignored.
Client `system` and `developer` messages are prepended to the prompt and add to the agent's own instructions rather than replacing them.
When `content` is an array of parts, only the `text` parts are used; images and other parts are ignored.

The API accepts but ignores these OpenAI parameters, because the agent's configuration controls them:

- `temperature`, `top_p`, `max_tokens`, `max_completion_tokens`
- `tools`, `tool_choice` (agents use their configured tools)
- `n`, `stop`, `frequency_penalty`, `presence_penalty`, `seed`
- `response_format`, `logprobs`, `logit_bias`
- `stream_options` (streaming responses never include a usage chunk)
- `user` (it never selects another user's identity, memory, or usage attribution; see [Requester identities and delegation](#requester-identities-and-delegation))

## Streaming and tool output

`stream: true` returns standard OpenAI Server-Sent Events: a role chunk, content chunks, a finish chunk, and `[DONE]`.
Tool calls appear inline in the content as `<tool id="N" state="start|done">...</tool>` text, not as native OpenAI `tool_calls`.
Tool-call text is always included on `/v1`, even when the agent sets `show_tool_calls: false`.

## Authentication

| `OPENAI_COMPAT_API_KEYS` | `OPENAI_COMPAT_ALLOW_UNAUTHENTICATED` | Behavior |
|---|---|---|
| Set | (any) | `Authorization: Bearer <key>` required, matching one of the comma-separated keys |
| Unset | `true` | No authentication required |
| Unset | Unset/`false` | All requests return 401 |

`/v1` authentication is independent of the dashboard: `MINDROOM_API_KEY` protects `/api/*` and the dashboard, while `OPENAI_COMPAT_API_KEYS` protects `/v1/*`.

### Requester identities and delegation

Bind API keys to Matrix user IDs with a JSON object in `.env`:

```dotenv
OPENAI_COMPAT_API_KEYS=sk-astrbot,sk-legacy
OPENAI_COMPAT_API_KEY_REQUESTERS='{"sk-astrbot":"@alice:example.org"}'
```

Restart MindRoom after changing these settings.
The key must also appear in `OPENAI_COMPAT_API_KEYS`; a mapping alone never authenticates a caller.
Mapped identities must be concrete human Matrix user IDs, and [bridge aliases](https://docs.mindroom.chat/authorization/#bridge-aliases) resolve to their canonical requester.
The request's `user` field and headers cannot override the mapped identity.

A mapped caller sees and can use only the agents and teams whose [responder access](https://docs.mindroom.chat/authorization/#responder-access) admits that user, and `auto` routes only among those agents.
Grant access with `access.users`, administrator membership, or `members_of_rooms`; `current_room_members` never applies because `/v1` requests have no room.
Mapped callers can use `run_subagent`, which requires both the calling agent's `delegate_to` and the requester's access to the target.

Unmapped keys and unauthenticated callers can use every available model but cannot delegate to subagents.

## Limitations

- **Fewer tools than in Matrix**: `/v1` hides tool functions matched by a required [tool approval](https://docs.mindroom.chat/tool-approval/#tools-that-cannot-pause-for-approval) rule, including script rules, because approval cards need a Matrix room.
  It also hides tools that need a live Matrix room, such as `scheduler`, `matrix_message`, `attachments`, and `todo`.
- **No requester-private agents**: see [Model selection](#model-selection).
- **Partial token usage**: only non-streaming responses report `usage`.
  It counts the turn's final model run, summed over the leader and members for teams, with cached input included in `prompt_tokens`, so tokens spent on earlier internal attempts in the same turn are not counted.
- **Request size**: request bodies over 16 MiB are rejected with HTTP 413.

## Errors

Errors use the OpenAI error format with an HTTP status, message, and `code`.

| Status | Message or code | Fix |
|---|---|---|
| 401 | `OpenAI-compatible API keys are not configured` | Set `OPENAI_COMPAT_API_KEYS` or `OPENAI_COMPAT_ALLOW_UNAUTHENTICATED=true` |
| 401 | `Missing or invalid Authorization header`, `Invalid API key` | Send `Authorization: Bearer <key>` with a configured key |
| 400 | `Host '<host>' is not allowed without a credential; ...` | Add the host to `MINDROOM_DASHBOARD_ALLOWED_HOSTS` or configure API keys |
| 403 | `This requester is not authorized for the model`, `No agents are authorized for this requester` | Grant the mapped user [responder access](https://docs.mindroom.chat/authorization/#responder-access) |
| 403 | `API requester must be a human identity` | Map the key to a human user, not an agent, bot account, or MindRoom's internal user |
| 404 | `model_not_found` | Use an ID from `/v1/models` |
| 400 | `unsupported_worker_scope` | See [Model selection](#model-selection) |
| 400 | `no_suitable_responder` | See [Auto-routing](#auto-routing) |
| 503 | `Invalid API requester configuration` | Make `OPENAI_COMPAT_API_KEY_REQUESTERS` a JSON object mapping keys to full Matrix user IDs |

# MCP Gateway

The optional MCP gateway lets external MCP clients, such as desktop AI apps, use MindRoom agents' tools through one endpoint at `/mcp`.
Each signed-in user sees only the agents they are authorized to use and have selected in the [Connections portal](https://docs.mindroom.chat/deployment/trusted-upstream-auth/#connections-portal), and only those agents' compatible tools.
Calls run with the selected agent's credential scope, tool filters, and worker routing, so each user connects only the services they need.
The gateway exposes tools only; it does not run the agent's model or load its prompt, memories, or conversation history, so use MindRoom chat to talk to agents.

## How clients use the gateway

The gateway offers exactly three MCP tools:

| Operation | Purpose |
|-----------|---------|
| `search_tools` | Search assigned integrations, or select an agent and toolkit to search its functions |
| `get_tool` | Fetch the input schema for one selected function |
| `invoke_tool` | Run that function with the selected agent's connections |

A client starts with `search_tools` and `{}` (or a query such as `{"query": "calendar"}`) to discover agent/toolkit pairs.
It passes a returned `agent` and `toolkit` to another `search_tools` call to list functions, then calls `get_tool` with the returned `agent`, `toolkit`, and `function`, and finally `invoke_tool` with the same selectors and an `arguments` object.
Clients must copy identifiers from these results; display names and agent directories such as `list_agents` do not show which agents the gateway exposes.
MCP initialization instructions name the configured personal agent from `MINDROOM_CONNECTIONS_AGENT` and the instance's `/connections` URL, but the personal agent is usable only when it appears in discovery.

When a service needs authorization, the result contains `error.code: connection_required` and a `connection_url` pointing to `/connections`.
The user connects that service in the browser and the client retries.
An unconnected or unavailable integration does not block discovery or use of other integrations.
Neither the gateway nor clients should automatically retry a failed or timed-out `invoke_tool` call, because the action may already have taken effect.

## Enable the gateway

First configure the [Connections portal](https://docs.mindroom.chat/deployment/trusted-upstream-auth/#connections-portal), including strict signed upstream authentication (`MINDROOM_TRUSTED_UPSTREAM_AUTH_ENABLED=true` and `MINDROOM_TRUSTED_UPSTREAM_REQUIRE_JWT=true`) and a private personal agent.
Then enable the gateway:

```bash
MINDROOM_CONNECTIONS_AGENT=personal
MINDROOM_PUBLIC_URL=https://assistant.example.org
MINDROOM_MCP_GATEWAY_ENABLED=true
```

The gateway is disabled by default and stays disabled unless all of these settings are present.
`MINDROOM_PUBLIC_URL` must be an HTTPS origin without a path, query, or fragment; loopback HTTP origins are accepted for local development.
Restart the API after changing any gateway environment variable.
Until then, gateway routes return `404 MCP gateway is disabled`.
If a gateway setting is invalid, the API logs `MCP gateway disabled: check public origin, limits, authentication, and account provisioning` and starts without the gateway.

Agent eligibility follows the agent access rules described for the Connections portal.
`access.current_room_members` alone does not grant MCP access because MCP requests have no current Matrix room, and credential management alone does not grant tool execution.
Users can address only agents in their own saved selection and cannot act as another user or credential owner.
Access changes, including revoked grant-room membership, apply to the next call without a configuration change; calls already running may finish.

## Choose exposed agents and tools

Users choose what the gateway exposes on the Connections page, which lists agents in a searchable table with filters for personal, shared, and MCP-enabled agents.
Agents a user can only manage credentials for have no MCP selection controls.
Expand an agent to see each tool with a connection, and expand **Other tools** for tools that need no setup.
Under **MCP gateway**, select **All tools** for an agent or choose tools individually.

- Each selection applies to one agent, even when another agent has the same tool.
- An individual selection covers a whole toolkit or MCP server, not single functions inside it.
- **All tools** includes compatible tools assigned to that agent later; a custom selection includes only the checked tools.
- The eligible personal agent starts selected; shared agents start off, and new agents are not added to an existing selection.
- Turning every agent off is allowed and persists.
- Losing access to an agent or tool hides its saved choice; if the user has not saved a new selection in the meantime, restoring access brings the choice back.
- One saved selection applies to all of a user's MCP clients and changes no other user's selection or connections.
- Removing an agent or tool still works when stored data exceeds a lowered storage quota.

Tools that share a service connection share its OAuth controls, and room-dependent tools are labeled **MindRoom only** and are not exposed.
A shared agent's configured worker and credential scopes still decide whose service connection runs a call.
When managed account provisioning is enabled, users without an active provisioned account see an access notice instead of MCP controls until an administrator provisions them; their ordinary connections remain available.

## Route browser and machine traffic

These routes apply to the default `MINDROOM_MCP_AUTH_MODE=builtin`; for externally issued credentials, see [External authorization](#external-authorization), where `/mcp` and protected-resource discovery are unchanged.
Forward these paths to the MindRoom API:

| Paths | Authentication at the access proxy |
|-------|------------------------------------|
| `/connections`, `/connections/*`, `/api/connections`, `/api/connections/*`, existing `/api/oauth/*` | Existing signed browser authentication |
| `/mcp` | Pass through to gateway bearer authentication |
| `/mcp/oauth/authorize`, `/mcp/oauth/register`, `/mcp/oauth/token`, `/mcp/oauth/revoke` | Public OAuth endpoints; pass through |
| `/.well-known/oauth-authorization-server/mcp/oauth`, `/.well-known/oauth-protected-resource/mcp` | Public client discovery |
| `/mcp/oauth/.well-known/oauth-authorization-server` | Compatibility discovery alias |

Browser consent lives at `/connections/mcp/authorize`, inside the authenticated browser prefix, so keep it out of any other frontend's service-worker navigation fallback.
Machine endpoints must receive MCP and OAuth responses, not an access proxy's HTML login page.
Strip client-supplied trusted identity headers at the proxy, including on machine paths.
Preserve the public `Host` header and the `Authorization`, `Origin`, `Accept`, `Content-Type`, and `MCP-Protocol-Version` headers.
Forward the exact `/mcp` path without adding a trailing slash.

Native clients can omit `Origin`.
To allow a browser-based MCP client, list its exact origin:

```bash
MINDROOM_MCP_GATEWAY_ALLOWED_ORIGINS=https://client.example.org,http://localhost:6274
```

The gateway's public origin is allowed automatically.
Additional origins must be HTTPS or loopback HTTP; wildcards are rejected.
Cross-origin MCP requests never carry browser cookies and do not grant access to dashboard APIs.
The public registration, token, revocation, and discovery endpoints accept noncredentialed cross-origin requests.

## Connect an MCP client

Configure a Streamable HTTP server with URL `https://assistant.example.org/mcp`.
In built-in mode, the client must support OAuth discovery and dynamic registration; a static bearer-only configuration screen cannot perform the initial login.
In [external mode](#external-authorization), clients follow the issuer's flow instead.
The built-in flow uses the OAuth authorization code grant with PKCE S256 and dynamic public-client registration:

1. Read the resource metadata URL from the gateway's `401` bearer challenge.
2. Discover the authorization server at issuer `https://assistant.example.org/mcp/oauth`.
3. Register with `token_endpoint_auth_method: none`, `response_types: [code]`, and both `authorization_code` and `refresh_token` in `grant_types`.
4. Request scope `mcp:tools` and exact resource `https://assistant.example.org/mcp`.
5. Open the authorization URL, sign in through the existing browser login, and approve the named client.
6. Exchange the code with its original callback, PKCE verifier, and exact resource, then send the access token as a bearer on MCP requests.

Code exchange and refresh both require the exact resource.
Client callbacks must be registered HTTPS URLs or loopback HTTP URLs.
Dashboard API keys, browser cookies, and unsigned identity headers do not authenticate `/mcp`.
Client names are self-declared, so users should check the displayed client address before approving.

The gateway speaks the Python MCP SDK 1.x Streamable HTTP protocol and is tested with protocol version `2025-11-25`.
It uses stateless requests with JSON responses and does not offer resumable SSE sessions, resources, or prompts.

## External authorization

External mode accepts signed JWT access tokens from one configured authority instead of MindRoom's built-in OAuth server.
It requires [managed account provisioning](#managed-account-provisioning), and every request also checks for an active provisioned account, current agent eligibility, and the user's saved selection.

```bash
MINDROOM_MCP_AUTH_MODE=external
MINDROOM_MCP_EXTERNAL_AUTHORIZATION_SERVER=https://identity.example.org
MINDROOM_MCP_EXTERNAL_ISSUER=https://identity.example.org
MINDROOM_MCP_EXTERNAL_AUDIENCE=https://assistant.example.org/mcp
MINDROOM_MCP_EXTERNAL_JWKS_URL=https://identity.example.org/.well-known/jwks.json
MINDROOM_MCP_EXTERNAL_MATRIX_USER_ID_CLAIM=matrix_user_id
MINDROOM_MCP_SCIM_TOKEN=<at least 32 random characters>
```

| Variable | Default | Meaning |
| --- | --- | --- |
| `MINDROOM_MCP_AUTH_MODE` | `builtin` | `builtin` or `external` |
| `MINDROOM_MCP_EXTERNAL_AUTHORIZATION_SERVER` | required | Issuer advertised in protected-resource metadata; its discovery document supplies the client's OAuth endpoints |
| `MINDROOM_MCP_EXTERNAL_ISSUER` | required | Required `iss` value; may differ from the authorization server, and significant trailing slashes matter |
| `MINDROOM_MCP_EXTERNAL_AUDIENCE` | required | Required `aud`; use an audience dedicated to this MCP resource, not a client ID or browser application audience |
| `MINDROOM_MCP_EXTERNAL_JWKS_URL` | required | Signing keys |
| `MINDROOM_MCP_EXTERNAL_TOKEN_HEADER` | `Authorization` | Header carrying the token; `Authorization` expects `Bearer <JWT>`, other headers carry the raw JWT |
| `MINDROOM_MCP_EXTERNAL_EMAIL_CLAIM` | `email` | Claim holding the user's email |
| `MINDROOM_MCP_EXTERNAL_MATRIX_USER_ID_CLAIM` | unset | Claim holding the Matrix user ID |
| `MINDROOM_MCP_EXTERNAL_EMAIL_DOMAIN` | unset | Only accepted email domain for the email-template mapping |
| `MINDROOM_MCP_EXTERNAL_EMAIL_TO_MATRIX_USER_ID_TEMPLATE` | unset | Matrix ID template such as `'@{localpart}:example.org'` |
| `MINDROOM_MCP_EXTERNAL_REQUIRED_SCOPES` | unset | Whitespace-separated scopes the signed `scope` claim must contain |
| `MINDROOM_MCP_EXTERNAL_CLIENT_ID` | unset | Exact signed `client_id` claim to require |

The authorization server, issuer, and JWKS URLs must be HTTPS without credentials, queries, or fragments.
Configure exactly one identity mapping: either the Matrix user ID claim, or the email template together with the email domain.
With the email mapping, the complete signed email must match the SCIM `userName` exactly, including case.

Tokens must be RS256 or ES256 JWTs with signed `iss`, `aud`, `exp`, `iat`, `sub`, and the email claim.
Opaque tokens, token introspection, and OIDC ID tokens are unsupported.
No scope is required unless `MINDROOM_MCP_EXTERNAL_REQUIRED_SCOPES` is set; a token missing a required scope gets HTTP 403.
Unsigned headers, browser cookies, owner API keys, and built-in gateway tokens cannot replace external credentials.

External mode disables MindRoom's OAuth registration, authorization, consent, token, revocation, and client-management endpoints and controls.
Agent selection on Connections still applies to all of the user's externally authenticated clients.
Manage client authorizations at the issuer; provider connections in the portal stay separate.
Because external mode shares the account store, unused `MINDROOM_MCP_OAUTH_*` settings must keep valid values.

Shared issuer keys, an operator-chosen audience, or an email domain do not isolate tenants.
Rely on external mode only when the issuer gives this resource exclusive resource and identity namespaces, or when it guarantees the signed user's identity within your tenant and you set `MINDROOM_MCP_EXTERNAL_CLIENT_ID` to a [JWT access-token `client_id`](https://www.rfc-editor.org/rfc/rfc9068.html#section-2.2) that other tenants cannot select or override.
A pinned client must use that preregistered client ID and support the authorization server's discovery and authorization flow.

### Direct OAuth and signed proxy assertions

Direct JumpCloud JWT authentication is unverified and not recommended.
[JumpCloud's OIDC guide](https://jumpcloud.com/support/sso-with-oidc) guarantees subject mapping only for ID tokens, and its shared regional issuer does not by itself isolate tenants.
Put JumpCloud behind an OAuth-aware proxy that enforces the tenant's login policy and sends signed assertions instead.

An OAuth-aware proxy can send a raw signed JWT in the header named by `MINDROOM_MCP_EXTERNAL_TOKEN_HEADER`.
For example, [Cloudflare Managed OAuth](https://developers.cloudflare.com/cloudflare-one/access-controls/applications/http-apps/managed-oauth/) (beta) uses `Cf-Access-Jwt-Assertion`; configure that header, the assertion issuer and JWKS, the MCP Access application's audience, and the OAuth discovery issuer.
The edge must own OAuth, enforce the MCP policy, replace client-supplied assertions, and prevent origin bypass.
Do not keep a bypass policy on `/mcp` or combine Managed OAuth with built-in mode.
SCIM still needs its own authenticated route without interactive login redirects.

### External lifecycle and validation

After an account is created, renamed, migrated, reactivated, or deleted and recreated, the gateway accepts only JWTs issued more than 60 seconds after that event, so clients may need to fetch a new token after waiting; profile-only updates keep access.
Keep the issuer and gateway clocks synchronized; tokens with a future `iat` are rejected.
Signing-key revocation can take up to 60 seconds to apply.
The issuer owns refresh revocation and login policy, so after reactivation a client may renew silently without an interactive login.
The per-user concurrency limit spans all of a user's tokens, while the per-grant limit and cancellation follow the exact token, so cancelling a call requires the token that started it.

Hosted identity-provider login and connector delivery have not been verified end to end.
Before general access, test real client login and resource handling, two users' tools, disabling a user with a connected client, refresh rejection, and reactivation against the intended service.

## Access and lifecycle

This section covers built-in mode client grants; agent selection and call limits apply in both modes.

Access tokens last up to 15 minutes, and refresh tokens rotate on use; reusing a refresh token revokes the whole grant.
A grant expires after its idle lifetime without a successful refresh or tool use, and at its absolute lifetime after approval, which continued use never extends.
Background token refresh counts as activity, so a client that keeps refreshing stays connected until the absolute deadline.
Portal visits, failed calls, and invalid tokens do not extend the idle lifetime.

| Setting | Default | Meaning |
| --- | --- | --- |
| `MINDROOM_MCP_OAUTH_IDLE_TTL_DAYS` | `30` | Maximum days without successful refresh or tool use |
| `MINDROOM_MCP_OAUTH_GRANT_TTL_DAYS` | `180` with provisioning, otherwise `30` | Absolute lifetime in days from approval |
| `MINDROOM_MCP_SCIM_TOKEN` | unset | Provisioning bearer secret, at least 32 characters; enables managed accounts |

Lifetimes must be positive whole days, the idle lifetime cannot exceed the absolute lifetime, and the absolute lifetime cannot exceed 365 days.
Absolute lifetimes above 30 days require managed account provisioning, so that disabling an account can revoke long-lived access.
After you enable provisioning, existing clients without a provisioned account must be approved again once.

The Connections portal lists each authorized client with its address, last tool use, and expiry.
Users can disconnect one client or all clients without disconnecting their upstream service accounts, and disconnecting all also cancels their pending approvals.
A disconnected client can request fresh consent, and users can still manage clients after losing agent tool permission.
The OAuth revocation endpoint revokes the whole client grant; revoking with an expired access token may succeed without effect, so use the portal or an unexpired refresh token.
Browser logout does not revoke gateway grants.

Gateway OAuth state and user selections are stored in `mcp_gateway/oauth.sqlite3` under the storage root; keep that directory private and persistent across restarts.
Tokens, codes, and nonces are stored only as hashes, and upstream provider credentials stay in the scoped credential store.
Run a single API process for the gateway; multiple independently routed replicas are not supported.

## Limits

Each call has a 60-second deadline and three concurrency limits:

| Variable | Default | Scope |
| --- | --- | --- |
| `MINDROOM_MCP_GATEWAY_MAX_ACTIVE_CALLS` | `128` | All active calls in the API process |
| `MINDROOM_MCP_GATEWAY_MAX_USER_CALLS` | `16` | One user across all clients |
| `MINDROOM_MCP_GATEWAY_MAX_GRANT_CALLS` | `16` | One authorized client connection |

Each value must be a positive integer; zero does not mean unlimited.
The limits apply to all three operations, and a call over any limit fails immediately with `busy` instead of queueing.
A cancelled or timed-out call keeps its allowance until its tool work and cleanup finish.
The defaults are conservative starting values; tune them to available resources, tool latency, and upstream quotas.
Gateway tool work runs on 32 dedicated threads, and calls beyond that wait within their deadline, so keep `MINDROOM_MCP_GATEWAY_MAX_USER_CALLS` below 32 so one user cannot occupy them all.

Size limits:

- Search returns at most 10 results and 16 KiB.
- A tool schema is limited to 32 KiB, and tool arguments and results to 64 KiB each.
- HTTP request bodies and MCP responses are limited to 128 KiB.

Public client registration and authorization starts are rate limited per API process:

| Variable | Default | Meaning |
| --- | --- | --- |
| `MINDROOM_MCP_GATEWAY_ONBOARDING_RATE_LIMIT` | `60` | Requests per minute from all sources |
| `MINDROOM_MCP_GATEWAY_ONBOARDING_SOURCE_RATE_LIMIT` | `10` | Requests per minute from one client IP |

Both values must be positive integers; zero does not remove the limit, it disables the gateway.
Keep the source limit below the aggregate limit.
Excess requests get HTTP 429 with `Retry-After`; token exchange, refresh, revocation, discovery, and existing grants are unaffected.
The source is the client IP reported by the ASGI server, not forwarding headers.
Behind a proxy, set Uvicorn's `FORWARDED_ALLOW_IPS` to only your trusted proxies and have them sanitize the forwarded client address; otherwise every caller behind the proxy shares one allowance.
Users behind the same NAT also share an allowance.
These limits provide fair admission, not protection against distributed denial-of-service attacks.

Stored gateway state has logical size budgets:

| Variable | Default | Covers |
| --- | --- | --- |
| `MINDROOM_MCP_GATEWAY_ONBOARDING_MAX_BYTES` | `67108864` (64 MiB) | Pending consent and registered clients without a live grant |
| `MINDROOM_MCP_OAUTH_MAX_BYTES` | `268435456` (256 MiB) | All retained OAuth state and selections |
| `MINDROOM_MCP_OAUTH_USER_MAX_BYTES` | `16777216` (16 MiB) | One user's selection, grants, and clients |

Each value is a positive integer in bytes.
When the onboarding budget is full, registration returns HTTP 503 with `Retry-After: 60` and authorization returns OAuth `temporarily_unavailable`.
When a durable budget is full, token issuance and consent return HTTP 503 with `Retry-After: 60` and `temporarily_unavailable`, while the existing refresh token or code stays usable.
Space is freed as grants are revoked or expire, which can take longer than the retry interval, and lowering a budget can block refreshes until usage falls below it.
Registrations without a grant or pending consent expire after 24 hours.
A grant can issue at most six token pairs in any 60-second window, including the initial code exchange.
These budgets do not cap the physical database file; use a filesystem or volume quota for a hard disk bound.

## Managed account provisioning

Managed account provisioning lets an identity provider control who may connect MCP clients, and is required for external mode and for grant lifetimes above 30 days.
Set a dedicated `MINDROOM_MCP_SCIM_TOKEN` and expose `/mcp/scim/v2` to the identity provider over HTTPS, without interactive login redirects or browser CORS access.
Do not share the provisioning secret with MCP clients or browser applications.
In a compatible custom SCIM connector, use the base URL `https://assistant.example.org/mcp/scim/v2` with that bearer token.

The endpoint implements a restricted SCIM 2.0 User profile: create, list, read, replace, PATCH, and delete, plus service-provider and schema discovery.
List filters support only one exact `eq` comparison on `userName`, `emails.value`, or `id`, such as `userName eq "ada@example.org"`; other filters, including `externalId` and compound filters, return `400 invalidFilter`.
Create and replace requests must include a boolean `active`; omitting it returns `400 invalidValue`.
Create and replace requests may also declare the Enterprise User extension, whose data is ignored; other extensions are rejected.
Group management, bulk operations, password management, and sorting are unsupported, so disable group management in the connector.
PATCH supports `userName`, `active`, `displayName`, `externalId`, `name`, `emails`, `name.givenName`, `name.familyName`, and `name.formatted`.
Replace emails through the whole `emails` attribute; filtered paths such as `emails[type eq "work"].value` return `400 invalidPath`, and an unsupported operation rolls back the whole PATCH.
Rejected schema declarations log `SCIM schema validation failed` with the expected and recognized schemas, which helps spot SCIM 1.1 payloads or wrong schemas.

Set the connector's `userName` to the user's email exactly as verified by browser login in built-in mode or by the signed token in external mode, including case.
Provisioning does not authenticate users or grant tool permissions.
Unknown or inactive accounts cannot authorize clients.
Deactivating, renaming, or deleting an account removes all its client grants and pending consent; reactivation allows new approvals but never restores old grants, and a deleted and recreated account does not get its previous selection back.
Deactivation applies when MindRoom receives the update, so monitor connector delivery; provider actions already started cannot be undone.
Provisioning does not deactivate Matrix accounts, erase upstream service credentials, or log out browser sessions.

To test offboarding in built-in mode, provision an active test user, approve an MCP client through the portal, deactivate the user, and check that token refresh, tool use, and new consent are rejected.
Test the connector's real update and deactivation payloads against this profile before relying on it.

## Error codes

Tool errors arrive as an `error` object with a `code` inside a successful MCP response.

| Code | Meaning and fix |
| --- | --- |
| `connection_required` | Open `connection_url` and connect the service, then retry |
| `tool_not_found` | The identifier is not assigned or selected; repeat discovery, and if a shared-agent tool is missing, check the agent and tool selections in Connections |
| `invalid_arguments` | Arguments do not match the schema or exceed 64 KiB |
| `approval_required` | The tool needs confirmation or a configured approval and cannot run through the gateway |
| `unauthorized` | Agent access or selection changed; check Connections |
| `tool_unavailable` | The tool could not be loaded or failed; its outcome may be unknown |
| `schema_too_large`, `result_too_large` | The schema or result exceeds the size limit |
| `busy` | A concurrency limit is full |
| `duplicate_request` | A call with the same request ID is already running |
| `timeout`, `cancelled` | The call stopped waiting; the action may still have taken effect |

Tools that need the live Matrix room, such as Matrix messaging, scheduling, and conversation attachments, are never exposed.
Explicit MCP cancellation applies only to a matching request ID from the same client.
Cancellation cannot forcibly stop synchronous tool work, so a stuck provider call can hold capacity and delay shutdown until the process supervisor terminates the API.

## Inbound request diagnostics

These log events describe inbound `/mcp` traffic, separately from outbound MCP integration logs, and omit credentials, arguments, queries, and results.

- `mcp_gateway_http_completed` records the HTTP status, elapsed milliseconds, and authenticated `requester_id` when available.
- `mcp_gateway_call_completed` records the operation, elapsed milliseconds, outcome, and error code; an HTTP 200 can still carry a tool error.
- `mcp_gateway_call_failed` records unexpected failures with the exception type.
- `mcp_gateway_agent_selection` records whether the requested agent is eligible, saved, selected, or matches an eligible name ignoring case, plus eligible and selected agent counts.

Join these events by the server-generated `request_id`, which differs from the client's JSON-RPC ID.
In `mcp_gateway_agent_selection`, `agent_eligible=false` with `agent_case_match=true` means a case mismatch, and `agent_eligible=true` with `agent_selected=false` means the user has not selected that agent.
A zero `selected_agent_count` explains empty discovery.

# Deployment

This page helps you choose how to run MindRoom and lists what every deployment needs.
Each option links to the page with its setup steps.

## Deployment Options

| Method | Best For |
|--------|----------|
| [Hosted Matrix + local MindRoom](https://docs.mindroom.chat/deployment/hosted-matrix/) | Recommended and simplest: run only `uvx mindroom run` locally |
| [NixOS LXC (Incus)](https://docs.mindroom.chat/getting-started/#preferred-alternative-nixos-lxc-container-agent-controlled-machine) | Give a MindRoom agent full freedom over its own persistent NixOS machine while the host controls what it sees |
| [Full Stack (Docker Compose)](https://docs.mindroom.chat/getting-started/#alternative-full-stack-docker-compose) | All-in-one: bundled dashboard + Matrix (Tuwunel) + MindRoom client |
| [Docker (single container)](https://docs.mindroom.chat/deployment/docker/) | Single MindRoom runtime when you already have Matrix |
| [Direct install](https://docs.mindroom.chat/getting-started/#manual-install-with-your-own-matrix-homeserver) | Development and simple setups with your own Matrix homeserver |
| [Kubernetes](https://docs.mindroom.chat/deployment/kubernetes/) | Production clusters: a runtime with optional Tuwunel, client, and MatrixRTC charts, or the multi-tenant SaaS platform |
| [Sandbox Proxy Isolation](https://docs.mindroom.chat/deployment/sandbox-proxy/) | Run MindRoom while execution tools run in isolated workers |
| [Approved Egress](https://docs.mindroom.chat/deployment/approved-egress/) | Require static allowlists or human approval before Kubernetes workers reach external hostnames |
| [Trusted upstream browser auth](https://docs.mindroom.chat/deployment/trusted-upstream-auth/) | Hosted multi-user private agents behind an authenticated access layer |

## Required Configuration

The full stack needs only a model provider key in its `.env`; see [Full Stack Docker Compose](https://docs.mindroom.chat/getting-started/#alternative-full-stack-docker-compose).

Direct and single-container deployments need:

1. **Matrix homeserver** - Set `MATRIX_HOMESERVER` and give MindRoom a way to create agent accounts (see [Matrix account provisioning](https://docs.mindroom.chat/getting-started/#matrix-account-provisioning)).
2. **Model credentials** - Configure credentials for the selected provider, using an API key or a supported CLI login (see [Model Configuration](https://docs.mindroom.chat/configuration/models/#environment-variables)); containers must mount or provide the matching auth state.
3. **Persistent storage** - Mount the storage directory (`mindroom_data/` by default) so agent state survives restarts (see [Data Storage](https://docs.mindroom.chat/deployment/storage/#data-persistence)).

Hosted `mindroom.chat` installs also need the credentials that pairing saves; see [Hosted Matrix](https://docs.mindroom.chat/deployment/hosted-matrix/#what-pairing-saves).
Every environment variable is listed in [Configuration — Environment Variables](https://docs.mindroom.chat/configuration/#environment-variables).

## Related Setup

- [Bridges](https://docs.mindroom.chat/deployment/bridges/) - connect Telegram and other messaging platforms to Matrix.
- [Connect Google Accounts](https://docs.mindroom.chat/deployment/google-services-user-oauth/) - Google tools on a paired local install, with no Google Cloud setup.
- [Google OAuth Apps & Scopes](https://docs.mindroom.chat/deployment/google-services-oauth/) - your own Google OAuth client for remote, public, or shared deployments.
- [Upgrade Notes](https://docs.mindroom.chat/deployment/upgrades/) - required steps when upgrading, including the [Nio 1.0 cutover](https://docs.mindroom.chat/deployment/upgrades/#upgrading-to-nio-10).

# Hosted Matrix + Local Backend

In this setup the Matrix homeserver and chat UI are hosted at `mindroom.chat`, and only the MindRoom backend runs on your machine.
This page covers pairing that backend with your MindRoom Chat account (`mindroom connect`), the credentials pairing saves, what the hosted server can see, and how to point the same flow at your own deployment.
For first-run install steps, see [Getting Started](https://docs.mindroom.chat/getting-started/#recommended-hosted-matrix-local-mindroom-uvx-only).

## What Runs Where

| Component | Runs on | Purpose |
|----------|---------|---------|
| `chat.mindroom.chat` | Hosted web app | Login and pairing approval |
| `mindroom.chat` | Hosted Matrix + provisioning API | Matrix transport and agent account registration |
| `uvx mindroom run` | Your machine or server | Agent orchestration, tools, model calls |

You need a Matrix account that can sign in to `chat.mindroom.chat` and at least one working model provider, such as a provider API key, a Codex CLI ChatGPT login, or a local Ollama server (see [Supported Providers](https://docs.mindroom.chat/configuration/models/#supported-providers)).
`mindroom config init` (default `--matrix-server mindroom.chat`) and first-run `mindroom run` write the hosted defaults to `~/.mindroom/.env`: `MATRIX_HOMESERVER=https://mindroom.chat`, `MATRIX_SERVER_NAME=mindroom.chat`, and `MINDROOM_PROVISIONING_URL=https://mindroom.chat`.
`mindroom run` pairs automatically before starting when `MINDROOM_PROVISIONING_URL` is set and none of `MATRIX_REGISTRATION_TOKEN`, `MATRIX_REGISTRATION_SHARED_SECRET`, or saved pairing credentials are present.
After pairing, MindRoom creates its agent accounts on `mindroom.chat`, joins or creates the configured rooms, and starts answering.
While `run` waits for approval, the API port answers health and readiness probes, so container health checks do not restart it with a new code (see [Health & Readiness](https://docs.mindroom.chat/deployment/operational-log-events/#health-readiness)).
To run code-execution tools in Docker workers on the same machine, see [Dedicated Docker workers](https://docs.mindroom.chat/deployment/sandbox-proxy/#host-machine-dedicated-docker-workers-mindroom_worker_backenddocker).

## connect

`mindroom connect` pairs this machine with your MindRoom Chat account without starting MindRoom.
You usually do not need it, because `mindroom run` pairs automatically and prints a new link whenever the previous one expires.

```bash
mindroom connect
```

| Option | Effect | Default |
| --- | --- | --- |
| `--provisioning-url URL` | Provisioning service to pair with | `MINDROOM_PROVISIONING_URL`, else `https://mindroom.chat` |
| `--client-name NAME` | Name for this machine in the list of paired installs | Host name |
| `--persist-env` / `--no-persist-env` | Save the credentials to `.env` next to `config.yaml`, or only print them as `export` lines | `--persist-env` |
| `--open-browser` | Open the approval link in the default browser | Off |
| `--path`, `-p` | Config file whose `.env` receives the credentials | Auto-detected |
| `--force` | Pair again without asking when this machine is already connected | Off |
| `--graceful-cancel` | SIGTERM while waiting exits with code `130` without saving (used by the macOS app) | Off |

### Approve the pairing

`connect` and `run` print an approval link, a pair code, and, in a terminal, a QR code of the link.
Open the link or scan the QR code while signed in to MindRoom Chat, or enter the code in MindRoom Chat → Settings → Local MindRoom.
Approve only when the page shows the same code as your terminal (`Approve only if the page shows code ABCD-EFGH`), because anyone who sees the link or code can approve it, and the approving account becomes the one your agents trust.
The page also shows the address the request came from.

A pair code is valid for 10 minutes.
`connect` makes one attempt and then exits with `Approval timed out. Run the command again to get a new link.`, while `run` keeps printing new codes.

After approval, and before anything is saved, MindRoom prints the approving account, for example `Approved by @alice:mindroom.chat.`
In a terminal it then asks `Is this your account? [Y/n]`.
Pressing Enter or answering `y` saves the credentials.
Answering `n`, pressing Ctrl+C, or closing input discards them without writing `.env` or `config.yaml`, and the command exits with an error (`run` does not start); revoke that connection in MindRoom Chat → Settings → Local MindRoom.
Without a terminal, such as under a service or the macOS app, or when the provisioning service does not name the approving account, nothing is asked and MindRoom prints the same revoke hint.

### What pairing saves

With `--persist-env`, pairing writes these to `.env` next to `config.yaml`:

- `MINDROOM_PROVISIONING_URL`
- `MINDROOM_LOCAL_CLIENT_ID`
- `MINDROOM_LOCAL_CLIENT_SECRET`
- `MINDROOM_NAMESPACE`
- `MINDROOM_OWNER_USER_ID`, when the approving account is a valid Matrix user ID

It also replaces the owner placeholder `__MINDROOM_OWNER_USER_ID_FROM_PAIRING__` with the approving account in `config.yaml` and every file it pulls in with `!include`.
An approving account that is not a valid Matrix user ID is printed as `Approved by '<value>', which is not a valid Matrix user ID.` and is never saved, so the placeholders stay for you to replace.
`mindroom config init` likewise warns and leaves the placeholders when `MINDROOM_OWNER_USER_ID` in the environment or `.env` is not a valid Matrix user ID.
Placeholders are replaced only once, so when a later pairing is approved by an account missing from `administrators`, MindRoom prints a note and leaves the config unchanged.
Add that account to `administrators`, `room_defaults.invite_users`, and `room_defaults.admins` yourself if it should manage MindRoom.
With `--no-persist-env`, MindRoom prints the variables as `export` lines, including the owner ID, and you replace the placeholders yourself.

### Pair again

Pairing a machine that is already connected creates a new connection and a new agent namespace: existing agents keep working, and new agents get the new namespace.
In a terminal, `connect` asks `Pair again?` first.
Without a terminal it does not pair and exits with code `3`, so scripts and the macOS app can tell this apart from a failure (exit code `1`).
Add `--force` to pair again without asking.

### Self-hosted homeservers

Pairing is only for hosted `mindroom.chat` or your own provisioning service.
When neither `MINDROOM_PROVISIONING_URL` nor `--provisioning-url` is set and `MATRIX_HOMESERVER` is not `mindroom.chat`, `connect` refuses without contacting anything or writing files.
This includes an unset `MATRIX_HOMESERVER`, which defaults to `http://localhost:8008`, so run `mindroom config init --matrix-server mindroom.chat` first for hosted defaults.
Self-hosted servers without a provisioning service register agents with `MATRIX_REGISTRATION_TOKEN` or `MATRIX_REGISTRATION_SHARED_SECRET` instead (see [Configuration](https://docs.mindroom.chat/configuration/)).

## Pairing Credentials

`MINDROOM_LOCAL_CLIENT_ID` and `MINDROOM_LOCAL_CLIENT_SECRET` are **not Matrix access tokens**.
They only authorize provisioning-service requests from this install: registering agent accounts and retrieving the [Google desktop OAuth client](https://docs.mindroom.chat/deployment/google-services-user-oauth/).
The provisioning service never learns your agents' Matrix passwords, because MindRoom replaces each new account's one-time password with a locally generated one.
Treat the pairing credentials as secrets anyway, because anyone who has them can make the same requests.
`MINDROOM_NAMESPACE` is appended to managed agent usernames and room aliases so installs do not collide on the shared homeserver.

Revoke a pairing in MindRoom Chat → Settings → Local MindRoom, which also shows when each install was last seen.
Each account keeps at most 20 paired installs, revoked ones included; pairing another at the limit removes a revoked install first, otherwise the one seen least recently.
A running install reports itself to the provisioning service at startup and every six hours, and the report carries no messages, configuration, or other content.
If the service rejects the credentials as invalid or revoked, MindRoom logs a warning to run `mindroom connect` again.

## What the Hosted Server Can See

In end-to-end encrypted rooms, the homeserver stores message bodies as ciphertext, and your local `mindroom run` holds the agent keys and decrypts locally.
Agents share encryption keys with room members' unverified devices (see [Delivery Policy](https://docs.mindroom.chat/matrix/#delivery-policy)), so a malicious homeserver that adds an attacker-controlled device to a participating account can receive keys for messages sent after that.
This does not give it access to earlier stored history.

The homeserver can still see:

- Metadata: room membership, timestamps, event IDs, sender IDs, and traffic patterns.
- Plaintext of any room that is not encrypted.

Model and tool providers see whatever prompts and data you send them.
See the [Matrix E2EE guide](https://matrix.org/docs/matrix-concepts/end-to-end-encryption/) for device discovery and verification.

## Use Your Own Deployment

Keep the same local flow and change the endpoints in `.env`:

- `MATRIX_HOMESERVER=https://your-matrix.example.com`
- `MATRIX_SERVER_NAME=your-matrix.example.com`
- `MINDROOM_PROVISIONING_URL=https://your-matrix.example.com` (or your dedicated provisioning host)

Then run `mindroom run`, which pairs with your own deployment on first start.
An install already paired with another provisioning service keeps its old pairing, so run `mindroom connect` first to pair it again.
To register agent accounts directly with the homeserver without pairing, set `MATRIX_REGISTRATION_TOKEN`, or set `MATRIX_REGISTRATION_SHARED_SECRET` and unset `MINDROOM_PROVISIONING_URL`.

On the provisioning service itself, set `MINDROOM_PROVISIONING_APPROVE_URL=https://chat.your-matrix.example.com/connect` so pairing links open your own chat UI.
It defaults to `https://chat.mindroom.chat/connect` and is not read by the local `mindroom` process.

# Docker Deployment

Run MindRoom as a single container when you already have a Matrix homeserver.
For a hosted homeserver with only MindRoom local, see [Hosted Matrix](https://docs.mindroom.chat/deployment/hosted-matrix/); for other options, see [Deployment](https://docs.mindroom.chat/deployment/).

The `ghcr.io/mindroom-ai/mindroom:latest` image runs `mindroom run` and serves, on port `8765`:

- the bot orchestrator
- the dashboard UI at `http://localhost:8765`
- the dashboard API at `http://localhost:8765/api`
- the OpenAI-compatible API at `http://localhost:8765/v1`

## Docker (single container)

### Prepare the data directory

The container runs as UID/GID `1000:1000`, so the host data directory and everything in it must be writable by that identity.
For a new directory on Linux with rootful Docker and no user-namespace remapping:

```bash
mkdir -p ./mindroom_data
sudo chown 1000:1000 ./mindroom_data
```

For rootless Docker or user-namespace remapping, grant access to the host UID/GID that container UID 1000 maps to, or use suitable ACLs.

See [Data Persistence](https://docs.mindroom.chat/deployment/storage/#data-persistence) for what this directory holds and what to back up.

### Run the container

```bash
docker run -d \
  --name mindroom \
  -p 8765:8765 \
  -v ./config.yaml:/app/config.yaml:ro \
  -v ./mindroom_data:/app/mindroom_data \
  --env-file .env \
  ghcr.io/mindroom-ai/mindroom:latest
```

Remove `:ro` from the config mount if you want configuration changes made from the dashboard or chat saved to `config.yaml`.
When upgrading a config that still uses retired access fields, follow [Membership Access Migration](https://docs.mindroom.chat/deployment/upgrades/#membership-access-migration) on the host before starting the container.

## Docker Compose

Prepare `./mindroom_data` as described [above](#prepare-the-data-directory), then create `docker-compose.yml`:

```yaml
services:
  mindroom:
    image: ghcr.io/mindroom-ai/mindroom:latest
    container_name: mindroom
    restart: unless-stopped
    ports:
      - "8765:8765"
    volumes:
      - ./config.yaml:/app/config.yaml:ro
      - ./mindroom_data:/app/mindroom_data
    env_file:
      - .env
```

Start it with:

```bash
docker compose up -d
```

## Environment Variables

Pass variables with `--env-file .env` or the Compose `env_file:` and `environment:` keys.
For what the container needs, see [Required Configuration](https://docs.mindroom.chat/deployment/#required-configuration).
Set `MATRIX_SSL_VERIFY=false` for a homeserver with a self-signed certificate, and set `MINDROOM_API_KEY` to require a key for the dashboard instead of leaving it open.
All variables, defaults, and which ones only the process environment can set are listed in [Configuration — Environment Variables](https://docs.mindroom.chat/configuration/#environment-variables).

To change the API port or bind address, pass `--api-port` or `--api-host` to `mindroom run`, for example `command: ["mindroom", "run", "--api-port", "9000"]` in the Compose service, and update the published port to match.
`MINDROOM_PORT` does not change the bind port.

## With Local Matrix

For development, start the repository's local Matrix stack (Synapse on port 8008, with PostgreSQL and Redis) and then MindRoom:

```bash
docker compose -f local/matrix/docker-compose.yml up -d
curl -s http://localhost:8008/_matrix/client/versions
docker compose up -d
```

`MATRIX_HOMESERVER` must resolve from inside the MindRoom container, and the two Compose projects do not share a network automatically.
Attach both services to one named external network and use the Synapse service name, such as `http://mindroom-synapse:8008`, or configure an explicit host-gateway address.
Do not use `http://localhost:8008` inside the MindRoom container, because there `localhost` is the MindRoom container itself.

To run the backend on the host instead of in a container, use [`mindroom local-stack-setup`](https://docs.mindroom.chat/cli/#local-stack-setup) to start Synapse and MindRoom Chat and save the Matrix variables:

```bash
mindroom local-stack-setup --synapse-dir /path/to/mindroom/local/matrix
mindroom run
```

## Health Checks

```bash
curl http://localhost:8765/api/health
curl http://localhost:8765/api/ready
```

Use `/api/health` for liveness and `/api/ready` for readiness; responses are described in [Health & Readiness](https://docs.mindroom.chat/deployment/operational-log-events/#health-readiness).
A hosted container waiting for pairing approval is live but not ready; find the approval link with `docker logs mindroom`.

## Building from Source

Build from the repository root:

```bash
docker build -t mindroom:dev -f local/instances/deploy/Dockerfile.mindroom .
```

`local/instances/deploy/Dockerfile.mindroom-minimal`, published as `ghcr.io/mindroom-ai/mindroom-minimal`, builds a smaller image without pre-installed tool extras, useful for sandbox runners; tools install their dependencies when first used.
To run execution tools in isolated workers, see [Sandbox Proxy Isolation](https://docs.mindroom.chat/deployment/sandbox-proxy/).

# Kubernetes Deployment

This page covers running MindRoom on Kubernetes with Helm: the available charts, a runtime-only install, worker backends for isolated tool execution, and backup and restore.
For the hosted multi-tenant platform (platform and instance charts), see [SaaS Platform](https://docs.mindroom.chat/deployment/saas-platform/).

## Charts

| Chart | Path | Use |
|-------|------|-----|
| Runtime | `cluster/k8s/runtime/` | MindRoom runtime only, for clusters that provide Matrix, storage, secrets, ingress, and platform services |
| Tuwunel | `cluster/k8s/tuwunel/` | Standalone Tuwunel homeserver (MindRoom fork) to pair with the runtime chart |
| Client | `cluster/k8s/client/` | Standalone MindRoom web client behind unprivileged nginx, for clusters that already provide a homeserver, ingress, and TLS |
| MatrixRTC | `cluster/k8s/matrixrtc/` | Optional LiveKit SFU and MatrixRTC authorization service for [voice calls](https://docs.mindroom.chat/voice-calls/), proxied by the client chart |
| Instance | `cluster/k8s/instance/` | One hosted MindRoom runtime with bundled dashboard/API and Matrix/Synapse, deployed by the SaaS platform |
| Platform | `cluster/k8s/platform/` | SaaS control plane (API, frontend, provisioner) |

## Prerequisites

- A Kubernetes cluster (tested with k3s via kube-hetzner)
- `kubectl` and `helm`
- NGINX Ingress Controller
- cert-manager for TLS certificates
- Hetzner Cloud Controller Manager and Hetzner CSI Driver when using `hcloud-volumes`

For Hetzner sizing, see [Hetzner Scaling Baseline](https://docs.mindroom.chat/deployment/saas-platform/#hetzner-scaling-baseline).

## Runtime-Only Deployment

Use the runtime chart when you already operate the surrounding platform and only want Kubernetes to run the MindRoom runtime.
It does not create Matrix, ingress, a model gateway, or platform services.
For a chart-managed homeserver, deploy the Tuwunel chart alongside it and point `matrix.homeserverUrl`, `matrix.serverName`, and `matrix.registrationToken` at it as described in `cluster/k8s/tuwunel/README.md`.

```bash
helm upgrade --install mindroom-runtime ./cluster/k8s/runtime \
  --namespace mindroom \
  --create-namespace \
  -f runtime-values.yaml
```

Typical production values point at existing resources:

```yaml
config:
  create: false
  existingConfigMap: mindroom-config
  key: config.yaml

storage:
  create: false
  existingClaim: mindroom-data

matrix:
  homeserverUrl: http://matrix.example.svc.cluster.local:8008
  serverName: example.com
  registrationToken:
    existingSecret: mindroom-secrets
    key: MATRIX_REGISTRATION_TOKEN

env:
  envFrom:
    - secretRef:
        name: mindroom-secrets

workers:
  backend: kubernetes
  sandbox:
    proxyToken:
      existingSecret: mindroom-sandbox-proxy
      key: MINDROOM_SANDBOX_PROXY_TOKEN
```

The runtime chart reads provider API keys from Kubernetes Secrets listed in `providerCredentials` (see [Provider API Keys from Kubernetes Secrets](https://github.com/mindroom-ai/mindroom/blob/main/cluster/k8s/runtime/README.md#provider-api-keys-from-kubernetes-secrets)).
Its `env` value is a map with `extra` and `envFrom`, not a list of environment variables.
The hosted instance chart instead mounts provider keys as files under `/etc/secrets/` and sets the matching [`*_API_KEY_FILE`](https://docs.mindroom.chat/configuration/models/#file-based-secrets) variables.

For passwordless agent accounts ([`appservice` mode](https://docs.mindroom.chat/matrix/#agent-users)), set `matrix.managedAccountAuth: appservice` and point `matrix.appserviceToken` at a Secret holding the registration's `as_token`, and leave `matrix.registrationToken` unset (see [Matrix Managed Account Authentication](https://github.com/mindroom-ai/mindroom/blob/main/cluster/k8s/runtime/README.md#matrix-managed-account-authentication)).
On the Tuwunel chart, mount the complete registration YAML from a Secret through `tuwunel.appserviceRegistration`, whose `key` is also the mounted filename and must end in `.yaml` or `.yml`, and optionally disable password login with `login_with_password: false` in `tuwunel.settings` (see [Passwordless MindRoom Accounts](https://github.com/mindroom-ai/mindroom/blob/main/cluster/k8s/tuwunel/README.md#passwordless-mindroom-accounts)).

### Chart Reference

Each chart's README documents its full values: [runtime](https://github.com/mindroom-ai/mindroom/blob/main/cluster/k8s/runtime/README.md), [Tuwunel](https://github.com/mindroom-ai/mindroom/blob/main/cluster/k8s/tuwunel/README.md), [client](https://github.com/mindroom-ai/mindroom/blob/main/cluster/k8s/client/README.md), and [MatrixRTC](https://github.com/mindroom-ai/mindroom/blob/main/cluster/k8s/matrixrtc/README.md).
Optional features:

- [Background script gateway](https://github.com/mindroom-ai/mindroom/blob/main/cluster/k8s/runtime/README.md#background-script-gateway) (`scriptGateway`) lets dedicated workers run [background scripts](https://docs.mindroom.chat/tools/background-scripts/).
- [Content bundles](https://github.com/mindroom-ai/mindroom/blob/main/cluster/k8s/runtime/README.md#content-bundles) (`contentBundles`, `config.bootstrapContentBundle`) ship config, plugins, and skills as digest-pinned images, including how to update a bootstrapped config.
- [Session and knowledge storage](https://github.com/mindroom-ai/mindroom/blob/main/cluster/k8s/runtime/README.md#session-and-knowledge-storage) (`sessionStorage`, `knowledgeStorage`) and [runtime state storage](https://github.com/mindroom-ai/mindroom/blob/main/cluster/k8s/runtime/README.md#runtime-state-storage) (`stateStorage`, `stateStorage.extraSubPaths`) move data to volumes of their own; on an existing install, copy the data first.
- [Layering values files](https://github.com/mindroom-ai/mindroom/blob/main/cluster/k8s/runtime/README.md#layering-values-files) explains the map form of `env.extra`, `env.envFrom`, the Agent Vault server's `extraEnv` and `envFrom`, `extraVolumes`, and `extraVolumeMounts`, which Helm merges key by key across values files, and lists which env values are rendered with `tpl`.
- [Approved Egress](https://docs.mindroom.chat/deployment/approved-egress/) gives workers human-approved temporary hostname grants.
- [Agent Vault server NetworkPolicy](https://github.com/mindroom-ai/mindroom/blob/main/cluster/k8s/runtime/README.md#agent-vault-server-networkpolicy) restricts the chart-managed vault, and [`jobNaming: contentHash`](https://github.com/mindroom-ai/mindroom/blob/main/cluster/k8s/runtime/README.md#agent-vault-access-grants) reruns its Jobs under `kubectl apply` workflows.
- Tuwunel [structured settings](https://github.com/mindroom-ai/mindroom/blob/main/cluster/k8s/tuwunel/README.md#structured-settings) (`tuwunel.settings`) merge across values files, and [upgrades and database migrations](https://github.com/mindroom-ai/mindroom/blob/main/cluster/k8s/tuwunel/README.md#upgrades-and-database-migrations) explains how to upgrade the homeserver without corrupting its database.
- The client chart's [`matrixRTC` values](https://github.com/mindroom-ai/mindroom/blob/main/cluster/k8s/client/README.md#matrixrtc-calls) announce and proxy the MatrixRTC chart's call backend.
- [Operational log events](https://docs.mindroom.chat/deployment/operational-log-events/) feed log-based metrics and alerts once `MINDROOM_LOG_FORMAT=json` is set through `env.extra`.

## Worker Backends

Worker-routed tools such as `coding`, `docker`, `file`, `python`, and `shell` run in one of two backends.
Which tools are routed and how `worker_scope` shares workers are described in [Sandbox Proxy Isolation](https://docs.mindroom.chat/deployment/sandbox-proxy/).

| Helm value | Behavior | Best for |
|------------|----------|----------|
| `workers.backend: static_runner` (default) | One shared sandbox-runner sidecar inside the MindRoom pod | Simpler deployments |
| `workers.backend: kubernetes` | Dedicated worker Deployments and Services created on demand | Isolation between agents or users |

The hosted instance chart supports only `static_runner` (`workerBackend`) and fails rendering for any other backend, because its tenants share one namespace.
Deploy the runtime chart in a namespace of its own when an instance needs dedicated workers.

### Shared Sidecar Mode

The primary runtime reaches the sidecar over `localhost`, and all proxied tool calls share one runner process.
The sidecar shares agent workspaces with the primary but cannot read the credential store, Matrix state, or the credentials encryption key, and it is not an isolation boundary between agents.
To enable encrypted credential storage, set `workers.sandbox.credentialsEncryptionKey.existingSecret`; only the primary runtime receives that key.
See [Kubernetes shared sidecar](https://docs.mindroom.chat/deployment/sandbox-proxy/#kubernetes-shared-sidecar-workerbackend-static_runner) for its mounts and limits.

### Dedicated Worker Mode

With `workers.backend: kubernetes`, the primary runtime creates a worker Deployment and Service per worker on demand and routes tool calls to it.
Idle workers scale to zero, keeping agent data and worker caches.
Each worker keeps its own caches and virtualenvs under `workers.kubernetes.storageSubpathPrefix`.
[Worker scopes](https://docs.mindroom.chat/deployment/sandbox-proxy/#worker-scopes) describes which workspaces each worker mounts, and [Credential leases](https://docs.mindroom.chat/deployment/sandbox-proxy/#credential-leases) describes how tool settings reach workers.
`google_vertex_adc` tools must run in the primary runtime, because workers receive no Google application default credentials.

Typical Helm values:

```yaml
storage:
  accessModes:
    - ReadWriteMany
workers:
  backend: kubernetes
  cleanupIntervalSeconds: 30
  sandbox:
    proxyToken:
      existingSecret: mindroom-sandbox-proxy
      key: MINDROOM_SANDBOX_PROXY_TOKEN
  kubernetes:
    idleTimeoutSeconds: 1800
    readyTimeoutSeconds: 60
    runtimeClassName: ""
```

#### Worker settings

The runtime chart sets these environment variables from its values; set them directly when deploying the backend without Helm.
Further options, such as extra labels, annotations, volumes, containers, and the seccomp profile, are in `cluster/k8s/runtime/values.yaml` and `src/mindroom/workers/backends/kubernetes_config.py`.

| Helm value | Environment variable | Default | Effect |
|------------|----------------------|---------|--------|
| `workers.kubernetes.image` | `MINDROOM_KUBERNETES_WORKER_IMAGE` | Chart: the main MindRoom image; required without Helm | Worker image |
| `workers.kubernetes.namespace` | `MINDROOM_KUBERNETES_WORKER_NAMESPACE` | Chart: the release namespace; otherwise the pod namespace or `default` | Namespace for worker resources |
| _(storage claim)_ | `MINDROOM_KUBERNETES_WORKER_STORAGE_PVC_NAME` | Chart: the runtime storage claim; required without Helm | PVC holding agent workspaces |
| `storage.mountPath` | `MINDROOM_KUBERNETES_WORKER_STORAGE_MOUNT_PATH` | Chart: `/app/agent_data`; otherwise `/app/worker` | Where workers mount storage |
| `workers.kubernetes.storageSubpathPrefix` | `MINDROOM_KUBERNETES_WORKER_STORAGE_SUBPATH_PREFIX` | `workers` | Storage directory for worker-local caches |
| `workers.kubernetes.port` | `MINDROOM_KUBERNETES_WORKER_PORT` | `8766` | Internal Service and container port |
| `workers.kubernetes.readyTimeoutSeconds` | `MINDROOM_KUBERNETES_WORKER_READY_TIMEOUT_SECONDS` | `60` | How long the primary waits for a worker to become ready |
| `workers.kubernetes.idleTimeoutSeconds` | `MINDROOM_KUBERNETES_WORKER_IDLE_TIMEOUT_SECONDS` | `1800` | Idle time before a worker may scale to zero |
| `workers.cleanupIntervalSeconds` | `MINDROOM_WORKER_CLEANUP_INTERVAL_SECONDS` | Chart: `30`; otherwise `0` (no automatic cleanup) | How often idle-worker cleanup runs |
| `workers.kubernetes.reconcilePodTemplates` | `MINDROOM_KUBERNETES_WORKER_RECONCILE_POD_TEMPLATES` | `true` | Recreate workers whose image, env, or resources changed |
| `workers.kubernetes.namePrefix` | `MINDROOM_KUBERNETES_WORKER_NAME_PREFIX` | `mindroom-worker` | Prefix for worker resource names |
| `workers.kubernetes.runtimeClassName` | `MINDROOM_KUBERNETES_WORKER_RUNTIME_CLASS_NAME` | Cluster default | RuntimeClass for all dedicated workers, including background-script workers |
| `workers.kubernetes.resources` | `MINDROOM_KUBERNETES_WORKER_CPU_REQUEST`, `_CPU_LIMIT`, `_MEMORY_REQUEST`, `_MEMORY_LIMIT` | `100m`, `500m`, `256Mi`, `1Gi` | Worker container resources |
| `workers.kubernetes.scriptResources` | `MINDROOM_KUBERNETES_DEFAULT_SCRIPT_RESOURCE_PROFILE`, `MINDROOM_KUBERNETES_SCRIPT_RESOURCE_PROFILES_JSON` | `small`; profiles in `values.yaml` | The `small`, `standard`, and `large` [background script](https://docs.mindroom.chat/tools/background-scripts/) resource profiles, all three required |
| `workers.kubernetes.nodeName`, `colocateWithControlPlaneNode` | `MINDROOM_KUBERNETES_WORKER_NODE_NAME`, `MINDROOM_KUBERNETES_WORKER_COLOCATE_WITH_CONTROL_PLANE_NODE` | Unset, `false` | Pin workers to one node (see [Storage Requirements](#storage-requirements)) |
| `workers.kubernetes.extraEnv` | `MINDROOM_KUBERNETES_WORKER_ENV_JSON` | `{}` | Extra environment variables for worker pods |
| _(`env.extra`)_ | `MINDROOM_KUBERNETES_WORKER_TMP_SIZE_LIMIT` | `1Gi` | Size of each worker's `/tmp` |
| _(`env.extra`)_ | `MINDROOM_KUBERNETES_WORKER_USER_RESOURCES_JSON` | `{}` | Per-requester worker resources |

Changing the image, environment, resources, or RuntimeClass recreates scaled-down workers on the next cleanup pass and running workers on their next use, so no manual recycling is needed; finish active work before changing them.
Before selecting a RuntimeClass, verify that its handler is available on every eligible worker node and supports the configured storage driver, access mode, and mount behavior.

Each worker's `/tmp` is disk-backed, and exceeding its size limit evicts that worker pod rather than refusing writes.
`MINDROOM_KUBERNETES_WORKER_TMP_SIZE_LIMIT` (for example `4Gi`) also requests and limits the same amount of container `ephemeral-storage`, which clusters such as GKE Autopilot otherwise cap at 1 GiB.
Tool subprocesses already get a `TMPDIR` on persistent storage, so only explicit `/tmp` paths use this space.

`MINDROOM_KUBERNETES_WORKER_USER_RESOURCES_JSON` maps a Matrix user ID to `requests` and/or `limits` with `cpu` and/or `memory`, for example `{"@alice:example.org": {"limits": {"memory": "4Gi"}}}`.
Listed quantities replace the global values for that requester's `user` and `user_agent` workers; shared and unscoped workers, other requesters, and background-script profiles are unchanged.

`workers.kubernetes.extraEnv` drops MindRoom's own control variables and vendor telemetry variables, except `MINDROOM_SANDBOX_RUNNER_SUBPROCESS_TIMEOUT_SECONDS`, which tunes runner subprocess timeouts.
Workers do not inherit the runtime's `.env` or provider keys; for per-workspace variables such as `PATH` entries or package indexes, use the [workspace env hook](https://docs.mindroom.chat/deployment/sandbox-proxy/#workspace-env-hook-mindroomworker-envsh), which takes effect without redeploying.

### Knowledge Source Visibility

Dedicated workers can read the knowledge-base source directories assigned to the agents they serve, mounted read-only at the same path relative to the worker storage mount.
A `user` worker sees the knowledge of every `worker_scope: user` agent; other workers see only their own agent's.

| Deployment | Worker storage mount | Visible path for `knowledge/reference` |
| --- | --- | --- |
| Runtime chart (`storage.mountPath`) | `/app/agent_data` | `/app/agent_data/knowledge/reference` |
| Direct backend without a mount override | `/app/worker` | `/app/worker/knowledge/reference` |

The worker sees the complete source directory, including files excluded from indexing, and MindRoom does not copy or clone it per agent.
Only sources inside the shared storage root are mounted.
A source inside a workspace the worker already mounts stays reachable through that writable workspace mount.
Sources that are missing, reached through a link, or inside another agent's workspace, a private instance, or a worker directory are skipped with a logged warning or error.
A knowledge source that overlaps another knowledge source or another worker mount prevents the worker from starting.
Changing knowledge assignments recreates the affected workers.

### Storage Requirements

Dedicated workers mount the runtime's storage PVC.
Set `storage.accessModes` to `ReadWriteMany` so several workers can use it at once.
If your storage class only supports `ReadWriteOnce`, keep the runtime and workers on one node: set `workers.kubernetes.colocateWithControlPlaneNode: true`, or set `workers.kubernetes.nodeName` and pin the runtime to that same node with `nodeSelector` or `affinity`.

### RBAC And Network Policy

With `workers.backend: kubernetes`, the runtime chart creates:

- A worker-manager ServiceAccount for the primary runtime.
- A Role and RoleBinding that manage worker Deployments and Services in the worker namespace.
- Access to worker auth Secrets: one chart-created Secret in the release namespace, or per-worker Secrets when `workers.kubernetes.namespace` names a separate namespace.
- NetworkPolicy rules that let the primary reach the worker port and deny worker-to-worker runner traffic.

That Role reaches every Deployment and Service in its namespace, so give each runtime release a namespace of its own.
Label that namespace with `pod-security.kubernetes.io/enforce=baseline` so admission rejects privileged containers, host namespaces, and `hostPath` volumes; the chart's runtime and worker pods satisfy that profile.

### Operations

To list active and idle workers or run an idle-worker cleanup pass, use the dashboard's [Workers API](https://docs.mindroom.chat/dashboard/#workers-api).

## Backup and Restore

This section covers the runtime chart and the Tuwunel chart.
Several volumes and Secrets only work together, so back them up and restore them as units.

### What each volume holds

| Volume | Values | Contents |
|--------|--------|----------|
| `<fullname>-storage` | `storage` | The storage root (`MINDROOM_STORAGE_PATH`, default `/app/agent_data`): `matrix_state.yaml` with each agent's Matrix account and access token; `tracking/`; `credentials/` and `private_oauth/`; agent and team sessions, learning, memory, and workspaces; `control_state/`; `knowledge_db/` and `knowledge_git/`; `logs/`; and `encryption_keys/` and `sync_continuity/` unless `stateStorage` is enabled |
| `<fullname>-state` | `stateStorage` | When enabled, `encryption_keys/` (each agent's Matrix device store with its E2EE keys and sync position) and `sync_continuity/`, plus each `stateStorage.extraSubPaths` directory, such as `tracking/` |
| `<fullname>-sessions` | `sessionStorage` | When enabled, the agent and team session databases (`MINDROOM_SESSION_STORAGE_PATH`) |
| `<fullname>-knowledge` | `knowledgeStorage` | When enabled, `knowledge_db/` |
| `<volumeName>-<fullname>-event-cache-postgres-0`, default `data-<fullname>-event-cache-postgres-0` | `eventCache.postgres.persistence` | The PostgreSQL event journal, which records which messages were already answered |
| `<server.name>-data`, default `agent-vault-data` | `workers.kubernetes.agentVault.server.persistence` | The chart-managed Agent Vault server's data |
| `<approved egress name>-data`, default `<fullname>-egress-proxy-data` | `approvedEgress.persistence` | Temporary hostname grants of the approved egress proxy |
| Tuwunel `<fullname>-data` | Tuwunel chart `storage` | The homeserver's RocksDB database and its `media/` directory |

Back up the sessions volume together with the storage claim.
[Data Persistence](https://docs.mindroom.chat/deployment/storage/#data-persistence) describes the storage root's directories in more detail.

### Restore units

Restore each of these sets together, from the same point in time:

- **Credentials encryption key and credential stores.**
  With `MINDROOM_CREDENTIALS_ENCRYPTION_KEY` set (for example through `workers.sandbox.credentialsEncryptionKey.existingSecret`), credential files and OAuth stores are encrypted under that key.
  Under a different key MindRoom logs `Failed to load encrypted credentials` and will not overwrite them, so back up the key's Secret value with the storage claim.
- **Agent Vault volume and its bootstrap Secret.**
  The Secret named by `workers.kubernetes.agentVault.bootstrapSecretName`, with its `AGENT_VAULT_MASTER_PASSWORD` and `AGENT_VAULT_OWNER_PASSWORD`, must accompany every vault snapshot.
- **Event journal, `tracking/`, and Matrix device stores.**
  The journal, `tracking/event_journal_binding.json`, `encryption_keys/`, `matrix_state.yaml`, and `sync_continuity/` must come from the same moment, or startup refuses the journal or the device stores.
  A restored chart-managed PostgreSQL volume keeps its original password, while the chart generates a new one when `<fullname>-event-cache-postgres-auth` is missing, so back up that password too.
  To reuse it, set `eventCache.postgres.auth.password`, or set both `eventCache.postgres.auth.existingSecret` and `eventCache.databaseUrl.existingSecret` to a Secret holding the password and the matching connection URL.
- **Homeserver and runtime Matrix state.**
  If the homeserver no longer knows an agent's stored access token or device, that agent does not start.
  If the runtime snapshot is older than the homeserver, encryption keys received since then are lost and agents may answer messages again.
  When both are lost, restore both from the same point.

### Snapshot consistency

Volume snapshots taken at different times while MindRoom runs are not a consistent restore set.
Take them from one quiet point:

1. Scale the runtime Deployment to zero and wait until its Pod has terminated, then scale dedicated worker Deployments (label `mindroom.ai/worker-id`) to zero and wait until their Pods have terminated, because workers write to the storage claim.
2. Snapshot the storage claim, the sessions claim if enabled, the state claim, and the event journal volume, or dump the journal database with `pg_dump`.
   When the homeserver belongs to the same restore set, back it up in this window too.
3. Scale the runtime back up; it recreates workers on demand.

A storage-level group snapshot that captures all these volumes at the same instant is also safe without stopping MindRoom.
The Agent Vault, approved egress, and knowledge volumes can be snapshotted on their own schedules.
For the Tuwunel volume, use an offline snapshot or Tuwunel's own database backups, which do not include media; see the fork's [backup guide](https://github.com/mindroom-ai/mindroom-tuwunel/blob/main/docs/backups.md).

### Rebuildable data

- `knowledge_db/` holds only knowledge indexes.
  A missing index is rebuilt in the background the next time the base is used, repeating every embedding call, and agents report the base as initializing meanwhile.
  The sources, including files uploaded through the dashboard, are not rebuildable unless they come from Git, in which case they are cloned again from the configured repository.
- `logs/` only holds runtime log files.
- The approved egress volume only holds temporary grants, which expire after at most `approvedEgress.maxTtlSeconds`.

Everything else on the storage, sessions, state, and journal volumes is not rebuildable.

### Restoring

1. Restore the volumes before installing the charts, then point the charts at them with `storage.existingClaim`, `stateStorage.existingClaim`, `sessionStorage.existingClaim`, `knowledgeStorage.existingClaim`, `workers.kubernetes.agentVault.server.persistence.existingClaim`, `approvedEgress.persistence.existingClaim`, and the Tuwunel chart's `storage.existingClaim`.
   The chart-managed PostgreSQL adopts a claim restored under `<volumeName>-<fullname>-event-cache-postgres-0`; set its original password as described in [Restore units](#restore-units).
   With an external database, restore the database and keep its URL Secret.
   To restore a `pg_dump` instead, install with a new journal volume and `replicaCount: 0`, load the dump, then set `replicaCount` back to 1.
2. Recreate the Secrets with their original values, including the credentials encryption key, the Agent Vault bootstrap Secret, and the Matrix registration token or application-service registration.
3. Start Tuwunel first, then the runtime.

If the runtime log shows `The bound Matrix device store is missing`, `Matrix stream changed`, or `IngestionBatchSequenceError`, the device stores do not match the journal; an event journal binding error means `tracking/` and the journal database do not match.
Restore the matching backups instead of deleting state, because automatic device replacement is unsupported.
See [Moving a journal safely](https://docs.mindroom.chat/deployment/storage/#moving-a-journal-safely) for copying or adopting a journal database, and [Device recovery after the upgrade](https://docs.mindroom.chat/deployment/upgrades/#device-recovery-after-the-upgrade) for device-store recovery.

See [SaaS Platform](https://docs.mindroom.chat/deployment/saas-platform/#secrets-management) and [Database Migrations](https://docs.mindroom.chat/deployment/saas-platform/#database-migrations).

# Sandbox Proxy Isolation

Code-execution tools such as `shell`, `file`, and `python` can read and modify anything their process can reach, including config files, credentials, and application code.
The **sandbox proxy** runs these tools in a separate worker runtime that has none of the primary runtime's secrets.
Use it whenever agents may run untrusted code; without it, the operator trusts every agent with code-execution tools completely.
This page covers the worker backends and how to deploy them, which tools run in workers, worker scopes and isolation, the shell environment inside workers, credential leases, brokered egress, and the environment variable reference.

## How it works

When an agent calls a worker-routed tool, the primary runtime forwards the call to the worker selected by the backend and the agent's [worker scope](#worker-scopes), and the worker returns the result.
All other tools, such as API and Matrix tools, run in the primary runtime, so one agent can use both.
Each agent's system prompt includes a short **Tool Execution Environment** section that lists which of its tools run locally and which run in a worker.

Select the worker backend with `MINDROOM_WORKER_BACKEND`:

| Backend | What it runs | Isolation between agents |
|---------|--------------|--------------------------|
| `static_runner` | One shared sandbox-runner process, usually a sidecar container or a local HTTP service | None: all calls share one process and filesystem |
| `docker` | One dedicated worker container per worker key, created on demand by the primary runtime | Each worker sees only the workspaces of its worker key |
| `kubernetes` | One dedicated worker pod per worker key, created on demand by the primary runtime | Each worker sees only the workspaces of its worker key |

Requests to workers are authenticated with `MINDROOM_SANDBOX_PROXY_TOKEN`.
Docker and Kubernetes workers each get their own token derived from it, so a compromised worker token cannot be used against another worker.
Credentials reach workers only through short-lived [credential leases](#credential-leases), never through tool arguments or the model prompt.

## Deployment modes



### Docker Compose (`static_runner`)

Add a `sandbox-runner` service that uses the same image as MindRoom, with a different entrypoint and no access to `.env` or the primary data volume.
This shared runner gives tools scratch storage only and cannot see the agent workspaces under `agents/<agent>/workspace`, so use [dedicated Docker workers](#host-machine-dedicated-docker-workers-mindroom_worker_backenddocker) when file or shell work must persist in the agent workspace.

```yaml
services:
  mindroom:
    image: ghcr.io/mindroom-ai/mindroom:latest
    env_file: .env
    volumes:
      - ./config.yaml:/app/config.yaml:ro
      - ./mindroom_data:/app/mindroom_data
    environment:
      - MINDROOM_WORKER_BACKEND=static_runner
      - MINDROOM_SANDBOX_PROXY_URL=http://sandbox-relay:8766
      - MINDROOM_SANDBOX_PROXY_TOKEN=${MINDROOM_SANDBOX_PROXY_TOKEN}
      - MINDROOM_SANDBOX_EXECUTION_MODE=selective
      - MINDROOM_SANDBOX_PROXY_TOOLS=shell,file,python
    networks:
      - mindroom-network

  sandbox-runner:
    image: ghcr.io/mindroom-ai/mindroom:latest
    command: ["/app/run-sandbox-runner.sh"]
    user: "1000:1000"
    volumes:
      - sandbox-workspace:/app/workspace
    environment:
      - MINDROOM_SANDBOX_RUNNER_MODE=true
      - MINDROOM_SANDBOX_PROXY_TOKEN=${MINDROOM_SANDBOX_PROXY_TOKEN}
      - MINDROOM_STORAGE_PATH=/app/workspace/.mindroom
    networks:
      - sandbox-network

  # Forwards only the runner port, so the runner shares no network with MindRoom.
  sandbox-relay:
    image: busybox:1.36
    user: "65534:65534"
    cap_drop:
      - ALL
    sysctls:
      net.ipv4.ip_forward: 0
    command: ["tcpsvd", "-c", "256", "-C", "128", "0.0.0.0", "8766", "nc", "sandbox-runner", "8766"]
    networks:
      - mindroom-network
      - sandbox-network

volumes:
  sandbox-workspace:

networks:
  mindroom-network:
  sandbox-network:
```

Keep these properties when you adapt the example:

- Do not give the runner an `env_file` or mount the `mindroom_data` tree, which holds API keys, credentials, Matrix encryption keys, sessions, and logs.
- Do not attach the runner to a network shared with MindRoom, its homeserver, or its databases, because tool code could then call those services directly.
- Keep the per-address connection cap (`-C`) on the relay so tool code cannot use up every relay slot MindRoom needs, and keep IP forwarding disabled on the relay.
- Set `MINDROOM_API_KEY` on MindRoom, because the runner keeps outbound access and can still reach MindRoom through published host ports or its public URL.
- Point `MINDROOM_STORAGE_PATH` at a writable location inside the scratch volume.

> [!IMPORTANT]
> The `sandbox-workspace` Docker volume is created as root by default.
> The runner runs as UID 1000, so fix ownership after first creating the volume:
> ```bash
> docker compose run --rm --user root --entrypoint chown sandbox-runner -R 1000:1000 /app/workspace
> ```
> Alternatively, omit the `user:` directive to run as root (less secure).

### Host machine + Docker sandbox container

Run MindRoom directly on the host while code-execution tools run in one shared Docker container:

```bash
# 1. Start the sandbox runner container
export MINDROOM_SANDBOX_PROXY_TOKEN="$(openssl rand -hex 32)"
docker run -d \
  --name mindroom-sandbox-runner \
  -p 127.0.0.1:8766:8766 \
  -e MINDROOM_WORKER_BACKEND=static_runner \
  -e MINDROOM_SANDBOX_RUNNER_MODE=true \
  -e MINDROOM_SANDBOX_PROXY_TOKEN="$MINDROOM_SANDBOX_PROXY_TOKEN" \
  -e MINDROOM_STORAGE_PATH=/app/workspace/.mindroom \
  ghcr.io/mindroom-ai/mindroom:latest \
  /app/run-sandbox-runner.sh

# 2. Start MindRoom on the host with proxy config
export MINDROOM_WORKER_BACKEND=static_runner
export MINDROOM_SANDBOX_PROXY_URL=http://localhost:8766
export MINDROOM_SANDBOX_EXECUTION_MODE=selective
export MINDROOM_SANDBOX_PROXY_TOOLS=shell,file,python
mindroom run
```

You can put the same `MINDROOM_*` variables in your `.env` file instead of exporting them.

The runner container needs no config file.
Do not mount your `config.yaml` or its `.env` into it, because tool code there can read everything the container can.
Install proxied plugin tools in the runner image, or mount only the plugin directory at the path the config's plugin entry resolves to.



### Host machine + dedicated Docker workers (`MINDROOM_WORKER_BACKEND=docker`)

Use this to run the primary runtime on the host, including with `uvx mindroom run`, and give each worker key its own persistent Docker container, without Kubernetes.
A container is reused until its launch configuration changes.
Idle containers keep running unless `MINDROOM_WORKER_CLEANUP_INTERVAL_SECONDS` is set to a positive interval, which then stops containers idle longer than `MINDROOM_DOCKER_WORKER_IDLE_TIMEOUT_SECONDS`.

MindRoom installs the optional `docker` extra the first time this backend is used.
If you disable auto-install with `MINDROOM_NO_AUTO_INSTALL_TOOLS=1`, install it with `uv sync --extra docker` in a source checkout or `pip install 'mindroom[docker]'`.

Use the published image tag that matches your MindRoom version, or build a worker image from the checkout MindRoom runs from:

```bash
docker build -t mindroom:dev -f local/instances/deploy/Dockerfile.mindroom .
```

Set the backend environment in your shell or `.env`:

```bash
export MINDROOM_WORKER_BACKEND=docker
export MINDROOM_DOCKER_WORKER_IMAGE=mindroom:dev
export MINDROOM_SANDBOX_PROXY_TOKEN=replace-me-with-a-long-random-token

# Optional but useful for local debugging.
export MINDROOM_DOCKER_WORKER_NAME_PREFIX=mindroom-worker
export MINDROOM_DOCKER_WORKER_PUBLISH_HOST=127.0.0.1
export MINDROOM_DOCKER_WORKER_READY_TIMEOUT_SECONDS=60
```

Then route tools into workers and choose a [worker scope](#worker-scopes):

```yaml
defaults:
  worker_tools: [shell, file, python]
  worker_scope: shared

agents:
  code:
    tools: [shell, file, python]

  research:
    tools: [shell, file, python]
```

To verify, ask two agents to run `hostname`, then list the worker containers:

```bash
docker ps --format '{{.Names}}\t{{.ID}}' | grep '^mindroom-worker'
```

Each worker gets a read-only copy of the [live config snapshot](#live-config-snapshots) built from `MINDROOM_DOCKER_WORKER_HOST_CONFIG_PATH`, together with the config-relative plugins and knowledge it needs.
Agent-scoped workers get only their agent and its assigned knowledge bases, while `worker_scope: user` workers get every agent that shares them.
Workers never see `~/.mindroom` or the config-adjacent `.env`.
The called tool's own inline overrides still reach the worker, but if it needs a secret from `.env` or from elsewhere in `config.yaml`, provide it through a [credential lease](#credential-leases), [environment passthrough](#shell-environment-and-path), or `MINDROOM_DOCKER_WORKER_ENV_JSON` instead.
Because worker code can reach the host network, `mindroom run` adds a generated `MINDROOM_API_KEY` to `.env` when a dedicated worker backend is configured and the dashboard has no credential; an explicitly empty `MINDROOM_API_KEY=` keeps open access.

### Kubernetes shared sidecar (`workerBackend: static_runner`)

In Kubernetes the shared runner runs as a second container in the MindRoom pod and is reached over `localhost`.
This is the default `static_runner` mode of both Helm charts; see [Kubernetes Deployment](https://docs.mindroom.chat/deployment/kubernetes/#shared-sidecar-mode) for the chart values and `cluster/k8s/instance/templates/deployment-mindroom.yaml` for the manifest.

The sidecar mounts only these parts of the storage PVC:

- The `agents` and `private_instances` directories, read-write at their usual paths, so agent workspaces persist and stay shared with the primary runtime.
- Its own `sandbox-runner` directory as its storage root, for worker-local files, virtualenvs, and caches.

It gets no config file and resolves agents from the [live config snapshot](#live-config-snapshots), so agents added or changed after the pod started work immediately.
It cannot read the credential store, Matrix encryption keys and access tokens, or other primary state, and it never receives the credentials-encryption key.
The runtime chart rejects a file-sourced config inside `agents`, `private_instances`, or `sandbox-runner`, because the sidecar can write those directories.

> [!WARNING]
> The sidecar protects the primary runtime's secrets and state, but it is not an isolation boundary between agents.
> All proxied tool calls share one runner process and user, and the sidecar sees every agent's state directory, including workspaces, sessions, learning data, and memory.
> The sidecar also shares the pod network namespace, so the primary API must require authentication that tool code cannot forge, such as platform authentication, `MINDROOM_API_KEY`, or trusted-upstream authentication with `requireJwt`; header-only trusted-upstream authentication is forgeable from the sidecar.
> Without Supabase authentication, both charts give the primary a generated `MINDROOM_API_KEY`.
> Use dedicated Kubernetes workers through the runtime chart when per-agent filesystem and credential isolation are required.

### Kubernetes dedicated workers (`workers.backend: kubernetes`)

In this mode the primary runtime creates a worker Deployment and Service per worker key on demand, and scales idle workers to zero while keeping agent data and worker caches.
See [Kubernetes Deployment](https://docs.mindroom.chat/deployment/kubernetes/#dedicated-worker-mode) for chart support, Helm values, storage access modes, RBAC, and NetworkPolicy.

Dedicated workers mount no config and resolve agents from the [live config snapshot](#live-config-snapshots).
They never receive the credentials-encryption key, so tool settings reach them only through [credential leases](#credential-leases).
Worker pods can reach the primary API over the pod network, so the runtime chart gives the primary a generated `MINDROOM_API_KEY` that workers never receive, unless the explicit opt-out is configured.

### Keeping worker versions matched

Run the same MindRoom release in the primary runtime and in every runner and worker image.
MindRoom refuses dedicated Docker worker images whose protocol does not match the primary runtime; it pulls the configured image once and recreates the worker before failing.
If the pull fails, as it does for a locally built tag such as `mindroom:dev`, or the image is still incompatible, rebuild the image from the checkout the primary runs from or select the published tag that matches your MindRoom version.
Plain `uvx mindroom run` runs the published PyPI release, so when testing unreleased code, run MindRoom from the checkout you build the image from (`uv run mindroom run` from the repo root, or `uvx --from /path/to/mindroom mindroom run`).
When upgrading from older releases, see [Workspace-only worker mounts](https://docs.mindroom.chat/deployment/upgrades/#workspace-only-worker-mounts), [Config-free runners and workers](https://docs.mindroom.chat/deployment/upgrades/#config-free-runners-and-workers), and [Requester-scoped worker keys](https://docs.mindroom.chat/deployment/upgrades/#requester-scoped-worker-keys).

## Live config snapshots

Runners never receive the primary's config file, the directory around it, its `.env`, or a ConfigMap holding it.
With the `static_runner` and `kubernetes` backends the primary sends the part of its live config that runners need with every request, so config changes reach runners without restarting them.
Docker workers get the same fields as a projected read-only config file, as described under [dedicated Docker workers](#host-machine-dedicated-docker-workers-mindroom_worker_backenddocker).

The snapshot holds only what runners need to resolve the agent, its tools, workspace, `file_access`, `worker_scope`, knowledge paths, and plugin paths.
Everything else stays in the primary, including models, MCP servers, plugin settings, memory provider settings, Git sources, instructions, rooms, teams, access policy, and the inline overrides of every tool other than the one being called.
A worker-routed call carries the called tool's own inline overrides, such as `shell: {extra_env_passthrough: "DAWARICH_*"}` in an agent's `tools` list (see [Per-Agent Tool Configuration](https://docs.mindroom.chat/tools/#per-agent-tool-configuration)), and saved tool settings reach the runner as [credential leases](#credential-leases).

Runners load plugin tools from the snapshot's plugin paths and skip plugins they cannot find, so plugin code must exist in the runner's filesystem.
The charts mount no config directory, so plugin directories beside the primary's config are invisible to the `static_runner` sidecar and Kubernetes workers; install such plugins as Python packages in the runner image.

## Worker Routing

`worker_tools` decides which tools run in workers; the field itself is described in [Agent Configuration](https://docs.mindroom.chat/configuration/agents/).
An agent's `worker_tools` list takes precedence, followed by `defaults.worker_tools`; an explicit empty list runs everything in the primary runtime.
When both are omitted, the [execution mode](#execution-modes) environment settings decide.

```yaml
defaults:
  worker_tools: [shell, file]        # route shell and file through workers for all agents

agents:
  code:
    tools: [file, shell, calculator]
    # inherits worker_tools from defaults

  research:
    tools: [duckduckgo, calculator]
    worker_tools: []                 # run everything in the primary runtime

  untrusted:
    tools: [shell, file, python]
    worker_tools: [shell, file, python]
```



The tools that default to workers when no list is set are `browser_mcp`, `coding`, `docker`, `file`, `python`, and `shell`.
Any other registered tool can be listed in `worker_tools`, except tools that need the primary runtime or room context, which stay in the primary runtime even when listed.
These include the Matrix, scheduling, todo, attachment, and thread tools, `gmail` and the other Google tools, `github`, `atlassian`, `slack`, `spotify`, `homeassistant`, `reasoning`, `daytona`, `e2b`, `mem0`, `claude_agent`, `config_manager`, `oauth_connections`, and `agent_vault_access`, plus `browserbase`, `composio`, `duckdb`, `pandas`, `sql`, and `zep`, which keep a session, connection, or other state between calls.
The built-in `memory`, `delegate`, and `self_config` tools also always run in the primary runtime.
The `browser` tool chooses per call: `target: desktop` stays in the primary runtime, while `target: host` follows the worker routing.

### Execution modes

These environment settings apply only when both the agent's `worker_tools` and `defaults.worker_tools` are omitted.

| `MINDROOM_SANDBOX_EXECUTION_MODE` | Behavior |
|------|----------|
| `selective` / `sandbox_selective` | Route eligible tools listed in `MINDROOM_SANDBOX_PROXY_TOOLS`, or all eligible tools for `*`; an empty list routes nothing |
| `all` / `sandbox_all` | Route every eligible tool enabled for the agent |
| `off` / `local` / `disabled` | Run tools in the primary runtime even if a proxy URL or dedicated backend is configured |
| _(unset)_ | A non-empty `MINDROOM_SANDBOX_PROXY_TOOLS` controls routing; otherwise a configured static proxy URL routes all eligible tools, the `docker` and `kubernetes` backends route the tools that default to workers, and `static_runner` without a proxy URL runs everything locally |

Any other value is rejected with `Invalid MINDROOM_SANDBOX_EXECUTION_MODE`.
With `static_runner`, no `MINDROOM_SANDBOX_PROXY_URL`, and no setting that requests routing, tools run directly in the primary runtime.
That is fine for development but not for production deployments where agents run untrusted code.
When routing is requested but cannot be satisfied, tool calls fail instead of running locally: with `static_runner` this happens when no proxy URL is set, and with `docker` or `kubernetes` when the backend is misconfigured.

`MINDROOM_UNSAFE_ALLOW_LOCAL_EXECUTION_TOOLS=true` lets tools that default to workers run locally when `static_runner` routing is requested without a proxy URL.
It never bypasses dedicated Docker or Kubernetes workers or a configured proxy URL.
Do not set it in hosted or multi-tenant deployments.

## Worker scopes

`worker_scope` controls how worker runtimes are shared between calls; the field is described in [Agent Configuration](https://docs.mindroom.chat/configuration/agents/).
It changes how tool runtimes are isolated, not where agent data lives.

| `worker_scope` | Worker runtime | What a dedicated Docker or Kubernetes worker mounts |
|----------------|----------------|------------------------------------------------------|
| _(unset)_ | Docker and Kubernetes: one persistent worker per agent; `static_runner`: no worker-specific storage | That agent's workspace |
| `shared` | One per agent, shared by all users | That agent's workspace |
| `user` | One per user, shared across that user's agents | The workspaces of every non-private agent on `worker_scope: user`, plus that user's own private workspaces of `private.per: user` agents |
| `user_agent` | One per user and agent pair | That agent's workspace |

Use `user_agent` when you need per-agent filesystem isolation.
Treat `user` as a shared workstation: it mounts every `worker_scope: user` agent's workspace, including files other requesters leave there, but never agents on other scopes.
A `user`-scope tool call whose `base_dir` points at an agent on another scope fails with HTTP 400 (`base_dir must stay inside a visible workspace or the worker root`).

### Filesystem isolation and agent data

Each non-private agent's data (context files, workspace, memory, sessions, learning) lives in `agents/<name>/` whatever its `worker_scope`, and every runtime for that agent reads and writes the same directory.
Private agents keep one state root per requester under `private_instances/<scope-key>/<agent>/`.
Worker runtimes also keep their own virtualenvs, caches, and scratch files, which are not agent data.

What a worker can see depends on the backend:

- **Local execution** shares the primary process filesystem.
- **The Kubernetes chart's `static_runner` sidecar** sees every agent's state directory, including the whole `private_instances` directory; a Compose or host-container runner sees only what you mount into it, so the examples above see only scratch storage.
- **Dedicated Docker and Kubernetes workers** mount only the workspaces listed in the [scope table](#worker-scopes), their own scratch space, and read-only assigned knowledge, never the sessions, memory, or learning data beside the workspaces.
  For private agents they mount only the private root (`private.root`), never the state root around it.

When an agent has a workspace (the `file` memory backend or `private`), `shell`, `file`, `python`, and `coding` use it as their default working directory (`base_dir`); this is a convenience, not a boundary.

A missing or linked workspace is not mounted.
Assigned knowledge that is missing, reached through a link, or inside another agent's workspace, a private instance, or a worker root is not mounted either, and the worker sees an empty knowledge folder.
Kubernetes knowledge mounts are described in [Knowledge Source Visibility](https://docs.mindroom.chat/deployment/kubernetes/#knowledge-source-visibility).
Any change to a dedicated worker's launch configuration, mounts, environment, or tool and plugin config recreates the worker on its next use, which ends its tmux sessions, background shells, and computer sessions.
Adding, removing, or re-scoping a `worker_scope: user` agent changes every user worker's mounts, so those workers are recreated too.

### Dashboard credentials and worker scopes

The dashboard's generic credential forms work only for unscoped agents and agents with `worker_scope: shared`.
The Google Drive, Docs, Gmail, Calendar, Sheets, and Tasks OAuth providers are an exception: the dashboard can connect scoped `user` and `user_agent` credentials, while the tools still execute in the primary runtime.
GitHub managed OAuth credentials always use the requester's `user` scope, independently of the agent's `worker_scope`.
Saved tool settings are stored by the primary per agent for `shared`, per requester across agents for `user`, and per requester-agent pair for `user_agent`, and reach worker-routed calls through [credential leases](#credential-leases).
Tools without a scoped OAuth provider have no dashboard form for `user` and `user_agent` settings, so configure them in authored config (see [Per-Agent Tool Configuration](https://docs.mindroom.chat/tools/#per-agent-tool-configuration)) or through shared settings granted with `defaults.worker_grantable_credentials`.
Only unscoped and `shared` agents are available through the [OpenAI-compatible API](https://docs.mindroom.chat/openai-api/#model-selection).

### Shared-only integrations

`spotify` and `homeassistant` require `worker_scope` unset or `shared`, and always run in the primary runtime.
Configured MCP `mcp_<server_id>` tools work on every worker scope (see [MCP](https://docs.mindroom.chat/mcp/#oauth-backed-remote-mcp)); [Where Connections Are Stored](https://docs.mindroom.chat/oauth-framework/#where-connections-are-stored) lists which OAuth connection each scope uses.

## Shell environment and PATH

Worker-routed `shell` receives only a small non-secret system environment by default, such as `PATH`, `HOME`, `USER`, `TMPDIR`, locale, proxy, and certificate path variables.
Runtime `.env` values and provider API keys such as `OPENAI_API_KEY` and `ANTHROPIC_API_KEY` are not forwarded.
Worker-routed `python` receives only allowed runtime names.

To pass more variables to `shell`, set the shell tool's `extra_env_passthrough` to exact names or glob patterns of exported process variables; it does not match config-adjacent `.env` entries.
Everything that matches passes through, including service tokens and provider credentials, except MindRoom's own control variables such as credential seeds, Kubernetes worker backend settings, `MINDROOM_CREDENTIALS_ENCRYPTION_KEY`, and any name starting with `MINDROOM_SANDBOX_`.
Use `shell_path_prepend` to add directories, such as wrapper directories, ahead of the existing `PATH`.
Both options are documented with the [shell tool](https://docs.mindroom.chat/tools/execution-and-coding/#shell).

### Workspace home contract

For worker-routed `shell` and `python` calls with a resolved workspace, MindRoom sets `HOME`, `MINDROOM_AGENT_WORKSPACE`, `XDG_CONFIG_HOME`, `XDG_DATA_HOME`, and `XDG_STATE_HOME` to the workspace or directories under it.
`XDG_CACHE_HOME`, `PIP_CACHE_DIR`, `UV_CACHE_DIR`, and `PYTHONPYCACHEPREFIX` point at the worker cache directory when a worker root exists, and `VIRTUAL_ENV` stays on the worker's environment.
Neither passthrough nor `.mindroom/worker-env.sh` can redirect these variables.
As a result `pwd`, `~`, `Path.home()`, attachment `mindroom_output_path` saves, and relative paths in the `file` and `coding` tools all refer to the same workspace.
For example, after `get_attachment("att_...", mindroom_output_path="incoming/file.txt")`, worker-routed shell can read both `incoming/file.txt` and `~/incoming/file.txt`.

### Workspace env hook (`.mindroom/worker-env.sh`)

Agents can set custom environment variables for worker-routed `shell` and `python` calls, such as `PATH` entries, package indexes, or npm cache locations, by writing a script to `.mindroom/worker-env.sh` at the root of the agent workspace, without changing config or redeploying.
It works the same with every worker backend.
For an example, see the [shell tool](https://docs.mindroom.chat/tools/execution-and-coding/#shell).

The hook lives at:

- `agents/<agent>/workspace/.mindroom/worker-env.sh` for shared and unscoped agents.
- `private_instances/<scope>/<agent>/<private.root>/.mindroom/worker-env.sh` for private agents, where `private.root` defaults to `<agent>_data`.
- The current request's workspace for `worker_scope: user`, so one shared user runtime picks up each agent's own hook.

How it behaves:

- The runner sources the script with `bash` before each call, and edits take effect on the next call without restarts, config reloads, or Helm changes.
- Only exported variables apply: write `export FOO=bar`, because plain `FOO=bar`, aliases, functions, and `cd` do not carry over.
- The script starts from the prepared request environment, not the runner's full environment.
- Exported values pass through, including tokens you export on purpose, except MindRoom's control variables (the same ones dropped from [shell passthrough](#shell-environment-and-path)), bash bookkeeping variables such as `PWD` and `SHLVL`, the [workspace home](#workspace-home-contract) variables, and values larger than 32 KiB.

Limits and errors:

- The script may be at most 64 KiB, each of stdout and stderr at most 256 KiB, and the exported overlay at most 128 KiB.
- The hook times out after 10 seconds.
- Symlinks that escape the workspace are rejected.
- Any failure, such as a non-zero exit, timeout, escape, or missing `bash`, fails only that tool call, with `ok: false`, `failure_kind: "tool"`, and an error mentioning `.mindroom/worker-env.sh`.

## Credential leases

A **credential lease** delivers saved tool settings and credentials to one worker-routed call as toolkit configuration.
Leases are single-use and expire after `MINDROOM_SANDBOX_CREDENTIAL_LEASE_TTL_SECONDS` (default 60, clamped to 1 to 3600).
Only fields that the receiving toolkit declares are applied.

Every worker-routed call leases the called tool's saved settings, because runners have no access to the primary's credential store or its encryption key:

- Scoped calls lease the settings saved for their scope; shared settings are leased only for services listed in `defaults.worker_grantable_credentials`.
- Unscoped calls lease the called tool's settings from the primary credential store, including the `openai` and `groq` provider keys, because those tools share their service names with the model providers; `defaults.worker_grantable_credentials` does not limit them.
- `defaults.worker_grantable_credentials` also decides which shared credentials are copied into dedicated workers.

`MINDROOM_SANDBOX_CREDENTIAL_POLICY_JSON` leases additional services: it maps tool names, `tool.function` selectors, or `*` to lists of credential service names.
Values in a dedicated worker's own credential store apply only inside that worker and only where no lease sets a value, so clear worker credential stores if older values there should stop applying.

Leases do not export API keys into shell environments, configure Git authentication, or install SSH keys.
For shell authentication, use [environment passthrough](#shell-environment-and-path), the [workspace env hook](#workspace-env-hook-mindroomworker-envsh), or [brokered egress](#brokered-worker-egress).

## Brokered worker egress

To let worker-routed `shell` and `python` call external APIs without ever receiving the real credential, route their egress through a proxy that injects the credential in transit.
This works at the network layer, so it covers URLs inside scripts, package CLIs, and subprocesses.
There are two supported shapes:

- **Per-worker Agent Vault egress** (Kubernetes backend), which gives each worker its own vault for per-user or per-agent isolation, described below.
- **A shared credential-injecting proxy** that you run, configured through `MINDROOM_KUBERNETES_WORKER_ENV_JSON`, the chart's `egressProxy` integration, or the worker proxy environment on other backends.
  The proxy holds the real credential and workers receive only its URL.

[Approved egress](https://docs.mindroom.chat/deployment/approved-egress/) and the `mindroom-egress-proxy` image restrict which hosts workers can reach but do not inject credentials; to combine them with Agent Vault, see [Agent Vault Chaining](https://docs.mindroom.chat/deployment/approved-egress/#agent-vault-chaining).

Do not put upstream API tokens in `extra_env_passthrough` or `.mindroom/worker-env.sh` unless you intend the worker process to receive them.

### Per-worker Agent Vault egress (Kubernetes backend)

Each dedicated worker gets its own vault, created if missing, and a fresh proxy-role Agent Vault token on one shared Agent Vault server.
Before enabling it, create the Secret named by `MINDROOM_KUBERNETES_AGENT_VAULT_BOOTSTRAP_SECRET_NAME` (chart: `workers.kubernetes.agentVault.bootstrapSecretName`, default `agent-vault-bootstrap`) with both `AGENT_VAULT_MASTER_PASSWORD` and `AGENT_VAULT_OWNER_PASSWORD`.
Create it in the worker namespace too when that differs from the release namespace.
`python` and `shell` egress then goes through the Agent Vault proxy with that token, so credentials stored in the worker's vault are injected in transit.

```bash
MINDROOM_KUBERNETES_AGENT_VAULT_ENABLED=true
MINDROOM_KUBERNETES_AGENT_VAULT_CLI_IMAGE=infisical/agent-vault:<pinned>
MINDROOM_KUBERNETES_AGENT_VAULT_OWNER_EMAIL=vault-owner@example.test
# optional overrides and their defaults:
MINDROOM_KUBERNETES_AGENT_VAULT_VAULT_NAME_PREFIX=agent-vault
MINDROOM_KUBERNETES_AGENT_VAULT_API_URL=http://agent-vault:14321
MINDROOM_KUBERNETES_AGENT_VAULT_PROXY_URL=http://agent-vault:14322
MINDROOM_KUBERNETES_AGENT_VAULT_BOOTSTRAP_SECRET_NAME=agent-vault-bootstrap
```

The proxy token exists only inside the worker pod and changes on every worker restart.
Tool code can read its own token, but a proxy-role token cannot read or decrypt credentials through the Agent Vault API and cannot reach another worker's vault.

For HTTPS, workers must trust the Agent Vault root CA.
Publish the CA from `agent-vault ca fetch` as a ConfigMap with key `ca.pem` and set `MINDROOM_KUBERNETES_AGENT_VAULT_WORKER_CA_CONFIGMAP_NAME` (chart: `workers.kubernetes.agentVault.workerCaConfigMapName`); `python` and `shell` then get `REQUESTS_CA_BUNDLE`, `CURL_CA_BUNDLE`, and `SSL_CERT_FILE` pointing at it.

When you also use [approved egress](https://docs.mindroom.chat/deployment/approved-egress/), Squid must be the first hop; see [Agent Vault Chaining](https://docs.mindroom.chat/deployment/approved-egress/#agent-vault-chaining).
Workers still need access to the Agent Vault API (`apiUrl`, usually port `14321`) to obtain their token.
The chart adds that rule when it manages the Agent Vault server; for an external server, add an egress rule for the API endpoint only, and do not add direct worker egress to the proxy port (`proxyUrl`, usually `14322`) when approved egress owns dynamic grants.
The chart-managed server's own NetworkPolicy is described under `workers.kubernetes.agentVault.server.networkPolicy` in `cluster/k8s/runtime/README.md`.

### Self-service vault access

The `agent_vault_access` tool lets a user ask their agent for a link to manage that agent's vault.
For `user` and `user_agent` scopes it grants the requester's Agent Vault account admin access to the worker's vault and returns the UI link.
For shared scopes it only returns the vault name and UI link (`"access": "operator_managed"`), which grants nothing by itself and tells the operator which vault backs the agent.

```bash
MINDROOM_AGENT_VAULT_ACCESS_API_URL=http://agent-vault:14321
MINDROOM_AGENT_VAULT_ACCESS_ADMIN_TOKEN=<owner or admin session/agent token>
MINDROOM_AGENT_VAULT_ACCESS_UI_BASE_URL=https://example.com/agent-vault
MINDROOM_AGENT_VAULT_ACCESS_EMAIL_DOMAIN=example.com
MINDROOM_AGENT_VAULT_ACCESS_VAULT_NAME_PREFIX=agent-vault  # must match workers.kubernetes.agentVault.vaultNamePrefix
```

Shared scopes need only the UI base URL and the vault name prefix; `user` and `user_agent` scopes also need the API URL, admin token, and email domain.
Instead of the inline admin token, `MINDROOM_AGENT_VAULT_ACCESS_ADMIN_TOKEN_FILE` can point at a file holding it; the file takes precedence and is re-read on every call, so rotating the Secret needs no restart.
When `MINDROOM_AGENT_VAULT_ACCESS_OWNER_EMAIL` names the Agent Vault owner account, self-service grants keep that account admin on the worker vault too.
The requester's account is `<Matrix localpart>@<EMAIL_DOMAIN>`, and only requesters on the runtime's own homeserver can self-grant.
The user must already have registered and verified that Agent Vault account.
This mapping controls only who may manage a vault in the UI, never which worker uses which vault.

## Stopping and background commands

Stopping a response also stops its in-flight worker calls, such as `run_shell_command` and `run_python_code`.
Tools that run inside the runner process itself, such as `file` under the runner's `inprocess` execution mode, and persistent browser actions finish before the stop takes effect.
Shell commands that exceed their timeout keep running in the background and survive across requests to the same runner, but a runner or worker restart stops them; see the [shell tool](https://docs.mindroom.chat/tools/execution-and-coding/#shell) for handles.

## Browsers in workers

Dedicated Docker and Kubernetes workers with `worker_scope: shared`, `user`, or `user_agent` keep a headless browser session across calls, including open tabs and the browser profile, when Worker Computer is disabled.
Include `browser` in the agent's `worker_tools` to use it.
A change to the browser configuration or the prepared process environment closes the session before the next call.
The `static_runner` backend and unscoped workers start a fresh browser for each call.
An explicit `target: host` uses the worker even when `default_target: desktop` is set.

[Worker Computer](https://docs.mindroom.chat/tools/worker-computer/) adds a visible headed browser and Chat viewer to dedicated Docker and Kubernetes workers; the `static_runner` backend does not support it.

## Worker tool results

Worker-routed tools can return images, audio, video, and files.
The worker downloads public HTTP(S) media URLs itself, following at most five redirects, and refuses local and metadata-service destinations; the primary runtime never reads a worker path or follows a media URL from the result.
Each result may contain at most eight media items, 10 MiB per item, and 20 MiB of media in total, plus at most 4,194,304 characters of text and 64 KiB of JSON metadata.
Unsupported resources or oversized results produce a tool error.

## Security considerations

- Workers never get the primary runtime's API key files, Matrix client state, credentials-encryption key, or orchestrator authority; they receive only the credentials leased under [Credential leases](#credential-leases).
- Use a strong random `MINDROOM_SANDBOX_PROXY_TOKEN`; runners accept requests and config snapshots only when authenticated with it or a token derived from it.
- Dedicated Docker and Kubernetes workers have a read-only root filesystem with a private writable `/tmp` of 1 GiB, so tool code cannot replace the runner's code; tool extras install into the worker's own virtualenv instead.
  On Docker, `/tmp` refuses writes past the limit; on Kubernetes, exceeding it evicts that worker pod.
  Shared `static_runner` sidecars and the Compose runner keep a writable image.
- Kubernetes worker containers drop all capabilities, disable privilege escalation, and use the pod's `RuntimeDefault` seccomp profile.
  Runtimes that block Chromium's namespace sandbox need the profile described in [Worker Computer](https://docs.mindroom.chat/tools/worker-computer/#browser-and-container-sandboxing).
- Dedicated Docker workers can use the hardened `MINDROOM_DOCKER_WORKER_SECURITY_POLICY=computer`, which Worker Computer requires; see [Browser and container sandboxing](https://docs.mindroom.chat/tools/worker-computer/#browser-and-container-sandboxing).

## Environment variable reference

### Primary MindRoom runtime (proxy client)

| Variable | Description | Default |
|----------|-------------|---------|
| `MINDROOM_WORKER_BACKEND` | Worker backend: `static_runner`, `docker`, or `kubernetes` | `static_runner` |
| `MINDROOM_SANDBOX_PROXY_URL` | URL of the shared sandbox runner for `static_runner`; unused by `docker` and `kubernetes` | _(none)_ |
| `MINDROOM_SANDBOX_PROXY_TOKEN` | Static-runner bearer token, and the secret from which Docker and Kubernetes derive per-worker tokens | _(required for worker routing)_ |
| `MINDROOM_SANDBOX_EXECUTION_MODE` | `selective`, `all`, or `off`; see [Execution modes](#execution-modes) | _(unset)_ |
| `MINDROOM_SANDBOX_PROXY_TOOLS` | Comma-separated tools to route when no `worker_tools` list is set | `*` for `all` or an unset mode with a proxy URL, otherwise empty |
| `MINDROOM_UNSAFE_ALLOW_LOCAL_EXECUTION_TOOLS` | Let worker-default tools run locally when `static_runner` routing is requested without a proxy URL | `false` |
| `MINDROOM_SANDBOX_PROXY_TIMEOUT_SECONDS` | HTTP timeout for proxy calls | `120` |
| `MINDROOM_ATTACHMENT_INLINE_SAVE_MAX_BYTES` | Largest attachment saved into a worker workspace with `get_attachment(..., mindroom_output_path=...)`, and largest Atlassian `confluence_download_attachment` download | `16777216` (16 MiB) |
| `MINDROOM_SANDBOX_CREDENTIAL_LEASE_TTL_SECONDS` | Credential lease lifetime | `60` |
| `MINDROOM_SANDBOX_CREDENTIAL_POLICY_JSON` | JSON mapping tool selectors to credential services | `{}` |
| `MINDROOM_WORKER_COMPUTER_ENABLED` | Enable the [Worker Computer](https://docs.mindroom.chat/tools/worker-computer/) persistent browser and display | `false` |
| `MINDROOM_COMPUTER_ALLOWED_ORIGINS` | JSON list of Chat origins allowed to open worker computers | `[]` |

The Helm chart sets the Kubernetes backend variables.
To deploy that backend without Helm, see [Worker settings](https://docs.mindroom.chat/deployment/kubernetes/#worker-settings).

### Dedicated Docker worker backend

| Variable | Description | Default |
|----------|-------------|---------|
| `MINDROOM_DOCKER_WORKER_IMAGE` | Container image for dedicated Docker workers | _(required for `docker`)_ |
| `MINDROOM_DOCKER_WORKER_SECURITY_POLICY` | `runtime_default` or `computer`; Worker Computer requires `computer` | `runtime_default` |
| `MINDROOM_DOCKER_WORKER_PORT` | Sandbox-runner port inside the container | `8766` |
| `MINDROOM_DOCKER_WORKER_STORAGE_MOUNT_PATH` | Worker root mount path inside the container | `/app/worker` |
| `MINDROOM_DOCKER_WORKER_CONFIG_PATH` | Config path inside the container | `/app/config-host/config.yaml` |
| `MINDROOM_DOCKER_WORKER_HOST_CONFIG_PATH` | Host `config.yaml` from which each worker's projected config is built | Resolved `MINDROOM_CONFIG_PATH` when it exists |
| `MINDROOM_DOCKER_WORKER_IDLE_TIMEOUT_SECONDS` | Idle time before a worker container may be cleaned up | `1800` |
| `MINDROOM_DOCKER_WORKER_READY_TIMEOUT_SECONDS` | Maximum wait for a new worker to become ready | `60` |
| `MINDROOM_DOCKER_WORKER_NAME_PREFIX` | Prefix for worker container names | `mindroom-worker` |
| `MINDROOM_DOCKER_WORKER_PUBLISH_HOST` | Host interface for published worker ports | `127.0.0.1` |
| `MINDROOM_DOCKER_WORKER_ENDPOINT_HOST` | Hostname the primary runtime uses to reach published worker ports | Value of `MINDROOM_DOCKER_WORKER_PUBLISH_HOST` |
| `MINDROOM_DOCKER_WORKER_USER` | Container user, or empty for the image default | Current host uid:gid on POSIX, image default otherwise |
| `MINDROOM_DOCKER_WORKER_ENV_JSON` | JSON object of extra environment variables for each worker container | `{}` |
| `MINDROOM_DOCKER_WORKER_LABELS_JSON` | JSON object of extra Docker labels for each worker container | `{}` |

### Sandbox runner

| Variable | Description | Default |
|----------|-------------|---------|
| `MINDROOM_SANDBOX_RUNNER_PORT` | Port the runner listens on | `8766` |
| `MINDROOM_SANDBOX_RUNNER_MODE` | Set to `true` to run as a sandbox runner | `false` |
| `MINDROOM_SANDBOX_PROXY_TOKEN` | Runner bearer token; dedicated workers receive their derived token automatically | _(required)_ |
| `MINDROOM_SANDBOX_RUNNER_EXECUTION_MODE` | `inprocess`, `subprocess` (a fresh process per call), or `forkserver` (a fresh forked process per call from a warm template) | `inprocess`; dedicated workers use `forkserver` |
| `MINDROOM_SANDBOX_RUNNER_SUBPROCESS_TIMEOUT_SECONDS` | Per-call timeout for `subprocess` and `forkserver` execution | `120` |
| `MINDROOM_STORAGE_PATH` | Writable directory for the tool registry and worker-local caches, such as `/app/workspace/.mindroom`; startup fails if it is not writable | `mindroom_data` next to the config |
| `MINDROOM_CONFIG_PATH` | Config used for requests that carry no [live config snapshot](#live-config-snapshots) | _(optional)_ |

# Approved Egress

Approved egress blocks direct internet access from dedicated Kubernetes workers and sends their traffic through a policy-enforcing HTTP proxy.
A worker can reach a hostname only when it is on the static allowlist or a human has approved a temporary grant in Matrix.
Use it when worker-routed tools such as `shell` and `python` must not reach arbitrary hosts.

## Enable with the Runtime Chart

```yaml
workers:
  backend: kubernetes
  sandbox:
    proxyToken:
      existingSecret: mindroom-sandbox-proxy
      key: MINDROOM_SANDBOX_PROXY_TOKEN

approvedEgress:
  enabled: true
  image:
    tag: v0.1.10
  allowlist:
    domains:
      - example.com
      - .docs.example.com
  maxTtlSeconds: 21600
```

The chart deploys the proxy, points worker proxy settings at it, and adds NetworkPolicies so workers cannot bypass it.
Approved egress requires `workers.backend: kubernetes`, a pinned `approvedEgress.image.tag` or `approvedEgress.image.digest`, and a token from `approvedEgress.token.existingSecret` or `workers.sandbox.proxyToken`.
Use proxy image `v0.1.10` or later, because browser tools tunnel every connection with `CONNECT` and older images refuse plain-HTTP pages with `TCP_DENIED/403`.
`approvedEgress.maxTtlSeconds` caps every temporary grant and defaults to `21600` (6 hours).

The chart also enables the built-in `approved_egress` toolkit at runtime, including with `config.data` or `config.existingConfigMap`, so you do not edit `config.yaml`.
It appends `approved_egress` to `defaults.tools`, or to the built-in default tools when `defaults.tools` is omitted, and adds a `tool_approval` rule that requires Matrix approval for `request_network_access`.
Agents with `include_default_tools: false` do not get the toolkit.
These runtime additions are not written to `config.yaml` when you save from the dashboard or API.
Set `approvedEgress.manageRuntimeConfig: false` to keep the proxy wiring without these additions, for example when your config assigns `approved_egress` to specific agents.

### Static Allowlist

Set allowlist entries in `approvedEgress.allowlist.domains`, or provide them in `approvedEgress.allowlist.existingConfigMap` under the key named by `approvedEgress.allowlist.key` (default `allowed-domains.txt`); you cannot set both.
A plain hostname matches exactly, and a leading dot such as `.docs.example.com` matches the domain and all its subdomains.
Allowlisted hostnames never need approval.
Changing `approvedEgress.allowlist.domains` restarts the proxy automatically when you apply the chart, but edits to an `existingConfigMap` reach the proxy only after you restart it.
MindRoom keeps the allowlist it started with until its own pod restarts, so a removed domain is still reported as allowed and gets no grant in the meantime; the [runtime chart README](https://github.com/mindroom-ai/mindroom/blob/main/cluster/k8s/runtime/README.md#worker-egress-proxy) names the Deployments to restart.

## Requesting Access

Agents call `request_network_access(hostnames, ttl_minutes, reason)` with every blocked external hostname the task needs, up to 20 per call, and one Matrix approval covers the whole batch.
Hostnames must be exact external DNS names; schemes, ports, paths, wildcards, IP literals, single-label names, `localhost`, cluster-local names, and cloud metadata hostnames are rejected, and one invalid hostname rejects the whole request.
Hostnames already on the static allowlist are skipped, and when every requested hostname is allowlisted the tool replies that no grant is needed without asking for approval.
The granted TTL is the requested `ttl_minutes`, capped at the deployment maximum, and the reply says when the cap applied.

Who a grant covers depends on the agent's effective [worker scope](https://docs.mindroom.chat/deployment/sandbox-proxy/#worker-scopes), which comes from `private.per` for private agents, then the agent's `worker_scope`, then `defaults.worker_scope`:

| Effective scope | Grant applies to |
|---|---|
| `user_agent` | The requester's own worker for that agent |
| `shared` or unset | That agent's worker |
| `user` | Not supported; the request fails with `approved egress is not supported for worker_scope=user` |

### Temporary Full Access

The `allow_full_access` tool option defaults to `false`.
Enable it in the tool's settings or in an authored tool entry, on `defaults.tools` or on an individual agent:

```yaml
defaults:
  tools:
    - approved_egress:
        allow_full_access: true
```

Agents can then call `request_network_access(hostnames=["*"], ttl_minutes=5, reason="Install dependencies")`.
The `"*"` must be the only entry.
Full-access requests always require Matrix approval, use the same TTL cap, and apply to the same workers as hostname grants.
They open all public hostnames on ports 80 and 443 through the proxy, while private networks, internal hostnames, and other ports and protocols stay blocked.
The proxy image must support full-access grants; older images reject them.
When a grant expires, new connections need an allowlist match or another grant, but established connections, including HTTPS tunnels, stay open.

## Without the Runtime Chart

Enable the toolkit by setting `MINDROOM_APPROVED_EGRESS_ENABLED=true`, or author the same configuration yourself:

```yaml
defaults:
  tools:
    - approved_egress

tool_approval:
  default: auto_approve
  rules:
    - match: request_network_access
      action: require_approval
```

You can assign `approved_egress` to individual agents instead of `defaults.tools`.
The toolkit reads these environment variables, which the runtime chart sets for you:

| Variable | Purpose | Default |
|---|---|---|
| `MINDROOM_APPROVED_EGRESS_ENABLED` | Add the toolkit and approval rule at runtime, as described above | unset |
| `MINDROOM_APPROVED_EGRESS_API_URL` | Proxy policy API URL; plain `http` is allowed only for loopback or in-cluster service names | `http://mindroom-egress-proxy:8080` |
| `MINDROOM_APPROVED_EGRESS_TOKEN` | Bearer token for the policy API | required |
| `MINDROOM_APPROVED_EGRESS_ALLOWLIST` | Comma-separated static allowlist, used instead of the file when set | unset |
| `MINDROOM_APPROVED_EGRESS_ALLOWLIST_PATH` | Static allowlist file with one entry per line and `#` comments | `/etc/mindroom-egress/allowed-domains.txt` |
| `MINDROOM_APPROVED_EGRESS_MAX_TTL_SECONDS` | Maximum grant TTL | `21600` |

## Agent Vault Chaining

When approved egress and [Agent Vault](https://docs.mindroom.chat/deployment/sandbox-proxy/) are used together, the chain must be:

```text
worker -> approved egress (Squid) -> Agent Vault MITM parent -> internet
```

Squid must be first because temporary grants are matched to the worker by its source IP.
If traffic goes through Agent Vault first, Squid sees only the vault's IP, so temporary grants never match while static allowlist entries still work.

Before enabling the Agent Vault server and bootstrap job, create the Secret named by `workers.kubernetes.agentVault.bootstrapSecretName` (default `agent-vault-bootstrap`) with both `AGENT_VAULT_MASTER_PASSWORD` and `AGENT_VAULT_OWNER_PASSWORD`.
Create it in the worker namespace too when that differs from the release namespace.

```yaml
workers:
  backend: kubernetes
  sandbox:
    proxyToken:
      existingSecret: mindroom-sandbox-proxy
      key: MINDROOM_SANDBOX_PROXY_TOKEN
  kubernetes:
    agentVault:
      enabled: true
      cliImage: infisical/agent-vault:<pinned>
      ownerEmail: owner@example.test
      server:
        enabled: true
      bootstrap:
        enabled: true
        kubectlImage: bitnami/kubectl:<pinned>
      workerCaConfigMapName: agent-vault-ca

approvedEgress:
  enabled: true
  image:
    tag: v0.1.10
  parentProxy:
    enabled: true
    host: agent-vault
    port: 14322
```

With `approvedEgress.parentProxy.enabled: true`, workers send Agent Vault traffic to approved egress first.
After the allowlist and grant check, requests carrying the worker's Agent Vault token go on to the vault, which still validates the token and injects credentials, while requests without a token go directly to the internet.

Use `approvedEgress.parentProxy.bypassDomains` (default empty) for destinations such as signed URLs that must skip credential injection:

```yaml
approvedEgress:
  parentProxy:
    enabled: true
    bypassDomains:
      - downloads.example.test
      - .objects.example.test
```

Entries match like allowlist entries: exact hostnames, or a leading dot for a domain and its subdomains.
Use ASCII domain names without schemes, ports, paths, `*` wildcards, or whitespace.
Bypassed destinations still need an allowlist match or grant, and changing `bypassDomains` restarts the proxy automatically.

Do not point Agent Vault tool traffic directly at the chart-managed Agent Vault proxy while approved egress is enabled.
The chart refuses that combination with `approvedEgress with Agent Vault must be squid-first: enable approvedEgress.parentProxy so Squid sees worker source IPs for dynamic grants, or set workers.kubernetes.agentVault.proxyUrl to an external/custom proxy URL explicitly`.
An external or custom `workers.kubernetes.agentVault.proxyUrl` remains allowed.

## Secure Minimum

Beyond the chart's required settings:

- Keep `workers.kubernetes.networkPolicy.create`, `egressProxy.networkPolicy.create`, and `approvedEgress.networkPolicy.create` enabled.
- Keep `request_network_access` behind `tool_approval`.
- Put hostnames that should never need approval on the static allowlist.
- Keep `approvedEgress.maxTtlSeconds` short.

# Trusted Upstream Browser Auth

Use trusted upstream auth when the MindRoom API and dashboard sit behind a deployment-owned reverse proxy or identity gateway that has already authenticated the human.
Hosted multi-user deployments need it so the browser that opens an agent-issued OAuth link, such as `/api/oauth/google_drive/authorize?connect_token=...`, signs in as the Matrix requester that triggered it.
The standalone `MINDROOM_OWNER_USER_ID` setting maps every dashboard request to one Matrix user, so it suits only single-owner deployments.
The [Connections portal](#connections-portal), the [MCP Gateway](https://docs.mindroom.chat/deployment/mcp-gateway/), and the [usage export service](https://docs.mindroom.chat/usage/#usage-export-service) also build on this mode.

Trusted upstream auth is disabled by default and works with any gateway, such as an ingress controller, OAuth2 proxy, or identity-aware proxy.
Enable it only when every network path to MindRoom removes client-supplied copies of the trusted headers and injects verified values itself; never expose such an instance directly to browsers or the public internet.
Header-only mode trusts those headers alone and suits deployments reachable only through the gateway.
Prefer [strict JWT mode](#strict-jwt-mode) whenever the gateway can sign an assertion.
While trusted upstream auth is enabled, it replaces Supabase platform auth and `MINDROOM_API_KEY` auth for the dashboard and its API.

## Header Setup

Set the header names that your access layer owns:

```bash
MINDROOM_TRUSTED_UPSTREAM_AUTH_ENABLED=true
MINDROOM_TRUSTED_UPSTREAM_USER_ID_HEADER=X-MindRoom-User-Id
MINDROOM_TRUSTED_UPSTREAM_EMAIL_HEADER=X-MindRoom-User-Email
MINDROOM_TRUSTED_UPSTREAM_MATRIX_USER_ID_HEADER=X-MindRoom-Matrix-User-Id
MINDROOM_TRUSTED_UPSTREAM_EMAIL_TO_MATRIX_USER_ID_TEMPLATE='@{localpart}:example.org'
MINDROOM_TRUSTED_UPSTREAM_EMAIL_DOMAIN=example.com
```

| Variable | Meaning |
| --- | --- |
| `MINDROOM_TRUSTED_UPSTREAM_AUTH_ENABLED` | Turns the mode on; default `false` |
| `MINDROOM_TRUSTED_UPSTREAM_USER_ID_HEADER` | Required; a header carrying a stable ID for the authenticated browser user |
| `MINDROOM_TRUSTED_UPSTREAM_EMAIL_HEADER` | Optional; header carrying the user's email, required in header-only mode when an email-to-Matrix template is set |
| `MINDROOM_TRUSTED_UPSTREAM_MATRIX_USER_ID_HEADER` | Optional; header carrying the user's Matrix ID |
| `MINDROOM_TRUSTED_UPSTREAM_EMAIL_TO_MATRIX_USER_ID_TEMPLATE` | Optional; derives the Matrix ID from the email localpart and must contain exactly one `{localpart}` placeholder |
| `MINDROOM_TRUSTED_UPSTREAM_EMAIL_DOMAIN` | The single email domain allowed for template derivation; required with the template |

Shared dashboard access needs only the user ID header.
Personal `user` and `user_agent` OAuth connections, the Connections portal, and personal APIs also need a Matrix identity that matches the requester used in conversations.
Prefer the Matrix user ID header when the access layer can supply a real Matrix ID; header-only mode uses it when present and otherwise derives the ID from the email.
With the template `@{localpart}:example.org` and domain `example.com`, `alice@example.com` maps to `@alice:example.org`.
Domain matching is case-insensitive, emails from any other domain or a subdomain are rejected, and the result must be a valid Matrix user ID.

## Strict JWT Mode

Strict mode requires every request to carry a signed JWT from the gateway alongside the identity headers, so a spoofed identity header alone is rejected.
Use it when the gateway publishes a JWKS endpoint and issues short-lived assertions for authenticated browser requests.

```bash
MINDROOM_TRUSTED_UPSTREAM_REQUIRE_JWT=true
MINDROOM_TRUSTED_UPSTREAM_JWT_HEADER=X-Trusted-Jwt
MINDROOM_TRUSTED_UPSTREAM_JWKS_URL=https://gateway.example.com/.well-known/jwks.json
MINDROOM_TRUSTED_UPSTREAM_JWT_AUDIENCE=mindroom-dashboard
MINDROOM_TRUSTED_UPSTREAM_JWT_ISSUER=https://gateway.example.com
MINDROOM_TRUSTED_UPSTREAM_JWT_EMAIL_CLAIM=email
MINDROOM_TRUSTED_UPSTREAM_JWT_USER_ID_CLAIM=sub
MINDROOM_TRUSTED_UPSTREAM_JWT_MATRIX_USER_ID_CLAIM=matrix_user_id
```

| Variable | Meaning |
| --- | --- |
| `MINDROOM_TRUSTED_UPSTREAM_REQUIRE_JWT` | Turns strict mode on; default `false` |
| `MINDROOM_TRUSTED_UPSTREAM_JWT_HEADER` | Required; header carrying the JWT |
| `MINDROOM_TRUSTED_UPSTREAM_JWKS_URL` | Required; the gateway's signing keys |
| `MINDROOM_TRUSTED_UPSTREAM_JWT_AUDIENCE` | Required; expected `aud` |
| `MINDROOM_TRUSTED_UPSTREAM_JWT_ISSUER` | Required; expected `iss` |
| `MINDROOM_TRUSTED_UPSTREAM_JWT_EMAIL_CLAIM` | Claim holding the verified email; default `email` |
| `MINDROOM_TRUSTED_UPSTREAM_JWT_USER_ID_CLAIM` | Optional; claim holding a stable user ID distinct from the email |
| `MINDROOM_TRUSTED_UPSTREAM_JWT_MATRIX_USER_ID_CLAIM` | Optional; claim holding a signed Matrix user ID |

MindRoom verifies the signature, expiry, issuer, audience, and every configured claim, and picks up gateway key rotation without a restart.
Headers must agree with the verified claims:

- The user ID header must equal the user ID claim when configured, and otherwise the email claim.
- A configured email header must equal the email claim; without an email header, MindRoom uses the email claim.
- With a Matrix user ID claim, that claim is the Matrix identity, and a Matrix user ID header must equal it.
- Without one, the Matrix identity can only come from the email-to-Matrix template applied to the verified email, which works without an email header, and a Matrix user ID header must equal the derived ID.
- With neither a Matrix claim nor a template, any Matrix user ID header is rejected because no signature backs it.

## Connections Portal

The Connections portal at `/connections` lets each signed-in user connect and disconnect their OAuth accounts without administrator dashboard access.
Enable it by naming a private agent:

```bash
MINDROOM_CONNECTIONS_AGENT=personal
MINDROOM_PUBLIC_URL=https://assistant.example.org
```

The named agent must use `private.per: user` or `private.per: user_agent`:

```yaml
agents:
  personal:
    display_name: Personal Mind
    role: Personal assistant
    private:
      per: user_agent
    access:
      users: ["@*:example.org"]
    tools:
      - google_drive
      - name: google_calendar
        defer: true
```

The portal requires [strict JWT mode](#strict-jwt-mode) with a Matrix identity from a signed Matrix claim or the email-to-Matrix template; header-only, API-key, and `MINDROOM_OWNER_USER_ID` authentication are rejected.
Connect and disconnect requests also require `MINDROOM_PUBLIC_URL`, or the request URL when unset, to be an HTTPS origin, and the browser's `Origin` header to match it.

The portal groups OAuth services by agent, covering the named private agent and the shared agents the user may use or manage credentials for.
Services come from each agent's tools, including deferred tools and plugin or MCP OAuth providers; tools that need no browser sign-in are listed too, and room-dependent tools are marked **MindRoom only**.
Each service's status loads independently, so one failing or unconnected service does not block the others.
Using an agent requires a matching `access.users` grant, administrator authority, or membership in a configured grant room; `access.current_room_members` does not apply because browser requests have no current room.
Users listed in `agents.<name>.credential_managers` see that shared agent even without use access, but credential management alone grants no tool access.
Who may manage each connection follows [Who Can Connect An Account](https://docs.mindroom.chat/oauth-framework/#who-can-connect-an-account), and connections made here are the same ones tools use.
Disconnecting a shared connection affects every agent using its [credential scope](https://docs.mindroom.chat/oauth-framework/#where-connections-are-stored).
The portal never shows tokens, model configuration, generic credential editing, or OAuth client settings; operators still configure OAuth clients.
When a service uses a shared service account, such as `GOOGLE_SERVICE_ACCOUNT_FILE`, the portal does not show it as a personal connection and disables **Connect** for that service.
Users choose the agents and tools the optional [MCP Gateway](https://docs.mindroom.chat/deployment/mcp-gateway/#choose-exposed-agents-and-tools) exposes on the same page.

To share a hostname with another frontend, forward `/connections`, `/connections/*`, `/api/connections`, `/api/connections/*`, and `/api/oauth/*` to the MindRoom API behind the authenticated upstream.
Exclude `/connections` from the other application's service-worker navigation fallback.
Portal assets are served under `/connections/assets/`, so root `/assets/` can keep serving the other application.

## Helm Charts

For the hosted instance chart, set the equivalent values:

```yaml
trustedUpstreamAuth:
  enabled: "true"
  userIdHeader: X-MindRoom-User-Id
  emailHeader: X-MindRoom-User-Email
  matrixUserIdHeader: X-MindRoom-Matrix-User-Id
  emailToMatrixUserIdTemplate: "@{localpart}:example.org"
  emailDomain: example.com
  requireJwt: "true"
  jwtHeader: X-Trusted-Jwt
  jwksUrl: https://gateway.example.com/.well-known/jwks.json
  jwtAudience: mindroom-dashboard
  jwtIssuer: https://gateway.example.com
  jwtEmailClaim: email
  jwtUserIdClaim: sub
  jwtMatrixUserIdClaim: matrix_user_id
```

The chart sets the matching `MINDROOM_TRUSTED_UPSTREAM_*` environment variables.
When instances come from the platform provisioner, put the same values under `provisioner.trustedUpstreamAuth` in the platform chart, which passes them to the provisioner as `INSTANCE_TRUSTED_UPSTREAM_*` variables.
Both charts fail to render when `enabled` is true without `userIdHeader`, when `emailToMatrixUserIdTemplate` is set without both `emailHeader` and `emailDomain`, or when `requireJwt` is true without `jwtHeader`, `jwksUrl`, `jwtAudience`, and `jwtIssuer`.

## Security Boundary

Dashboard configuration is an operator capability.
Without `MINDROOM_CONNECTIONS_AGENT`, every user the upstream authenticates can read and change dashboard configuration regardless of the Matrix `administrators` list, so admit only trusted operators through the gateway in that mode.
With the Connections portal enabled, ordinary dashboard pages and APIs also require a Matrix identity listed in `administrators`, while the portal and the OAuth callback, success, and reset pages keep their own access checks.

Strict JWT mode adds signature checks but does not replace header stripping, which every network path still needs.
A personal OAuth link opened by a browser signed in as anyone other than the requester who received it fails with `403`; see [Connect An Account](https://docs.mindroom.chat/oauth-framework/#connect-an-account) for link rules.

## Browser Mutation Protection

Changing requests (anything other than `GET`, `HEAD`, `OPTIONS`, or `TRACE`) authenticated by trusted upstream identity or a dashboard cookie must send an `Origin` matching `MINDROOM_PUBLIC_URL`, or the request origin when it is unset.
Requests marked `Sec-Fetch-Site: cross-site` are rejected even when the `Origin` matches.
A validated bearer token skips these checks, but adding a bearer header to a trusted-upstream request does not.
`MINDROOM_DASHBOARD_CORS_ALLOWED_ORIGINS` controls which origins may read credentialed responses and never authorizes cross-origin changes.
Host the dashboard on the app's public origin, or use its development proxy, which authenticates with a bearer token.

## Troubleshooting

| Status and message | Fix |
| --- | --- |
| `500 Trusted upstream auth is enabled but MINDROOM_TRUSTED_UPSTREAM_USER_ID_HEADER is not set` | Set the user ID header variable |
| `500 Trusted upstream strict JWT auth is enabled but <variable> is not set` | Set the named strict-mode variable |
| `500 Trusted upstream email-to-Matrix template is set but MINDROOM_TRUSTED_UPSTREAM_EMAIL_HEADER is not set` | In header-only mode, set the email header or use the Matrix user ID header instead |
| `500 Trusted upstream email mapping requires a valid template and MINDROOM_TRUSTED_UPSTREAM_EMAIL_DOMAIN` | Use exactly one `{localpart}` in a valid Matrix ID template and set the email domain |
| `401 Missing trusted upstream identity header: <header>`, `Missing trusted upstream email header: <header>`, or `Missing trusted upstream JWT header: <header>` | The gateway did not send the header; check its routing and header injection |
| `401 Invalid trusted upstream JWT` | The JWT is expired, signed by an unknown key, for the wrong issuer or audience, or lacks a configured claim |
| `401 Trusted upstream identity does not match JWT claim` | Make the user ID and email headers carry the same values as the JWT claims |
| `401 Trusted upstream Matrix identity does not match JWT claim`, `does not match verified email`, or `is not signed` | Make the Matrix user ID header match the signed or derived ID, configure a Matrix claim or template, or stop sending the header |
| `401 Invalid trusted upstream email identity` | The email is outside `MINDROOM_TRUSTED_UPSTREAM_EMAIL_DOMAIN` |
| `401 Invalid trusted upstream Matrix user id` | The Matrix header or claim is not a valid Matrix user ID |
| `403 Administrator access required` | With the portal enabled, only `administrators` reach ordinary dashboard routes |
| `403 Browser changes require a same-origin request` or `Browser changes require a valid public origin` | Open the dashboard from its `MINDROOM_PUBLIC_URL` origin |
| `403 OAuth link does not belong to the current user` | Open the conversation link while signed in as the requester who received it |
| `403 Connections require trusted signed authentication` or `Connections require a verified Matrix identity` | Enable strict JWT mode with a Matrix claim or email-to-Matrix template |
| `403 Connections require a configured private agent` | Point `MINDROOM_CONNECTIONS_AGENT` at an agent with `private.per: user` or `user_agent` |
| `403 No connections are available for this account` | Give the user agent access or list them in `credential_managers` |
| `403 Connections require an HTTPS public origin` or `Connection changes require a same-origin request` | Set an HTTPS `MINDROOM_PUBLIC_URL` and open the portal from that origin |

# Data Storage & Journal

This page covers where MindRoom keeps its data, what to back up, how to configure the event journal, and how to move or rebind the journal with `mindroom journal adopt`.

## Data Persistence

MindRoom stores data in `mindroom_data/` next to `config.yaml` by default; set `MINDROOM_STORAGE_PATH` to use another directory.

- `agents/*/sessions/` and `teams/*/sessions/` - Conversation history (SQLite)
- `agents/*/learning/` - Per-agent Agno Learning state when learning is enabled
- `agents/*/chroma/` - Per-agent Mem0 ChromaDB storage
- `private_instances/` - The same per-agent directories for private agents, one tree per requester scope
- `knowledge_db/` - Knowledge base vector stores
- `tracking/` - Durable response and callback state, including the SQLite event journal, that prevents duplicate replies across restarts
- `credentials/` - Secrets synchronized from `.env`
- `logs/` - Application logs
- `matrix_state.yaml` - Matrix connection state
- `encryption_keys/` - Matrix E2EE keys (if enabled)

Set `MINDROOM_SESSION_STORAGE_PATH` to move agent and team session databases to a separate root; learning and memory stay under the storage directory.

### Backups

Back up the whole storage directory, and keep `tracking/` on persistent storage.
When `MINDROOM_SESSION_STORAGE_PATH` is set in a container, mount that path on persistent storage and back it up too.

`tracking/` keeps a small record of every handled event so restarts and resyncs never answer the same message twice.
These records are never pruned automatically, so size and monitor the volume for growth over the install's lifetime.
See [Bot Runtime Architecture](https://docs.mindroom.chat/architecture/bot-runtime/#durable-dispatch-boundary) to inspect the records or remediate a corrupted one.

### Incompatible session databases

If an upgraded Agno version requires session columns that an agent's or team's session database lacks, MindRoom moves that `sessions/` directory aside to `sessions.incompatible-<id>/` and starts a fresh conversation history.
The archive is kept intact for inspection or manual recovery; back it up and delete it when you no longer need it.

## Event Journal

The event journal records which Matrix events were already handled and delivered, so restarts do not produce duplicate replies.
By default it is SQLite at `<storage>/tracking/event_journal.db`, a location that cannot be changed separately.
To use PostgreSQL, install the `postgres` extra (for example `uvx --from 'mindroom[postgres]' mindroom run`, or `--extra postgres` when syncing a source checkout), then select the backend and provide a connection URL:

```yaml
event_journal:
  backend: postgres
  database_url_env: MINDROOM_EVENT_CACHE_DATABASE_URL
```

| Field | Type | Default | Meaning |
| --- | --- | --- | --- |
| `backend` | `sqlite` or `postgres` | `sqlite` | Journal storage backend; a URL alone does not switch to PostgreSQL |
| `database_url_env` | string | `MINDROOM_EVENT_CACHE_DATABASE_URL` | Variable holding the PostgreSQL URL, read from the process environment or the config-adjacent `.env` (process environment wins); custom names must be `DATABASE_URL` or end in `_DATABASE_URL` |
| `database_url` | string or `null` | `null` | Inline PostgreSQL URL that takes precedence over the variable |

Changes to `event_journal` apply after restarting MindRoom.
Read [Journal binding](#journal) before pointing an existing install at a different database.



## Journal binding

Each install is bound to one event-journal database the first time it opens one, and records that binding in `<storage>/tracking/event_journal_binding.json`.
MindRoom refuses to start against any other journal, because another database would silently lose turn deduplication, delivery ownership, and recovery ownership.
A refused database is left untouched.

| Startup error contains | Cause | Fix |
| --- | --- | --- |
| `has never been used by this install` | The configured database is new or empty, usually because the connection URL points somewhere new. | Check `event_journal` and the variable named by `database_url_env` and point them back at the bound database; adopt only if you want to start the journal's history fresh. |
| `is a different journal from the one this install is bound to` | The configured database belongs to another install, usually because the connection URL points at it. | Point `event_journal` back at the bound database, or adopt deliberately. |
| `could not be read` or `does not name a generation` | The binding file is corrupt or truncated. | Delete `<storage>/tracking/event_journal_binding.json`, then run `mindroom journal adopt` against the database you want; the file holds nothing the database does not. |

Do not adopt just because startup refused: a connection URL that drifted to a fresh database is the common cause, and adopting would abandon the real journal's history instead of finding it.



## Adopting a journal

`mindroom journal adopt` binds the install to the event-journal database configured right now.
It is the deliberate override of the startup refusal and the only fix for a lost or corrupt binding file.
Adopting the database the install was already using keeps its history, but adopting a different database abandons the deduplication, delivery, and recovery history in the previously bound journal.
It asks for confirmation when the install is already bound, unless `--yes` is passed.

Stop MindRoom before adopting, because a running MindRoom keeps writing to the journal it started with and adopting under it would split the install's history across two databases.
Adoption refuses with `Another process still has this install's event journal open` while MindRoom is running; a crashed MindRoom does not block it.
`--force` adopts anyway; use it only when you are certain nothing is running, for example when several hosts share one storage root over a network filesystem, where the running check does not work across hosts.

If adoption fails, for example because the database is unreachable, the previous binding stays in place.

### Moving a journal safely

A full copy of a journal database keeps its identity, so the binding accepts it without adoption.
This also means a stale copy is accepted without complaint, even though every turn since it was taken is missing, so copy only from a stopped install.

1. Stop MindRoom, and any `mindroom threads export --watch` running against the same storage root.
2. Copy or dump-and-restore the database in full.
3. Configure the destination PostgreSQL backend and URL, or move the SQLite journal together with its storage root.
4. Start MindRoom.

Adopt instead of copying only when you accept beginning the journal's history fresh.

# Config Bundles

A config bundle is a complete configuration directory: `config.yaml`, its included YAML and text files, prompts, `.env`, and any other assets.
The bundle commands deploy such a directory as a unit, validate it before activation, refuse to overwrite hand edits, keep the replaced tree for rollback, and confirm that the running MindRoom applied it.

| Task | Command |
| --- | --- |
| Hot-apply a YAML/include change to a running MindRoom, wait for the result, and optionally roll back | [`config apply-bundle`](#config-apply-bundle) |
| Install any change, including `.env`, plugins, or other assets, without waiting for the runtime | [`config install-bundle`](#config-install-bundle) |
| Seed or update the config directory when MindRoom starts | [`mindroom run --bootstrap-config-bundle`](#install-at-startup) |
| Check whether a change can be applied by config hot reload | [`config classify-change`](#config-classify-change) |
| Confirm the runtime applied a config | [`config fingerprint` and `config check-applied`](#config-fingerprint-and-config-check-applied) |

Keep runtime state and storage outside the bundle directory, and leave `.mindroom-bundle.json` inside it to the installer.

## config install-bundle

Validate and install a complete configuration directory:

```bash
mindroom config install-bundle ./candidate --target ./active --json
mindroom config install-bundle ./candidate --target ./active --initialize-only
mindroom config install-bundle ./candidate --target ./active --revision deploy-2
mindroom config check-applied --path ./active/config.yaml --fingerprint <receipt-fingerprint> --wait 300
# Roll back, pinned to the digest of the tree the installation moved aside:
mindroom config install-bundle ./active.previous --target ./active --expected-digest <receipt-previous-digest> --json
```

`--target` is the directory to install into, `--config` selects the config file relative to the bundle root (default `config.yaml`), and `--json` prints the receipt as JSON.

The candidate is validated as MindRoom would load it, including includes and environment, before anything is activated.
Includes must stay inside the config file's directory, as described in [Splitting the Configuration](https://docs.mindroom.chat/configuration/#splitting-the-configuration-into-multiple-files).
Run the installer with the runtime's environment and filesystem access; external resources the config references are validated but not part of the bundle.
Automatic config migrations rewrite the installed copy, never the source bundle.

An invalid candidate leaves the active and previous trees untouched.
Bundles containing symlinks or special files are rejected, and so are mounts at or inside the target and its recovery paths, even with `--force`.
The target must be a directory below a writable mount, not the mount point itself.

### Protection against hand edits

An identical candidate returns `unchanged` and changes nothing.
A changed candidate replaces the active tree only if every file name, mode, and content still matches the last installation, including `.env` and files outside the YAML include graph.
A target that was edited or never installed by this command fails with `Target contains authored edits or is unmanaged; use install-bundle --force to replace it explicitly.`
`--force` adopts or resets such a target but never bypasses validation; it cannot be combined with `--initialize-only`.

`--source-only` refuses a changed candidate unless every difference lies within the YAML/include sources of `--config`, as reported by [`config classify-change`](#config-classify-change); a refused candidate is never installed.
`--source-only` also requires an existing target, so omit it for the initial installation.

### Rolling back

Every replacement keeps the complete former tree at `TARGET.previous`; keep it until the runtime confirms the new config.
To roll back, install `TARGET.previous` with the receipt's `previous_digest` as `--expected-digest`.
The digest covers the whole tree, including `.env` and other assets that the config fingerprint does not cover, and `--expected-digest` can pin any candidate source this way.
After the rollback, the rejected tree becomes the new `TARGET.previous`, so repeating a pinned rollback fails instead of switching back to the rejected tree.
If the active tree was edited since the installation, rollback also requires `--force`.
`install-bundle` never rolls back on its own when the runtime rejects a config; use `apply-bundle --rollback-on-failure` for that.

### Install at startup

`mindroom run --bootstrap-config-bundle SOURCE --config TARGET/config.yaml` installs the bundle before MindRoom loads its environment.
The target directory is the selected config file's directory, and the source must contain the same config filename at its root.
Validation reads the bundle's `.env`, with exported process variables and an explicit `--storage-path` taking precedence, and startup then uses the installed tree.

Without a revision, startup installs the bundle only when the target directory is absent and otherwise keeps it unchanged and unvalidated, returning `initialized` with no digest or fingerprint.
Add `--bootstrap-config-bundle-revision REVISION` (which requires `--bootstrap-config-bundle`) to roll out a new bundle through restarts:

- A target whose recorded revision matches is kept, including later hot installs and rollbacks.
- A target with a different or no recorded revision gets the candidate after full validation, but only if the target is unedited and was installed by these commands, even when the content is identical; adopt or reset it once with `install-bundle --force`.
- When only the revision differs, only the recorded revision changes and the files stay untouched.
- A failed installation keeps the previous revision.

These are the same rules as `install-bundle --initialize-only` with and without `--revision`.
A revision is a nonblank string of at most 128 UTF-8 bytes without control characters.
Installs without `--revision` keep the active revision and ignore any revision recorded in the incoming bundle.

### Receipts and exit codes

Exit `0` means the tree was installed or kept; exit `2` reports an installation error, printed as `{"status": "failed", "detail": ...}` with `--json`.
The JSON receipt contains these fields:

| Field | Meaning |
| --- | --- |
| `status` | `installed`, `unchanged`, or `initialized` |
| `config_path` | Installed config file |
| `digest` | Whole-tree digest of file names, modes, and contents, excluding installer metadata; `null` for `initialized` |
| `fingerprint` | [Config source fingerprint](#config-fingerprint-and-config-check-applied) to pass to `check-applied`; `null` for `initialized` |
| `recovery_pending` | `true` when the new tree is active but moving the old tree to `TARGET.previous` needs a retry |
| `previous_digest` | Whole-tree digest of the tree moved to `TARGET.previous`, or `null` when nothing was replaced |

A changed installation triggers the running MindRoom's [config hot reload](https://docs.mindroom.chat/configuration/#configuration-file); confirm the result with `config check-applied`.
Hot reload does not reread `.env` or other assets, so environment changes can need a restart.

### Interrupted installs

Readers can briefly find the target missing during replacement but never see a partly copied tree.
Installation is not durable against power loss and does not coordinate with other programs writing to the tree.
After an interrupted install, rerun the command to finish recovery before doing anything else.
Do not modify the reserved sibling paths `.TARGET.pending`, `.TARGET.retired`, `.TARGET.transaction`, and `.TARGET.lock`.
If recovery reports changed previous or recovery trees, inspect them manually.
A process killed while copying can leave an unused `.TARGET.stage-*` directory that you can delete.

## config classify-change

Check whether a candidate tree differs from the current tree only in the YAML/include sources that config hot reload rereads:

```bash
mindroom config classify-change ./active ./candidate --json
mindroom config classify-change ./old-tree ./new-tree --config prod/config.yaml --config staging/config.yaml
```

Sources are the files the YAML loader reads from each `--config` entrypoint (default `config.yaml`, repeatable) in either tree, including transitively included YAML and text files, plus directories that only appear or disappear around them.
Every entrypoint must load in both trees.
Every other difference is reported as `other`, including `.env`, plugins, scripts, unreferenced files, directory mode changes, and include paths that switch between file and directory.
Installer metadata in `.mindroom-bundle.json` is ignored.
Hot reload does not reread `other` paths, so they may need a restart or another mechanism such as [plugin hot reload](https://docs.mindroom.chat/plugins/#live-development-hot-reload).
A source-only result does not prove that the runtime applies every changed setting; confirm with `config check-applied`.
The command only reads both trees, rejects symlinks and special files, and lists paths but never file contents.

JSON output contains `status` (`source_only` or `non_source`), `sources`, and `other`.
Exit codes are `0` when every difference is a source change (including no difference), `1` for other changes, and `2` when either tree cannot be classified, printed as `{"status": "failed", "detail": ...}` with `--json`.

## config apply-bundle

Hot-apply a source-only change to the directory a running MindRoom loads, wait for the runtime result, and optionally restore the previous tree when the runtime rejects it:

```bash
mindroom config apply-bundle ./candidate --target ./active --rollback-on-failure --wait 300 --json
```

The command first checks that the runtime reports a reload status, using the same `--url`, `--timeout`, `MINDROOM_API_KEY`, and HTTPS rules as [`check-applied`](#config-fingerprint-and-config-check-applied), and installs nothing until it does.
It then installs the candidate like `install-bundle --source-only` without `--force`, honoring `--config` and `--expected-digest`, and waits up to `--wait` seconds (default 300) for the runtime result.
Changes to `.env`, plugins, or other files that hot reload does not reread are refused before anything is installed; install those with `install-bundle` instead.
With `--rollback-on-failure`, a runtime rejection of a changed installation restores `TARGET.previous`, pinned to the receipt's `previous_digest`, and waits again for that tree.
Pending, unreadable, or restart-required results never roll back, and neither does a failure when a reload of the same or an unknown config was already pending or failed before installation.

| Status | Exit code | Meaning |
| --- | --- | --- |
| `applied` | `0` | The runtime applied the candidate. |
| `pending` | `1` | The candidate is installed, but the runtime had not settled it within `--wait`; confirm later with `check-applied`. |
| `failed` | `2` | Nothing was installed, or the runtime rejected the candidate and no rollback was requested or possible, so the candidate stays installed. |
| `rolled_back` | `3` | The runtime rejected the candidate, and the restored previous tree was applied. |
| `unconfirmed` | `4` | The runtime result could not be read or proven, or restoring or confirming the previous tree failed; inspect before retrying. |
| `restart_required` | `5` | The runtime adopted the candidate, but part of it needs a restart. |

The JSON receipt contains `status`, `detail`, the candidate `install` receipt and its `runtime_status`, and the `rollback` receipt and its `rollback_runtime_status`.
`install` is `null` when nothing was installed, and `rollback` is `null` unless the previous tree was restored.

## config fingerprint and config check-applied

Confirm that the running MindRoom finished applying a particular config:

```bash
mindroom config fingerprint --path ./config.yaml
mindroom config check-applied --path ./config.yaml --wait 300
mindroom config check-applied --fingerprint <sha256> --url https://example.org --json
```

`fingerprint` prints a SHA-256 over the config file and all transitively included YAML and text files; for a config without includes it equals the file's SHA-256.
Moving the tree to another directory keeps its fingerprint.
Comment and formatting changes alter the fingerprint, while unrelated files and environment variables do not.

When reading a config file, both commands refuse legacy access settings, exiting `2`, because the runtime would rewrite those source files when migrating them.
Run `mindroom config migrate --path <config-path>` first.
`check-applied --fingerprint` does not read the local config, so it needs no local migration, but the fingerprint must be that of the migrated config.

`check-applied` reads the authenticated `GET /api/config/reload-status` endpoint from `--url` (default `MINDROOM_URL`, then `http://127.0.0.1:8765`).
It requires `MINDROOM_API_KEY`, and HTTPS for remote URLs.
Without `--wait`, it checks once; `--wait` bounds the polling period and `--timeout` (default 10 seconds) bounds each request.
Neither command changes config or triggers a reload.

| Exit code | Meaning |
| --- | --- |
| `0` | The runtime applied the expected fingerprint. |
| `1` | Pending, or the runtime reports a different fingerprint, including when `--wait` expires. |
| `2` | The runtime failed to apply the expected fingerprint, needs a restart, or its status is unavailable. |

JSON output shows the expected and observed fingerprints.
The endpoint reports only the latest reload, with status `pending`, `applied`, `failed`, `restart_required`, or `unavailable` (HTTP 503, until MindRoom is ready).

The runtime reports `applied` once the reload finished, including changes that need no agent restart, but this does not prove that every bot or external service is healthy.
A change to the [event journal](https://docs.mindroom.chat/deployment/storage/#event-journal) database reports `restart_required`.
If the YAML fails to parse, the failure has no fingerprint, so a wait for a specific fingerprint reports pending until it expires.

# Operational Log Events

This page covers how to monitor a running MindRoom: the health and readiness endpoints, the structured log events that are stable enough for metrics and alerts, and the debug logging you can turn on while troubleshooting.
Most other log event names are internal diagnostics and can change in any release.

See also [Routing & Responder Selection](https://docs.mindroom.chat/configuration/router/), [Threads, Replies & Participation](https://docs.mindroom.chat/configuration/threads/), and [Access Control](https://docs.mindroom.chat/authorization/).

## Health & Readiness

| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/api/health` | Liveness. Returns `200` with `{"status": "healthy", ...}`, or `503` with `"status": "unhealthy"` once startup is ready and an agent, team, or router has stopped syncing with Matrix |
| GET | `/api/ready` | Readiness. Returns `{"status": "ready"}` when startup has finished, otherwise `503` with `{"status": "<phase>", "detail": "..."}` |

Use `/api/health` for liveness probes and `/api/ready` for readiness probes in container orchestrators.

The `/api/health` body also contains:

- `last_sync_time`: the oldest last successful Matrix sync among running entities.
- `stale_sync_entities`: the names of entities whose Matrix sync is stale, when there are any.
- `e2ee`: decryption-failure counters (see [Troubleshooting Undecryptable Messages](https://docs.mindroom.chat/matrix/#troubleshooting-undecryptable-messages)).
- `embedder`: `{"status": "failing", "detail": ...}` with the cause while the semantic-search embedder is failing; this never makes the endpoint unhealthy.

The `/api/ready` phases are:

| Phase | Meaning |
|-------|---------|
| `idle` | Process not started |
| `starting` | Startup in progress; `detail` says what it is waiting for |
| `ready` | Startup finished and requests are being served |
| `failed` | Startup or runtime failure; `detail` gives the cause |

While `mindroom run` waits for hosted pairing approval, only these two endpoints answer: `/api/health` returns `200` and `/api/ready` returns `503` with phase `starting` and detail `Waiting for local pairing approval`.

When an agent, team, or router receives no Matrix sync for 120 seconds, MindRoom restarts that entity's sync loop on its own.
After 180 seconds without a sync, `/api/health` returns `503` and lists the entity in `stale_sync_entities`.
While an entity is still making progress on a large backlog of received events, both wait up to [`MINDROOM_MATRIX_INGESTION_GRACE_SECONDS`](https://docs.mindroom.chat/configuration/#matrix).
Set the liveness probe's `failureThreshold` high enough that the sync-loop restart can recover before the container is restarted.

## Collecting the Events

Set `MINDROOM_LOG_FORMAT=json` so each record is written as one JSON object per line to stderr and to the runtime log file under `<storage>/logs/`.
Every record carries `event`, `level`, `logger`, and `timestamp` next to its own fields, so log collectors that parse JSON lines can filter on `event` and extract numeric fields.

```json
{"sample_count": 1200, "p50_ms": 0.412, "p95_ms": 1.87, "p99_ms": 4.02, "max_ms": 11.3, "gc_collections_total": [5120, 466, 31], "max_lag_scheduled_at": "2026-01-01T12:00:41.250+00:00", "max_lag_observed_at": "2026-01-01T12:00:41.261+00:00", "event": "event_loop_scheduler_lag_summary", "logger": "mindroom.event_loop_stall", "level": "info", "timestamp": "2026-01-01T12:01:00.012345Z"}
```

`event_loop_scheduler_lag_summary` is an `info` record, so keep `LOG_LEVEL` at `INFO` or lower, or raise only that logger with `MINDROOM_LOGGER_LEVELS=mindroom.event_loop_stall:INFO`.
The other events on this page are `warning` records.
See [Environment Variables](https://docs.mindroom.chat/configuration/#environment-variables) for the logging controls.

## Events

The event names and fields below are stable operator signals.
Each section lists the fields, a suggested alert, and where to look when it fires.
The thresholds are starting points to tune against your own baseline, and the queries are generic pseudo-queries to translate into your log platform's syntax.

### `event_loop_scheduler_lag_summary`

All agents, teams, and the router share one event loop.
Every 60 seconds MindRoom logs how late a heartbeat scheduled every 50 ms actually ran.

| Field | Meaning |
|-------|---------|
| `sample_count` | Heartbeat samples in the window |
| `p50_ms` | Median heartbeat lateness in milliseconds |
| `p95_ms` | 95th percentile heartbeat lateness in milliseconds |
| `p99_ms` | 99th percentile heartbeat lateness in milliseconds |
| `max_ms` | Worst heartbeat lateness in milliseconds |
| `max_lag_scheduled_at` | UTC time at which the worst heartbeat was due |
| `max_lag_observed_at` | UTC time at which the worst heartbeat ran |
| `gc_collections_total` | Cumulative garbage collections per generation since process start |

An idle or lightly loaded loop reports a `p50_ms` of a few milliseconds or less.
A sustained high median means the loop is saturated and replies slow down, even while `/api/health` reports healthy, because liveness tracks Matrix sync rather than loop latency.

```text
metric  = distribution of p50_ms
          from logs where event = "event_loop_scheduler_lag_summary"
alert if  median(metric) over 10 minutes > 20
          for 30 minutes
```

When it fires, look for `event_loop_stall_detected` and `event_loop_stall_ongoing` records, which include the blocking stack when one stall exceeds `MINDROOM_EVENT_LOOP_STALL_THRESHOLD_SECONDS` (default `5`).
Compare the frequency of `event_loop_gc_collection` records, which report garbage collections slower than 50 ms.
Repeated `large_streaming_edit_preview_prepared` records with a growing `original_size_bytes` in one room point at one long streaming response.
Setting `MINDROOM_EVENT_LOOP_STALL_THRESHOLD_SECONDS` to `0` or a negative value disables stall detection and this summary.

### `matrix_sync_stall_diagnostics`

Logged when one agent, team, or router goes 90 seconds without Matrix sync progress, and repeated at most every 90 seconds while the stall lasts.
It shows where that entity's sync and event-processing tasks are waiting, without message content, and does not affect health or readiness.

| Field | Meaning |
|-------|---------|
| `agent` | Name of the entity that made no progress |
| `no_progress_seconds` | Seconds since the last sync or event-processing progress |
| `sync_age` | Seconds since the last sync activity, or `null` before the first sync result |
| `generation` | Event-processing progress counter, or `null` when none is reported |
| `snapshots` | Up to four task snapshots, each with `task_name`, `await_chain`, `await_boundary`, and `truncated` |

Each report covers its own 90 seconds without progress, so five reports for one entity within 15 minutes mean at least seven and a half minutes, not necessarily continuous, without progress.

```text
metric  = count of logs where event = "matrix_sync_stall_diagnostics"
          grouped by agent
alert if  sum(metric) over 15 minutes >= 5 for any agent
```

Read the `await_chain` snapshots to see where each task is waiting.
Reports for every entity at once usually point at the homeserver or at a saturated event loop, so check `event_loop_scheduler_lag_summary` as well.

### `tool_call_limit_reached`

Each agent or team run has a tool-call budget, normally the entity's [`max_tool_calls_per_turn`](https://docs.mindroom.chat/configuration/agents/#configuration-options).
Calls past the budget fail with a tool error, and this event is logged once when a run keeps requesting tools and is stopped, ending the turn with the text produced so far.

| Field | Meaning |
|-------|---------|
| `entity` | Agent or team name; dynamic workflow participants appear as `dynamic_workflow_<participant id>` |
| `budget` | The run's tool-call budget |
| `model_requests` | Model requests the run made before it was stopped |

```text
alert on  each log where event = "tool_call_limit_reached"
notify    at most once per hour
```

Check whether the run was a runaway loop, such as the same tool call repeated or calls to unknown tools, or legitimate long work.
For legitimate long work, raise that entity's `max_tool_calls_per_turn`.
Skill reviews also log this record under the reviewed agent's name, with a fixed `budget` of 16 that `max_tool_calls_per_turn` does not change.

## Debug Logging

Every completed model request logs an `LLM usage` event with provider and model identifiers and, when the provider reports usage, input tokens, uncached input tokens, and a cache-read ratio, without prompt content.

To record full provider requests, enable `debug.log_llm_requests`:

```yaml
debug:
  log_llm_requests: true
  llm_request_log_dir: null
```

- `log_llm_requests` (bool, default `false`): write provider requests as JSONL, and record successful tool calls with timing in `<storage>/tracking/tool_calls.jsonl`, where `<storage>` is `MINDROOM_STORAGE_PATH` or the default `mindroom_data` (tool failures are always recorded there).
- `llm_request_log_dir` (string, default `null`, meaning `<storage>/logs/llm_requests`): directory for the request logs.

Request logs include prompts, messages, the tools sent to the provider, model parameters, and requester and source Matrix event metadata.
Credentials such as tokens, cookies, passwords, API keys, and authorization headers are redacted, but these files can still contain sensitive prompt, argument, and result data, so leave the flag disabled unless you are actively debugging.

Export `MINDROOM_TIMING=1` before startup to log timing for tool calls and one INFO-level `Dispatch pipeline timing` summary per turn, including `time_to_model_request_ms` and context, queue, payload, agent-build, and model spans.

```bash
LOG_LEVEL=DEBUG MINDROOM_LOG_FORMAT=json MINDROOM_TIMING=1 mindroom run
```

### Memory Growth

To find what grows the primary process's memory, set `MINDROOM_HEAP_PROBE_INTERVAL_SECONDS` in the process environment or the `.env` file next to `config.yaml`.
MindRoom then logs one `heap_type_probe` event per interval with the 25 most common object types and their counts, resident memory (`rss_bytes`), allocator totals (`arena_bytes`, `arena_in_use_bytes`, `arena_free_bytes`, `mmap_bytes`), and `python_allocated_blocks`.
Resident memory that grows while `arena_in_use_bytes` plus `mmap_bytes` and `python_allocated_blocks` stay flat suggests freed memory the allocator keeps.
Growth in those suggests live objects, which the type histogram may not show because it does not count strings, bytes, or numbers.
Each probe briefly pauses the event loop, so prefer intervals of several minutes on large processes.
Unset or `0` disables the probe (the default), and any other value below `60` fails startup.

# Upgrade Notes

Use this page when upgrading an existing deployment across a release that needs operator action.
Each section names the change, what to do before or after the upgrade, and what does not carry over.
Always back up configuration and persistent storage before replacing the running version, and never run two backend versions against the same storage at once.

## Upgrading to Nio 1.0

Existing deployments upgrade automatically during normal startup.
Stop the previous backend, install the new release, and start it with the same configuration and storage root.
No journal archival, database reset, or `mindroom journal adopt` command is required.
If you customized `MINDROOM_MATRIX_SYNC_CACHE_WRITE_GRACE_SECONDS`, replace it with `MINDROOM_MATRIX_INGESTION_GRACE_SECONDS`.

### What the automatic migration preserves

The event journal is upgraded in place on both SQLite and PostgreSQL, keeping message history, redactions, and handled-turn records, so restarts do not produce duplicate replies.
Configuration, credentials, workspaces, memories, knowledge stores, and agent sessions are untouched.
Matrix accounts, device IDs, encryption keys, and device trust remain intact.

### What does not continue

Replies and tool approvals left unfinished before the upgrade are abandoned and do not resume.
An old approval card may stay visible but cannot be approved.
A tool action that already ran is not undone, so check its result before retrying it manually.

On first startup each room's existing history is loaded as context only and does not trigger replies or commands, including encrypted messages whose keys arrive later.
Messages sent during the downtime can therefore stay unanswered; resend any request you still want handled once startup completes.
Later messages are handled normally.
The old `tracking/room_member_joins.json` file is no longer read, and existing room members are not onboarded again.

### Device recovery after the upgrade

Normal restarts reuse the existing Matrix device.
If the homeserver reports a soft logout, MindRoom renews credentials for the same device with the configured authentication method, without losing pending work or encryption keys.

MindRoom never replaces a device automatically.
If the device store is missing, restore the deployment's matching storage backup before restarting.
A hard logout, a deleted server device, a changed returned identity, corrupt storage, or an unknown storage format requires operator recovery.
Clearing the journal or the crypto directory is not a supported repair, because a Nio 1.0 device session is bound to its journal.
Preserve the failed deployment's state while recovering it, because pending input and attempted deliveries may still need reconciliation.

## Private storage migration

Private storage created by v2026.9.32 or earlier uses older requester directory names.
The first primary startup of a release with this migration moves every verified private scope to its current path before accepting traffic, in both the orchestrator and the standalone primary API.
Current and empty storage need no migration and trigger no worker checks.

### Before upgrading

1. Pause new requests and scheduled work, then drain active responses, tool calls, shell commands, and background processes.
   Stop every writer through the deployment lifecycle, including the previous primary, independent controllers, supervised processes, managed Docker or Kubernetes workers, and external runners.
   Disable worker creation, restart policies, and external reconciliation until the migration finishes, because MindRoom cannot fence older binaries or the control plane.
2. Remove managed Docker worker containers, including stopped ones, while keeping their host state and credential directories.
   For Kubernetes, delete every worker Deployment and ReplicaSet and wait until every worker Pod, including terminating Pods, is gone; scaled-down controllers still block migration.
   The check covers every resource labeled `mindroom.ai/worker-id` in the configured worker namespace, whatever primary owns it, so deployments sharing that namespace must coordinate this quiet window.
3. Back up primary storage, session storage, and worker state together while writers are stopped.
4. Mount the original volumes at the same configured primary and session paths.
   When `MINDROOM_SESSION_STORAGE_PATH` is set, make sure that volume is available before starting.
5. Start the new primary with worker creation still disabled externally.
   When a move is needed, startup checks that no workers exist before changing anything; it never stops or deletes workers itself.
   A worker API failure, missing permissions, or remaining workers stops startup.
6. Resume admission, scheduling, and worker creation only after startup succeeds.

Kubernetes primary service accounts need `list` on `deployments` and `replicasets` in the `apps` API group and on `pods` in the core API group, within the configured worker namespace.
The runtime chart's worker-manager Role includes these permissions, so apply the updated Role before starting the upgraded primary; externally managed RBAC must grant the same.

Static external sandbox runners must be stopped separately.
For the migration startup, remove `MINDROOM_SANDBOX_PROXY_URL` from the primary configuration after stopping the runner, because a configured external runner blocks the migration.
Restore the setting and restart the runner after the migration finishes.

### What changes

Files, databases, credentials, workspaces, and histories keep their contents.
Each old directory name stays behind as a relative symlink to its new directory, so absolute workspace paths and script shebangs keep working.
Keep these links when backing up and restoring the volumes; they are not recreated if lost.
Worker credential directories are not moved.

Private scopes without an owner record, which older releases could create, are left in place with the warning `Preserving private scope without an owner record; not migrating`.
They are not attached to the requester's new private scope.
Recover that history manually only after independently verifying its owner, with all writers stopped.

While a move is pending, its scope contains `.mindroom-private-storage-migration.json`, which holds private owner information; never delete or copy it.

### Interrupted or rejected startup

Restart the primary with the same mounted data and configured paths to resume an interrupted migration; remounts that keep the same directories are fine.
Do not create destination directories or start other writers during recovery.

An entry under `private_instances` whose ownership cannot be verified is left untouched with the warning `Leaving an invalid private storage entry untouched; not migrating`, and startup continues.
Startup stops with an error starting with `Private storage migration:` for conflicts it cannot leave aside, such as a missing or overlapping volume, two scopes claiming the same owner, session-only data without a primary scope, or nested mounts.
Correct the reported conflict with all writers stopped, then restart.

Once every verified move finishes, startup writes `tracking/private_storage_migrated.json` and later starts skip the migration.
To migrate an entry that was left untouched, fix it with all writers stopped, delete that file, and restart through the quiet window above.

There is no migration CLI or automatic rollback.
To return to an older release, stop all writers and restore the coordinated backups together before starting it.

## Membership Access Migration

See [Access Control](https://docs.mindroom.chat/authorization/#configuration) for the membership access schema.

Loading a single-file configuration that still uses retired access fields converts it automatically.
MindRoom validates the result, replaces `config.yaml`, removes the retired fields, keeps explicit new-schema values, and saves the original once as `config.yaml.pre-membership-access`.
The rewritten YAML loses comments and hand formatting; the backup keeps both.
When `config.yaml` is a single-file Docker bind mount that cannot be replaced, loading stops and asks you to run `mindroom config migrate --path <host-config.yaml>` on the host.

If the migration is rejected, replace non-concrete identity grants with concrete Matrix user IDs, replace unresolved room IDs or aliases with managed room keys, and remove `authorization.agent_reply_permissions` entries for agents or teams that no longer exist, then retry.

Configurations that use `!include` are not migrated.
When retired access fields and `!include` appear together, loading fails with `Automatic access migration does not support !include configurations` and changes no files.
Remove the includes or migrate the combined configuration by hand before retrying.

## Upgrade and reset limits

Smaller changes that need operator action:

- **Host browser files**: primary-process browser profiles live in each agent's state root, and `<storage>/browser-profiles` is not read; sign in again, then delete that directory.
  Default host browser screenshots, PDFs, and downloads live in `browser/` in each agent's state root, and `upload` does not accept files left in `<storage>/browser`; move any you still need into the agent workspace, then delete it.
- **Browser egress proxy**: Chromium in the host, headless worker, and Computer browsers and in `crawl4ai` tunnels every connection, plain HTTP included, through the configured egress proxy with HTTP `CONNECT`.
  The proxy must allow `CONNECT` to ports 80 and 443, and for the primary also to IP addresses; the chart-managed approved egress proxy needs the [minimum proxy image version](https://docs.mindroom.chat/deployment/approved-egress/#enable-with-the-runtime-chart).
  In a sandbox runner, set every proxy variable, or only `all_proxy`, to the same HTTP(S) proxy, or the browser does not start.
  See [Egress Proxy for Browsers](https://docs.mindroom.chat/tools/web-scraping-and-browser/#egress-proxy-for-browsers) for the full rules.
- **Boolean tool settings**: stored tool config values for boolean fields, including credential seeds read from environment variables or files, must be JSON booleans, JSON `null`, or exactly `true` or `false`; any other value makes the tool fail to load with an error naming the field.
- **Restart resume setting**: `defaults.auto_resume_after_restart` is removed and fails validation, so delete it from `config.yaml`; a reply interrupted by a restart always continues in the same message.
- **Delegation records**: a delegation still running during the upgrade finishes normally, but its workspace `run.json`, `events.jsonl`, and `transcript.md` stop updating.

### Compaction Archive

Downgrading to a release without the [compaction archive](https://docs.mindroom.chat/configuration/history/#agent-compaction-settings) is unsupported: older releases neither maintain nor redact the archive, and compaction state they write is never adopted again.

When upgrading from v2026.9.314 or earlier, runs that those releases deleted during compaction are not recoverable.
Summaries compacted by those releases record no per-run provenance, so redacting an event they may contain still clears that conversation's summaries, the runs archived after them, and its live runs.

### Workspace-only worker mounts

From v2026.9.327, dedicated Docker and Kubernetes workers mount only agent workspaces.
Drain worker activity before upgrading, because the upgraded primary stops every older worker before serving, ending its tool calls, shells, background scripts, and CLI sessions.
Until those workers are gone, startup fails and the primary restarts, so the Docker daemon or Kubernetes API must be reachable.
Upgrade worker images together with the primary: Docker refuses worker images from older releases, and Kubernetes workers run the configured worker image, which must come from the same release.
Rolling back to v2026.9.326 is safe when the Docker and Kubernetes worker images roll back with the primary; upgrading again stops those workers at startup.

Before upgrading, check two limits:

- `private.root` cannot start with a name the primary writes beside the private workspace, listed under [Private Fields](https://docs.mindroom.chat/configuration/agents/#private-fields); such a configuration fails validation, so rename the root first.
- Knowledge files above 64 MiB are left out of every knowledge base, including operator-managed ones, and the next refresh removes their existing vectors; find them with `find <knowledge folder> -type f -size +64M`.

Older workers could write agent state roots, so after the upgrade check those roots for links they may have planted, running each command once with GNU find:

```bash
find "$STORAGE/agents" -mindepth 2 -type l -print -o -type d -regex "$STORAGE/agents/[^/]*/workspace" -prune
find "$STORAGE/private_instances" -mindepth 2 -type l -print -o -type d -regex "$STORAGE/private_instances/[^/]*/\([^/]*\)/\1_data" -prune
find "$STORAGE/agents" "$STORAGE/private_instances" -type f -links +1 -print
```

The first two commands skip links inside workspaces, which belong to the workers, and the verified old-name aliases directly below `private_instances`.
Remove every link they print, including a workspace that is itself a link, and inspect hard-linked files for data copied out of another instance.
The second command assumes the default `private.root` of `<agent>_data`.
When some agents set another root, pair every private agent with its root, default ones included, in one alternation such as `-regex "$STORAGE/private_instances/[^/]*/\(notes/notes_data\|mail/inbox\)"`; separate runs per root would print other agents' workspace links, and roots listed without their agents would skip same-named directories under other agents.

### Config-free runners and workers

The `static_runner` sidecar and dedicated Kubernetes workers do not mount the primary's config; they receive the agent settings they need with each request.
The runtime chart's `workers.kubernetes.configMapName`, `configKey`, and `configPath` values and the matching `MINDROOM_KUBERNETES_WORKER_CONFIG_*` settings are removed.
Background scripts still running on Kubernetes workers during the upgrade are interrupted once when the primary starts.
Kubernetes workers pick up the new pod template when they are next recreated.
Plugin directories beside a file-sourced config are not visible to the sidecar or Kubernetes workers, so install proxied plugins as Python packages in the runner image.

### Requester-scoped worker keys

v2026.9.33 changed the key of every `user` and `user_agent` worker, and workers from earlier releases are not reused or migrated.
After upgrading from an earlier release, reprovision every existing `user` and `user_agent` worker.
OAuth connections made for those scopes by ordinary Matrix IDs such as `@alice:example.org` keep working; reconnect any other integration connected for those scopes.
Shared and unscoped workers are unaffected.

# CLI Reference

This page lists every `mindroom` command with its options and links to the page that explains each feature in depth.
Start with `mindroom run`, which sets up, pairs, and starts MindRoom on first use, or use `mindroom config init` to create the configuration without starting.
Every command also prints its options with `--help`.

## Commands

<!-- CODE:START -->
<!-- from mindroom.cli.main import app -->
<!-- from typer.testing import CliRunner -->
<!-- runner = CliRunner() -->
<!-- result = runner.invoke(app, ["--help"]) -->
<!-- print("```") -->
<!-- print(result.output) -->
<!-- print("```") -->
<!-- CODE:END -->
<!-- OUTPUT:START -->
<!-- ⚠️ This content is auto-generated by `markdown-code-runner`. -->
```

 Usage: root [OPTIONS] COMMAND [ARGS]...

 AI agents that live in Matrix and work everywhere via bridges.

 Quick start:
 mindroom run           Set up on first run, pair, and start
 mindroom config init   Create a starter config without starting

╭─ Options ──────────────────────────────────────────────────────────────────────────────╮
│ --install-completion            Install completion for the current shell.              │
│ --show-completion               Show completion for the current shell, to copy it or   │
│                                 customize the installation.                            │
│ --help                -h        Show this message and exit.                            │
╰────────────────────────────────────────────────────────────────────────────────────────╯
╭─ Commands ─────────────────────────────────────────────────────────────────────────────╮
│ check-active-responses   Check live work; exit 0 idle, 1 busy, or 2 unavailable.       │
│ debug-report             Collect everything the backend stored about a reported        │
│                          conversation, as JSON.                                        │
│ version                  Show the current version of Mindroom.                         │
│ run                      Run the mindroom multi-agent system.                          │
│ doctor                   Check your environment for common issues.                     │
│ connect                  Connect this local MindRoom to your MindRoom Chat account by  │
│                          approving a link.                                             │
│ local-stack-setup        Start local Synapse + MindRoom Chat using Docker only.        │
│ config                   Manage MindRoom configuration files.                          │
│ plugins                  Validate and vendor external MindRoom plugins.                │
│ desktop                  Connect allowlisted local apps, read-only folders, and        │
│                          locally approved shell commands to cloud MindRoom over Matrix │
│                          E2EE.                                                         │
│ avatars                  Generate and sync managed avatar assets.                      │
│ threads                  Export Matrix threads to local files.                         │
│ journal                  Inspect and rebind the durable event journal.                 │
│ service                  Install and manage MindRoom as a background user service.     │
│ trigger                  Send signed external triggers.                                │
╰────────────────────────────────────────────────────────────────────────────────────────╯


```

<!-- OUTPUT:END -->

## run

Start MindRoom with your configuration.

On first run in a terminal, `mindroom run` creates the hosted starter config, pairs with MindRoom Chat, and offers to install the [login service](#service).
See [Getting Started](https://docs.mindroom.chat/getting-started/) for the setup questions and for unattended setup with `--provider` and `--service`.
An interactive terminal that nobody answers waits at the first prompt, so unattended runs should set `MINDROOM_CONFIG_TEMPLATE` or create the config first with `mindroom config init --no-input`.
On a hosted install, the dashboard becomes available after pairing is approved.
`.env` is read only at startup, so restart `mindroom run` after editing it.

<!-- CODE:START -->
<!-- from mindroom.cli.main import app -->
<!-- from typer.testing import CliRunner -->
<!-- runner = CliRunner() -->
<!-- result = runner.invoke(app, ["run", "--help"]) -->
<!-- print("```") -->
<!-- print(result.output) -->
<!-- print("```") -->
<!-- CODE:END -->
<!-- OUTPUT:START -->
<!-- ⚠️ This content is auto-generated by `markdown-code-runner`. -->
```

 Usage: root run [OPTIONS]

 Run the mindroom multi-agent system.

 This command starts the multi-agent bot system which automatically:
 - Creates a hosted starter config on first run in a terminal, or with --provider
 - Pairs hosted installs with your MindRoom Chat account on first run
 - Offers on first run to keep running as a background service that starts at login
 (--service/--no-service)
 - Creates all necessary user and agent accounts
 - Creates all rooms defined in config.yaml
 - Manages agent room memberships
 - Starts the bundled dashboard/API server (disable with --no-api)

╭─ Options ──────────────────────────────────────────────────────────────────────────────╮
│ --log-level                   -l                  TEXT     Set the logging level       │
│                                                            (DEBUG, INFO, WARNING,      │
│                                                            ERROR)                      │
│                                                            [env var: LOG_LEVEL]        │
│                                                            [default: INFO]             │
│ --config                      -c                  PATH     Use this config file path.  │
│                                                            Defaults the storage        │
│                                                            location to the selected    │
│                                                            config directory unless     │
│                                                            --storage-path is set.      │
│ --storage-path                -s                  PATH     Base directory for          │
│                                                            persistent MindRoom data    │
│                                                            (state, sessions, tracking) │
│ --bootstrap-config-bundle                         PATH     Initialize the selected     │
│                                                            config directory from this  │
│                                                            bundle only when the        │
│                                                            directory is absent.        │
│ --bootstrap-config-bundle-r…                      TEXT     Install a changed bootstrap │
│                                                            revision through native     │
│                                                            validation; preserve a      │
│                                                            matching active revision.   │
│ --api                             --no-api                 Start the bundled           │
│                                                            dashboard/API server        │
│                                                            alongside the bot           │
│                                                            [default: api]              │
│ --api-port                                        INTEGER  Port for the bundled        │
│                                                            dashboard/API server        │
│                                                            [default: 8765]             │
│ --api-host                                        TEXT     Host for the bundled        │
│                                                            dashboard/API server        │
│                                                            [default: 0.0.0.0]          │
│ --provider                                        TEXT     Model provider preset for   │
│                                                            the starter config when     │
│                                                            none exists, with the       │
│                                                            choices of `config init     │
│                                                            --provider`. It also lets   │
│                                                            setup run without a         │
│                                                            terminal, taking the API    │
│                                                            key from the environment.   │
│ --service                         --no-service             After setup and pairing,    │
│                                                            install and start MindRoom  │
│                                                            as a login service (systemd │
│                                                            or launchd) instead of      │
│                                                            running it here, or never   │
│                                                            offer to. A first run in a  │
│                                                            terminal asks.              │
│ --help                        -h                           Show this message and exit. │
╰────────────────────────────────────────────────────────────────────────────────────────╯


```

<!-- OUTPUT:END -->

### Debug logging

```bash
mindroom run --log-level DEBUG
```

`--log-level DEBUG` also enables debug logs from most dependencies, but Matrix `nio` loggers stay at `WARNING` unless you override them with `MINDROOM_LOGGER_LEVELS`.
To debug MindRoom alone, keep the global level at `INFO` and set per-logger overrides with [`MINDROOM_LOGGER_LEVELS`](https://docs.mindroom.chat/configuration/#environment-variables):

```bash
LOG_LEVEL=INFO MINDROOM_LOGGER_LEVELS="mindroom:DEBUG,httpx:WARNING,httpcore:WARNING,anthropic:INFO,nio:WARNING" mindroom run
```

Matrix decrypt warnings from `nio.crypto` are hidden by default; add `nio.crypto:WARNING` to `MINDROOM_LOGGER_LEVELS` to see them while debugging encryption.

## config

Create, view, edit, and validate `config.yaml`.

<!-- CODE:START -->
<!-- from mindroom.cli.main import app -->
<!-- from typer.testing import CliRunner -->
<!-- runner = CliRunner() -->
<!-- result = runner.invoke(app, ["config", "--help"]) -->
<!-- print("```") -->
<!-- print(result.output) -->
<!-- print("```") -->
<!-- CODE:END -->
<!-- OUTPUT:START -->
<!-- ⚠️ This content is auto-generated by `markdown-code-runner`. -->
```

 Usage: root config [OPTIONS] COMMAND [ARGS]...

 Manage MindRoom configuration files.

╭─ Options ──────────────────────────────────────────────────────────────────────────────╮
│ --help  -h        Show this message and exit.                                          │
╰────────────────────────────────────────────────────────────────────────────────────────╯
╭─ Commands ─────────────────────────────────────────────────────────────────────────────╮
│ init              Create a starter config.yaml with a personal agent and model.        │
│ show              Display the current config file with syntax highlighting.            │
│ edit              Open config.yaml in your default editor.                             │
│ validate          Validate config.yaml and check for common issues.                    │
│ resolve           Print the fully merged config YAML with all !include tags resolved.  │
│ path              Show the resolved config file path and search locations.             │
│ migrate           Migrate config.yaml to membership access settings.                   │
│ fingerprint       Print the config source SHA-256, including all transitively included │
│                   files.                                                               │
│ install-bundle    Validate and install a complete tree; use check-applied to confirm   │
│                   runtime reload.                                                      │
│ classify-change   Classify tree differences; exit 0 YAML/include sources only, 1 other │
│                   changes, 2 error.                                                    │
│ apply-bundle      Hot-apply a source-only tree change; exit 0 applied, 1 pending, 2    │
│                   failed, 3 rolled back, 4 unconfirmed, 5 restart.                     │
│ check-applied     Confirm config application; exit 0 applied, 1 pending/mismatch, 2    │
│                   failed/restart-required/unavailable.                                 │
╰────────────────────────────────────────────────────────────────────────────────────────╯


```

<!-- OUTPUT:END -->

### config init

Create a starter `config.yaml` and `.env` with the personal Mind agent, starter models, file-based memory, and sensible defaults.
For hosted MindRoom Chat, `mindroom run` runs this setup interactively on first run, so use `config init` to choose presets up front, use a self-hosted homeserver, or create files without starting.

| Option | Effect |
| --- | --- |
| `--path`, `-p` | Where to write `config.yaml`; `.env` goes in the same directory. Defaults to the auto-detected location, usually `~/.mindroom/config.yaml`. |
| `--matrix-server` | Where MindRoom creates Matrix users and rooms: `mindroom.chat` (hosted, default) or `self-hosted` (your own homeserver). |
| `--provider` | Default model provider: `anthropic`, `azure`, `bedrock_claude`, `codex`, `kimi`, `llama.cpp`, `ollama`, `openai`, `openrouter`, or `vertexai_claude`; defaults to `openai`, except that `self-hosted` in a terminal asks. |
| `--print` | Print the generated `config.yaml` with syntax highlighting without writing `config.yaml`, `.env`, or starter workspace files. |
| `--no-input` | Never prompt: keep an existing `config.yaml` unchanged, create anything missing, and use `openai` unless `--provider` is given. |
| `--force` | Overwrite without asking: replaces `config.yaml`, `.env` (including API keys and pairing credentials), the Mind starter workspace files such as `MEMORY.md` and `USER.md`, and the `AGENTS.md` beside the config; back up anything you want to keep first. |

Generated configs include commented model alternatives for providers with common variants, such as other OpenAI model tiers.

```bash
# Hosted Matrix quickstart (creates ~/.mindroom/config.yaml)
mindroom config init

# Create a config for a separate instance directory
mindroom config init --path ./instance/config.yaml

# Self-hosted Matrix with Anthropic
mindroom config init --matrix-server self-hosted --provider anthropic

# Preview generated YAML without writing files
mindroom config init --provider ollama --print

# Regenerate config.yaml, .env, and starter workspace files from scratch
mindroom config init --force
```

Some presets need a local login or server before MindRoom can answer:

- `codex`: run `codex login` first; see [Codex Models](https://docs.mindroom.chat/configuration/models/#codex-models-with-chatgpt-login) for the generated models.
- `kimi`: run `kimi` and `/login` first; see [Kimi Models](https://docs.mindroom.chat/configuration/models/#kimi-models-with-kimi-code-login).
- `ollama`: generates `gemma4` as `default` and `qwen3.8:27b` as `qwen3_8_27b`, with `OLLAMA_HOST=http://localhost:11434`; pull both models first with `ollama pull gemma4` and `ollama pull qwen3.8:27b`.
- `llama.cpp`: generates Unsloth GGUF models for a llama.cpp server at `http://localhost:8080/v1`; start it with one of the configured model refs:

```bash
llama-server -hf unsloth/gemma-4-26B-A4B-it-GGUF:UD-Q4_K_M --host 127.0.0.1 --port 8080
llama-server -hf unsloth/Qwen3.8-27B-GGUF:UD-Q4_K_XL --host 127.0.0.1 --port 8080
```

### config show

Display the current config file with syntax highlighting.

```bash
mindroom config show
mindroom config show --raw   # plain YAML for piping
mindroom config show --path /custom/path/config.yaml
```

### config edit

Open `config.yaml` in your editor, chosen from `$EDITOR`, then `$VISUAL`, then `nano`, `vim`, or `vi`.

### config validate

Check `config.yaml` against the schema, report errors readably, and warn about missing provider API keys.

### config path

Show the resolved config file path and every search location.

### config resolve

Print the fully merged YAML with every `!include` resolved and keys sorted, so you can diff the result before and after splitting a configuration into include files.

```bash
mindroom config resolve --path ./config.yaml
```

### config migrate

Convert a config that still uses retired access fields to the membership access settings, or print `No migrations applied.` when nothing needs to change.
See [Membership Access Migration](https://docs.mindroom.chat/deployment/upgrades/#membership-access-migration) for when to run it.

### Config bundle commands

`config fingerprint`, `config install-bundle`, `config classify-change`, `config apply-bundle`, and `config check-applied` install and confirm complete configuration trees; see [Config Bundles](https://docs.mindroom.chat/deployment/config-bundles/#config-install-bundle).

## doctor

Check your environment for common issues before running `mindroom run`.
It checks, in one pass:

- **Config file**: exists, is valid YAML, and matches the config schema.
- **Providers**: API keys for each configured provider (Anthropic, OpenAI, Ollama, Vertex AI Claude, and others) are valid.
- **Memory config**: the memory LLM and embedder are reachable (Ollama, OpenAI embeddings, sentence-transformers).
- **Matrix homeserver**: the homeserver answers `/_matrix/client/versions`.
- **Pairing**: on hosted installs, whether this machine is paired with MindRoom Chat; before the first run it passes with `Not paired yet`, because `mindroom run` pairs automatically.
- **Storage**: the storage directory is writable.
- **Encryption stores**: persisted Matrix device identities still have their local encryption stores.

<!-- CODE:START -->
<!-- from mindroom.cli.main import app -->
<!-- from typer.testing import CliRunner -->
<!-- runner = CliRunner() -->
<!-- result = runner.invoke(app, ["doctor", "--help"]) -->
<!-- print("```") -->
<!-- print(result.output) -->
<!-- print("```") -->
<!-- CODE:END -->
<!-- OUTPUT:START -->
<!-- ⚠️ This content is auto-generated by `markdown-code-runner`. -->
```

 Usage: root doctor [OPTIONS]

 Check your environment for common issues.

 Runs connectivity, configuration, and credential checks in a single pass
 so you can fix everything before running `mindroom run`.

╭─ Options ──────────────────────────────────────────────────────────────────────────────╮
│ --config        -c      PATH  Use this config file path. Defaults the storage location │
│                               to the selected config directory unless --storage-path   │
│                               is set.                                                  │
│ --storage-path  -s      PATH  Base directory for persistent MindRoom data (state,      │
│                               sessions, tracking)                                      │
│ --help          -h            Show this message and exit.                              │
╰────────────────────────────────────────────────────────────────────────────────────────╯


```

<!-- OUTPUT:END -->

## connect

Pair this machine with your MindRoom Chat account without starting MindRoom; `mindroom run` pairs automatically.
See [connect](https://docs.mindroom.chat/deployment/hosted-matrix/#connect) on the Hosted Matrix page for options, approval, and saved credentials.

```bash
mindroom connect --open-browser
```

## service

Install and manage MindRoom as a background user service that starts at login, using launchd user agents on macOS and systemd user services on Linux.
The service runs the MindRoom version that installed it, so rerun `mindroom service install` after upgrading.
Installing also installs that version as a uv tool, which puts a `mindroom` executable in uv's tool directory (usually `~/.local/bin`).
From a source checkout with commits past a release, this replaces an installed `mindroom` uv tool, including an editable one, with that release.
On a headless Linux machine, run `loginctl enable-linger` so the service keeps running after you log out.

The service does not see your shell's environment, so `mindroom service install` and `mindroom run --service` first save `MINDROOM_API_KEY` and any provider API keys exported in your shell (such as `OPENAI_API_KEY` or `OPENAI_API_KEY_FILE`) to `.env`.
Installation stops with `Refusing to write <KEY> to the env file` when an exported value cannot be stored in `.env` unchanged, for example because it contains `${`.
When the installed service would serve the dashboard without a credential, installing prints the open-dashboard warning in the terminal.
After editing `.env`, run `mindroom service restart`.

On first run in a terminal, a plain `mindroom run` offers to install the service after pairing; answering yes starts the service instead of running MindRoom in that terminal, and a failed installation falls back to the terminal.
The offer is skipped when a MindRoom service is already installed, when systemd is not running (for example in containers), with `--no-service`, and when `run` was given `--config`, `--storage-path`, `--no-api`, `--api-host`, or `--api-port`, because the service runs a plain `mindroom run`.
To install later, run `mindroom service install` or `mindroom run --service`.
`mindroom run --service` installs without asking, replaces an installed service, refuses those options, and exits with an error when the service cannot be installed.

<!-- CODE:START -->
<!-- from mindroom.cli.main import app -->
<!-- from typer.testing import CliRunner -->
<!-- runner = CliRunner() -->
<!-- result = runner.invoke(app, ["service", "--help"]) -->
<!-- print("```") -->
<!-- print(result.output) -->
<!-- print("```") -->
<!-- CODE:END -->
<!-- OUTPUT:START -->
<!-- ⚠️ This content is auto-generated by `markdown-code-runner`. -->
```

 Usage: root service [OPTIONS] COMMAND [ARGS]...

 Install and manage MindRoom as a background user service.

 MindRoom runs the version installed by this command through `uv tool run` and starts
 automatically at login.
 That version is also installed as a uv tool, so the service runs from a persistent
 environment instead of uv's cache.
 Rerun `mindroom service install` after upgrading MindRoom.

 Supported platforms:
 - macOS: launchd (`~/Library/LaunchAgents/`)
 - Linux: systemd user services (`~/.config/systemd/user/`)

╭─ Options ──────────────────────────────────────────────────────────────────────────────╮
│ --help  -h        Show this message and exit.                                          │
╰────────────────────────────────────────────────────────────────────────────────────────╯
╭─ Commands ─────────────────────────────────────────────────────────────────────────────╮
│ install     Install and start MindRoom as a background user service.                   │
│ uninstall   Stop and remove the MindRoom user service.                                 │
│ start       Start the installed MindRoom user service.                                 │
│ stop        Stop the installed MindRoom user service without removing it.              │
│ restart     Restart the installed MindRoom user service.                               │
│ status      Show MindRoom service status and recent logs.                              │
│ logs        Follow MindRoom service logs.                                              │
╰────────────────────────────────────────────────────────────────────────────────────────╯


```

<!-- OUTPUT:END -->

### service install

Install and start MindRoom as a background user service.
Use `--no-confirm` for non-interactive setup.

<!-- CODE:START -->
<!-- from mindroom.cli.main import app -->
<!-- from typer.testing import CliRunner -->
<!-- runner = CliRunner() -->
<!-- result = runner.invoke(app, ["service", "install", "--help"]) -->
<!-- print("```") -->
<!-- print(result.output) -->
<!-- print("```") -->
<!-- CODE:END -->
<!-- OUTPUT:START -->
<!-- ⚠️ This content is auto-generated by `markdown-code-runner`. -->
```

 Usage: root service install [OPTIONS]

 Install and start MindRoom as a background user service.

╭─ Options ──────────────────────────────────────────────────────────────────────────────╮
│ --skip-deps             Skip uv dependency check.                                      │
│ --no-confirm  -y        Skip confirmation prompts.                                     │
│ --help        -h        Show this message and exit.                                    │
╰────────────────────────────────────────────────────────────────────────────────────────╯


```

<!-- OUTPUT:END -->

### service status

Show service status and recent logs.
While the running service still waits for pairing, the status adds a `pairing: required` line, judged from the config and storage paths saved in the installed service.

<!-- CODE:START -->
<!-- from mindroom.cli.main import app -->
<!-- from typer.testing import CliRunner -->
<!-- runner = CliRunner() -->
<!-- result = runner.invoke(app, ["service", "status", "--help"]) -->
<!-- print("```") -->
<!-- print(result.output) -->
<!-- print("```") -->
<!-- CODE:END -->
<!-- OUTPUT:START -->
<!-- ⚠️ This content is auto-generated by `markdown-code-runner`. -->
```

 Usage: root service status [OPTIONS]

 Show MindRoom service status and recent logs.

╭─ Options ──────────────────────────────────────────────────────────────────────────────╮
│ --logs  -l      INTEGER  Number of recent log lines to show. Use 0 to hide logs.       │
│                          [default: 10]                                                 │
│ --help  -h               Show this message and exit.                                   │
╰────────────────────────────────────────────────────────────────────────────────────────╯


```

<!-- OUTPUT:END -->

### service uninstall

Stop and remove the MindRoom user service.
On macOS, log files are kept under `~/Library/Logs/mindroom/`.

<!-- CODE:START -->
<!-- from mindroom.cli.main import app -->
<!-- from typer.testing import CliRunner -->
<!-- runner = CliRunner() -->
<!-- result = runner.invoke(app, ["service", "uninstall", "--help"]) -->
<!-- print("```") -->
<!-- print(result.output) -->
<!-- print("```") -->
<!-- CODE:END -->
<!-- OUTPUT:START -->
<!-- ⚠️ This content is auto-generated by `markdown-code-runner`. -->
```

 Usage: root service uninstall [OPTIONS]

 Stop and remove the MindRoom user service.

╭─ Options ──────────────────────────────────────────────────────────────────────────────╮
│ --no-confirm  -y        Skip confirmation prompts.                                     │
│ --help        -h        Show this message and exit.                                    │
╰────────────────────────────────────────────────────────────────────────────────────────╯


```

<!-- OUTPUT:END -->

## check-active-responses

Check whether a restart would interrupt live work in the running MindRoom: Matrix responses, OpenAI-compatible requests, voice calls, and background script runs.
Run it before restarting, upgrading, or reloading a configuration that restarts agents.

```bash
mindroom check-active-responses
mindroom check-active-responses --json
mindroom check-active-responses --details
mindroom check-active-responses --wait 1800
```

| Exit code | Meaning |
| --- | --- |
| `0` | Runtime ready, admission open, and no active Matrix work, OpenAI requests, voice calls, or interruptible script runs. |
| `1` | Matrix work, OpenAI-compatible requests, voice calls, or interruptible script runs are active. |
| `2` | Status unavailable, including startup, replacement, connection failure, or an incompatible server. |

The snapshot reports these counts:

| Field | Counts |
| --- | --- |
| `active_matrix_operations` | Admitted Matrix work, including planning and waits for a response lock; nested work counts separately, so this is not a count of unique responses. |
| `active_openai_requests` | OpenAI-compatible chat completion requests until their response body finishes. |
| `active_calls` | [Voice calls](https://docs.mindroom.chat/voice-calls/) an agent has joined or is joining; a restart, or a reload that restarts the call agent, drops the agent from the call. |
| `interruptible_script_runs` | Unfinished [background script runs](https://docs.mindroom.chat/tools/background-scripts/) that a restart would interrupt. |
| `recoverable_script_runs` | Script runs that survive a restart; they do not make the runtime busy, but treat them as busy before a change that [interrupts them](https://docs.mindroom.chat/tools/background-scripts/#lifecycle-and-failure-semantics). |

Persisted approval waits, unadmitted queued messages, and unrelated background jobs are not counted.
The result is a point-in-time observation, not a restart lock, so new work can start right afterward.
The check needs the bundled API running inside `mindroom run`; with multiple processes, check each one.

`--details` adds one row per active response or call with `channel` (`matrix`, `openai`, or `call`), `responder` (the agent or `team/<team-name>`), and `requester_id`, plus `script_runs` rows with `run_id`, `responder`, `requester_id`, and `recoverable`.
Unknown identities are `null` in JSON and shown as unknown in text.
Detail rows need not match `active_matrix_operations`, because work can be busy before a response identity exists.
`--details` requires `MINDROOM_API_KEY` in the selected runtime environment, including when browser proxy authentication is enabled; a missing or rejected key exits `2`.
Aggregate output never includes identities.

`--wait SECONDS` polls every 5 seconds while busy and exits as soon as the runtime is idle.
If work is still active at the deadline, it exits `1` with the last busy snapshot, and an unavailable result ends the wait immediately with exit `2`.
Only the final snapshot is printed, and the total runtime can exceed `--wait` by up to one request timeout.

The CLI reads the unauthenticated `GET /api/responses/activity` endpoint, or `GET /api/responses/activity/details` with a bearer token for `--details`.
The URL comes from `--url`, then `MINDROOM_URL` from the selected environment, then `http://127.0.0.1:8765`.
`--config` selects the environment and `--timeout` sets each request's timeout (10 seconds by default).
`MINDROOM_API_KEY` is sent only over HTTPS or loopback HTTP, and redirects are not followed.

## version

Show the installed MindRoom version.

<!-- CODE:START -->
<!-- from mindroom.cli.main import app -->
<!-- from typer.testing import CliRunner -->
<!-- runner = CliRunner() -->
<!-- result = runner.invoke(app, ["version", "--help"]) -->
<!-- print("```") -->
<!-- print(result.output) -->
<!-- print("```") -->
<!-- CODE:END -->
<!-- OUTPUT:START -->
<!-- ⚠️ This content is auto-generated by `markdown-code-runner`. -->
```

 Usage: root version [OPTIONS]

 Show the current version of Mindroom.

╭─ Options ──────────────────────────────────────────────────────────────────────────────╮
│ --help  -h        Show this message and exit.                                          │
╰────────────────────────────────────────────────────────────────────────────────────────╯


```

<!-- OUTPUT:END -->

## local-stack-setup

Start a local Synapse homeserver and the MindRoom Chat client container from the core MindRoom repository's `local/matrix` development Compose files.
By default it writes `MATRIX_HOMESERVER` and `MATRIX_SERVER_NAME` to the `.env` next to your active `config.yaml`, so `mindroom run` works without extra exports; `--no-persist-env` skips this.
For an `https://` homeserver it also writes `MATRIX_SSL_VERIFY=false`, which turns off certificate checks for that homeserver only, so remove it before switching to hosted Matrix.

```bash
mindroom local-stack-setup --synapse-dir /path/to/mindroom/local/matrix
```

<!-- CODE:START -->
<!-- from mindroom.cli.main import app -->
<!-- from typer.testing import CliRunner -->
<!-- runner = CliRunner() -->
<!-- result = runner.invoke(app, ["local-stack-setup", "--help"]) -->
<!-- print("```") -->
<!-- print(result.output) -->
<!-- print("```") -->
<!-- CODE:END -->
<!-- OUTPUT:START -->
<!-- ⚠️ This content is auto-generated by `markdown-code-runner`. -->
```

 Usage: root local-stack-setup [OPTIONS]

 Start local Synapse + MindRoom Chat using Docker only.

╭─ Options ──────────────────────────────────────────────────────────────────────────────╮
│ --synapse-dir                                 PATH                 Directory           │
│                                                                    containing Synapse  │
│                                                                    docker-compose.yml  │
│                                                                    (core MindRoom      │
│                                                                    repo:               │
│                                                                    local/matrix).      │
│                                                                    [default:           │
│                                                                    local/matrix]       │
│ --homeserver-url                              TEXT                 Homeserver URL that │
│                                                                    MindRoom Chat and   │
│                                                                    MindRoom should     │
│                                                                    use.                │
│                                                                    [default:           │
│                                                                    http://localhost:8… │
│ --server-name                                 TEXT                 Matrix server name  │
│                                                                    (default: inferred  │
│                                                                    from                │
│                                                                    --homeserver-url    │
│                                                                    hostname).          │
│ --cinny-port                                  INTEGER RANGE        Local host port for │
│                                               [1<=x<=65535]        the MindRoom Chat   │
│                                                                    container.          │
│                                                                    [default: 8080]     │
│ --cinny-image                                 TEXT                 Docker image for    │
│                                                                    MindRoom Chat.      │
│                                                                    [default:           │
│                                                                    ghcr.io/mindroom-a… │
│ --cinny-container-n…                          TEXT                 Container name for  │
│                                                                    MindRoom Chat       │
│                                                                    (legacy default     │
│                                                                    retained for        │
│                                                                    compatibility).     │
│                                                                    [default:           │
│                                                                    mindroom-cinny-loc… │
│ --skip-synapse                                                     Skip starting       │
│                                                                    Synapse (assume it  │
│                                                                    is already          │
│                                                                    running).           │
│ --persist-env             --no-persist-env                         Persist Matrix      │
│                                                                    local dev settings  │
│                                                                    to .env next to     │
│                                                                    config.yaml.        │
│                                                                    [default:           │
│                                                                    persist-env]        │
│ --help                -h                                           Show this message   │
│                                                                    and exit.           │
╰────────────────────────────────────────────────────────────────────────────────────────╯


```

<!-- OUTPUT:END -->

## plugins

Install, update, and validate external plugins with `mindroom plugins install`, `mindroom plugins update`, and `mindroom plugins check`.
See [Installing plugins](https://docs.mindroom.chat/plugins/#installing-plugins) and [Compatibility checks](https://docs.mindroom.chat/plugins/#compatibility-checks).

## desktop

Connect explicitly allowlisted local applications, read-only folders, and locally approved shell commands to cloud MindRoom over Matrix end-to-end encryption.
See the [Matrix Desktop Bridge](https://docs.mindroom.chat/tools/desktop/) guide for setup, security, and every behavior these commands control.

<!-- CODE:START -->
<!-- from mindroom.cli.main import app -->
<!-- from typer.testing import CliRunner -->
<!-- runner = CliRunner() -->
<!-- result = runner.invoke(app, ["desktop", "--help"]) -->
<!-- print("```") -->
<!-- print(result.output) -->
<!-- print("```") -->
<!-- CODE:END -->
<!-- OUTPUT:START -->
<!-- ⚠️ This content is auto-generated by `markdown-code-runner`. -->
```

 Usage: root desktop [OPTIONS] COMMAND [ARGS]...

 Connect allowlisted local apps, read-only folders, and locally approved shell commands
 to cloud MindRoom over Matrix E2EE.

╭─ Options ──────────────────────────────────────────────────────────────────────────────╮
│ --help  -h        Show this message and exit.                                          │
╰────────────────────────────────────────────────────────────────────────────────────────╯
╭─ Commands ─────────────────────────────────────────────────────────────────────────────╮
│ app      Run the native app's private structured helper over inherited standard I/O.   │
│ login    Log in once, create an Olm device, and save its access token privately.       │
│ pair     Claim one requester-agent pairing through authenticated Matrix E2EE.          │
│ setup    Pair and save the connection shared with the macOS app.                       │
│ access   Save read-only folders and shell requests shared with the macOS app; omitted  │
│          options keep saved values.                                                    │
│ run      Run the bridge with the saved app, folder, and shell setup; flags override    │
│          this run only.                                                                │
╰────────────────────────────────────────────────────────────────────────────────────────╯


```

<!-- OUTPUT:END -->

### desktop setup

Log in when needed, claim the pairing from `!desktop setup`, and save the connection shared with the macOS app.
Use `--allow-app` to save app choices at the same time.

<!-- CODE:START -->
<!-- from mindroom.cli.main import app -->
<!-- from typer.testing import CliRunner -->
<!-- runner = CliRunner() -->
<!-- result = runner.invoke(app, ["desktop", "setup", "--help"]) -->
<!-- print("```") -->
<!-- print(result.output) -->
<!-- print("```") -->
<!-- CODE:END -->
<!-- OUTPUT:START -->
<!-- ⚠️ This content is auto-generated by `markdown-code-runner`. -->
```

 Usage: root desktop setup [OPTIONS]

 Pair and save the connection shared with the macOS app.

╭─ Options ──────────────────────────────────────────────────────────────────────────────╮
│ *  --code                              TEXT  Short-lived code returned by !desktop     │
│                                              setup.                                    │
│                                              [required]                                │
│ *  --controller-user-id                TEXT  Pinned cloud controller Matrix user.      │
│                                              [required]                                │
│ *  --controller-device-id              TEXT  Pinned cloud controller device.           │
│                                              [required]                                │
│ *  --controller-ed25519                TEXT  Pinned controller fingerprint. [required] │
│    --allow-agent                       TEXT  Agent name from the setup message;        │
│                                              prompts if omitted. Repeat as needed.     │
│    --allow-app                         TEXT  Save allowed app IDs, or choose apps      │
│                                              later in the macOS app.                   │
│    --user-id                           TEXT  Expected Matrix user ID; required for     │
│                                              password login and optional for SSO.      │
│    --homeserver                        TEXT  Matrix homeserver URL; defaults to the    │
│                                              configured MindRoom homeserver.           │
│    --cloudflare-access                       Authenticate Matrix requests              │
│                                              interactively with the local cloudflared  │
│                                              CLI.                                      │
│                                              [env var:                                 │
│                                              MINDROOM_DESKTOP_CLOUDFLARE_ACCESS]       │
│    --matrix-http-headers-file          PATH  Owner-only JSON file of HTTP headers      │
│                                              added to every Matrix request.            │
│                                              [env var:                                 │
│                                              MINDROOM_DESKTOP_MATRIX_HTTP_HEADERS_FIL… │
│    --config                    -c      PATH  MindRoom config path used for runtime     │
│                                              env.                                      │
│    --storage-path              -s      PATH  Desktop bridge state directory.           │
│    --help                      -h            Show this message and exit.               │
╰────────────────────────────────────────────────────────────────────────────────────────╯


```

<!-- OUTPUT:END -->

### desktop access

Save read-only folders and shell command requests in the setup shared with the macOS app; omitted options keep the saved values.
Shell commands run with your full account access; see [Shell Commands](https://docs.mindroom.chat/tools/desktop/#shell-commands) before enabling them.

<!-- CODE:START -->
<!-- from mindroom.cli.main import app -->
<!-- from typer.testing import CliRunner -->
<!-- runner = CliRunner() -->
<!-- result = runner.invoke(app, ["desktop", "access", "--help"]) -->
<!-- print("```") -->
<!-- print(result.output) -->
<!-- print("```") -->
<!-- CODE:END -->
<!-- OUTPUT:START -->
<!-- ⚠️ This content is auto-generated by `markdown-code-runner`. -->
```

 Usage: root desktop access [OPTIONS]

 Save read-only folders and shell requests shared with the macOS app; omitted options
 keep saved values.

╭─ Options ──────────────────────────────────────────────────────────────────────────────╮
│ --allow-folder                     PATH  Add a folder agents may list and read, never  │
│                                          write; repeat as needed.                      │
│ --clear-folders                          Remove every saved read-only folder.          │
│ --shell              --no-shell          Let agents request shell commands, each       │
│                                          approved on this computer while the bridge    │
│                                          runs.                                         │
│ --config         -c                PATH  MindRoom config path used for runtime env.    │
│ --storage-path   -s                PATH  Desktop bridge state directory.               │
│ --help           -h                      Show this message and exit.                   │
╰────────────────────────────────────────────────────────────────────────────────────────╯


```

<!-- OUTPUT:END -->

### desktop login

Create and privately save the dedicated local desktop Matrix device.

<!-- CODE:START -->
<!-- from mindroom.cli.main import app -->
<!-- from typer.testing import CliRunner -->
<!-- runner = CliRunner() -->
<!-- result = runner.invoke(app, ["desktop", "login", "--help"]) -->
<!-- print("```") -->
<!-- print(result.output) -->
<!-- print("```") -->
<!-- CODE:END -->
<!-- OUTPUT:START -->
<!-- ⚠️ This content is auto-generated by `markdown-code-runner`. -->
```

 Usage: root desktop login [OPTIONS]

 Log in once, create an Olm device, and save its access token privately.

╭─ Options ──────────────────────────────────────────────────────────────────────────────╮
│ --user-id                                     TEXT                 Expected Matrix     │
│                                                                    user ID; required   │
│                                                                    for password login  │
│                                                                    and optional for    │
│                                                                    SSO.                │
│ --homeserver                                  TEXT                 Matrix homeserver   │
│                                                                    URL; defaults to    │
│                                                                    the configured      │
│                                                                    MindRoom            │
│                                                                    homeserver.         │
│ --login-method                                [auto|password|sso]  Matrix login        │
│                                                                    method. Auto uses   │
│                                                                    browser SSO when    │
│                                                                    advertised,         │
│                                                                    otherwise password. │
│                                                                    [default: auto]     │
│ --sso-idp                                     TEXT                 Matrix SSO          │
│                                                                    identity-provider   │
│                                                                    ID. Selects SSO     │
│                                                                    when login method   │
│                                                                    is auto.            │
│ --open-browser           --no-open-browser                         Open Matrix SSO in  │
│                                                                    the default         │
│                                                                    browser; otherwise  │
│                                                                    print the URL.      │
│                                                                    [default:           │
│                                                                    open-browser]       │
│ --cloudflare-access                                                Authenticate Matrix │
│                                                                    requests            │
│                                                                    interactively with  │
│                                                                    the local           │
│                                                                    cloudflared CLI.    │
│                                                                    [env var:           │
│                                                                    MINDROOM_DESKTOP_C… │
│ --replace                                                          Replace the saved   │
│                                                                    session with a      │
│                                                                    fresh Matrix        │
│                                                                    device.             │
│ --matrix-http-head…                           PATH                 Owner-only JSON     │
│                                                                    file of HTTP        │
│                                                                    headers added to    │
│                                                                    every Matrix        │
│                                                                    request.            │
│                                                                    [env var:           │
│                                                                    MINDROOM_DESKTOP_M… │
│ --config             -c                       PATH                 MindRoom config     │
│                                                                    path used for       │
│                                                                    runtime env.        │
│ --storage-path       -s                       PATH                 Desktop bridge      │
│                                                                    state directory.    │
│ --help               -h                                            Show this message   │
│                                                                    and exit.           │
╰────────────────────────────────────────────────────────────────────────────────────────╯


```

<!-- OUTPUT:END -->

### desktop run

Run the local bridge with the setup saved by the terminal or macOS app; flags override settings for this run only.
Applications stay observe-only unless `--allow-control` grants a short lease, and with shell requests enabled, each command waits for an answer at this terminal unless `--shell-auto-approve-minutes` is set.

<!-- CODE:START -->
<!-- from mindroom.cli.main import app -->
<!-- from typer import rich_utils -->
<!-- from typer.testing import CliRunner -->
<!-- runner = CliRunner() -->
<!-- previous_max_width = rich_utils.MAX_WIDTH -->
<!-- rich_utils.MAX_WIDTH = 96 -->
<!-- result = runner.invoke(app, ["desktop", "run", "--help"]) -->
<!-- rich_utils.MAX_WIDTH = previous_max_width -->
<!-- print("```") -->
<!-- print(result.output) -->
<!-- print("```") -->
<!-- CODE:END -->
<!-- OUTPUT:START -->
<!-- ⚠️ This content is auto-generated by `markdown-code-runner`. -->
```

 Usage: root desktop run [OPTIONS]

 Run the bridge with the saved app, folder, and shell setup; flags override this run only.

╭─ Options ────────────────────────────────────────────────────────────────────────────────────╮
│ --controller-user-id                              TEXT                  Pinned cloud         │
│                                                                         controller Matrix    │
│                                                                         user.                │
│ --controller-device-…                             TEXT                  Pinned cloud         │
│                                                                         controller device.   │
│ --controller-ed25519                              TEXT                  Pinned controller    │
│                                                                         fingerprint.         │
│ --allow-requester                                 TEXT                  Human Matrix         │
│                                                                         requester allowed to │
│                                                                         operate this         │
│                                                                         desktop; repeat as   │
│                                                                         needed.              │
│ --allow-agent                                     TEXT                  MindRoom agent name  │
│                                                                         allowed to operate   │
│                                                                         this desktop; repeat │
│                                                                         as needed.           │
│ --allow-app                                       TEXT                  Exact local          │
│                                                                         application ID       │
│                                                                         exposed to the       │
│                                                                         agent; repeat as     │
│                                                                         needed.              │
│ --allow-control                                                         Enable semantic and  │
│                                                                         fallback app input   │
│                                                                         for a short local    │
│                                                                         lease. Default: apps │
│                                                                         are observe-only.    │
│ --lease-minutes                                   INTEGER RANGE         Local control lease  │
│                                                   [1<=x<=60]            duration.            │
│                                                                         [default: 15]        │
│ --shell-auto-approve…                             INTEGER RANGE         Approve shell        │
│                                                   [1<=x<=60]            commands from every  │
│                                                                         allowed requester    │
│                                                                         and agent            │
│                                                                         automatically for    │
│                                                                         this many minutes of │
│                                                                         this run; never      │
│                                                                         saved.               │
│ --max-screenshot-wid…                             INTEGER RANGE                              │
│                                                   [320<=x<=3840]                             │
│ --jpeg-quality                                    INTEGER RANGE                              │
│                                                   [40<=x<=95]                                │
│ --browser-extension        --no-browser-exten…                          Expose Playwright    │
│                                                                         MCP control of an    │
│                                                                         existing browser     │
│                                                                         profile when its     │
│                                                                         extension is         │
│                                                                         installed.           │
│ --browser-executable                              PATH                  Chrome-family        │
│                                                                         executable to open   │
│                                                                         the Playwright       │
│                                                                         extension connection │
│                                                                         page, including      │
│                                                                         Brave.               │
│ --browser-user-data-…                             PATH                  Existing browser     │
│                                                                         user-data root       │
│                                                                         containing the       │
│                                                                         profile where the    │
│                                                                         extension is         │
│                                                                         installed.           │
│ --browser-timeout-se…                             INTEGER RANGE         Local Playwright MCP │
│                                                   [1<=x<=120]           call timeout.        │
│ --log-level            -l                         TEXT                  [default: INFO]      │
│ --cloudflare-access                                                     Authenticate Matrix  │
│                                                                         requests             │
│                                                                         interactively with   │
│                                                                         the local            │
│                                                                         cloudflared CLI.     │
│                                                                         [env var:            │
│                                                                         MINDROOM_DESKTOP_CL… │
│ --matrix-http-header…                             PATH                  Owner-only JSON file │
│                                                                         of HTTP headers      │
│                                                                         added to every       │
│                                                                         Matrix request.      │
│                                                                         [env var:            │
│                                                                         MINDROOM_DESKTOP_MA… │
│ --config               -c                         PATH                  MindRoom config path │
│                                                                         used for runtime     │
│                                                                         env.                 │
│ --storage-path         -s                         PATH                  Desktop bridge state │
│                                                                         directory.           │
│ --help                 -h                                               Show this message    │
│                                                                         and exit.            │
╰──────────────────────────────────────────────────────────────────────────────────────────────╯


```

<!-- OUTPUT:END -->

## avatars

Generate and sync managed avatars; see [Managed Avatars](https://docs.mindroom.chat/matrix/#managed-avatars) for defaults, file locations, and overrides.

<!-- CODE:START -->
<!-- from mindroom.cli.main import app -->
<!-- from typer.testing import CliRunner -->
<!-- runner = CliRunner() -->
<!-- result = runner.invoke(app, ["avatars", "--help"]) -->
<!-- print("```") -->
<!-- print(result.output) -->
<!-- print("```") -->
<!-- CODE:END -->
<!-- OUTPUT:START -->
<!-- ⚠️ This content is auto-generated by `markdown-code-runner`. -->
```

 Usage: root avatars [OPTIONS] COMMAND [ARGS]...

 Generate and sync managed avatar assets.

╭─ Options ──────────────────────────────────────────────────────────────────────────────╮
│ --help  -h        Show this message and exit.                                          │
╰────────────────────────────────────────────────────────────────────────────────────────╯
╭─ Commands ─────────────────────────────────────────────────────────────────────────────╮
│ generate   Generate missing managed avatar files in the workspace.                     │
│ sync       Sync configured room and root-space avatars to Matrix using the initialized │
│            router account.                                                             │
╰────────────────────────────────────────────────────────────────────────────────────────╯


```

<!-- OUTPUT:END -->

### avatars generate

Generate avatar files for entities that have none, using OpenAI with `OPENAI_API_KEY` or `OPENAI_API_KEY_FILE`.
Use `--force` to overwrite existing files after changing avatar prompts or styles.

<!-- CODE:START -->
<!-- from mindroom.cli.main import app -->
<!-- from typer.testing import CliRunner -->
<!-- runner = CliRunner() -->
<!-- result = runner.invoke(app, ["avatars", "generate", "--help"]) -->
<!-- print("```") -->
<!-- print(result.output) -->
<!-- print("```") -->
<!-- CODE:END -->
<!-- OUTPUT:START -->
<!-- ⚠️ This content is auto-generated by `markdown-code-runner`. -->
```

 Usage: root avatars generate [OPTIONS]

 Generate missing managed avatar files in the workspace.

╭─ Options ──────────────────────────────────────────────────────────────────────────────╮
│ --force            Overwrite existing managed workspace avatar files.                  │
│ --help   -h        Show this message and exit.                                         │
╰────────────────────────────────────────────────────────────────────────────────────────╯


```

<!-- OUTPUT:END -->

### avatars sync

Apply configured room and root-Space avatars to Matrix using the router account.
Existing Matrix avatars are kept unless you pass `--force`.

<!-- CODE:START -->
<!-- from mindroom.cli.main import app -->
<!-- from typer.testing import CliRunner -->
<!-- runner = CliRunner() -->
<!-- result = runner.invoke(app, ["avatars", "sync", "--help"]) -->
<!-- print("```") -->
<!-- print(result.output) -->
<!-- print("```") -->
<!-- CODE:END -->
<!-- OUTPUT:START -->
<!-- ⚠️ This content is auto-generated by `markdown-code-runner`. -->
```

 Usage: root avatars sync [OPTIONS]

 Sync configured room and root-space avatars to Matrix using the initialized router
 account.

╭─ Options ──────────────────────────────────────────────────────────────────────────────╮
│ --force            Replace existing Matrix room and root-space avatars.                │
│ --help   -h        Show this message and exit.                                         │
╰────────────────────────────────────────────────────────────────────────────────────────╯


```

<!-- OUTPUT:END -->

## threads

Export Matrix threads to local YAML files.

<!-- CODE:START -->
<!-- from mindroom.cli.main import app -->
<!-- from typer.testing import CliRunner -->
<!-- runner = CliRunner() -->
<!-- result = runner.invoke(app, ["threads", "--help"]) -->
<!-- print("```") -->
<!-- print(result.output) -->
<!-- print("```") -->
<!-- CODE:END -->
<!-- OUTPUT:START -->
<!-- ⚠️ This content is auto-generated by `markdown-code-runner`. -->
```

 Usage: root threads [OPTIONS] COMMAND [ARGS]...

 Export Matrix threads to local files.

╭─ Options ──────────────────────────────────────────────────────────────────────────────╮
│ --help  -h        Show this message and exit.                                          │
╰────────────────────────────────────────────────────────────────────────────────────────╯
╭─ Commands ─────────────────────────────────────────────────────────────────────────────╮
│ export   Export Matrix threads through a running MindRoom instance to searchable YAML  │
│          files.                                                                        │
╰────────────────────────────────────────────────────────────────────────────────────────╯


```

<!-- OUTPUT:END -->

### threads export

See [threads export](https://docs.mindroom.chat/thread-exports/#threads-export) for requirements and output.

<!-- CODE:START -->
<!-- from mindroom.cli.main import app -->
<!-- from typer.testing import CliRunner -->
<!-- runner = CliRunner() -->
<!-- result = runner.invoke(app, ["threads", "export", "--help"]) -->
<!-- print("```") -->
<!-- print(result.output) -->
<!-- print("```") -->
<!-- CODE:END -->
<!-- OUTPUT:START -->
<!-- ⚠️ This content is auto-generated by `markdown-code-runner`. -->
```

 Usage: root threads export [OPTIONS]

 Export Matrix threads through a running MindRoom instance to searchable YAML files.

╭─ Options ──────────────────────────────────────────────────────────────────────────────╮
│ --url                                         TEXT     Running MindRoom URL; defaults  │
│                                                        to MINDROOM_URL or              │
│                                                        localhost:8765.                 │
│ --config            -c                        PATH     Use this config file path.      │
│ --storage-path      -s                        PATH     Base directory for persistent   │
│                                                        MindRoom data.                  │
│ --output            -o                        PATH     Output directory. Defaults to   │
│                                                        <storage>/thread_exports.       │
│ --room              -r                        TEXT     Filter exported rooms by a      │
│                                                        substring of the room key,      │
│                                                        alias, name, or Matrix room ID. │
│ --watch                                                Repeat the export forever on a  │
│                                                        fixed interval.                 │
│ --interval                                    INTEGER  Watch interval in seconds.      │
│                                                        [default: 300]                  │
│ --max-thread-roots                            INTEGER  Maximum thread roots to         │
│                                                        enumerate per room.             │
│                                                        [default: 2000]                 │
│ --invited-rooms         --no-invited-rooms             Include rooms joined through    │
│                                                        authorized invites              │
│                                                        (user-created rooms).           │
│                                                        [default: invited-rooms]        │
│ --help              -h                                 Show this message and exit.     │
╰────────────────────────────────────────────────────────────────────────────────────────╯


```

<!-- OUTPUT:END -->

```bash
mindroom threads export --storage-path mindroom_data --output "$HOME/mindroom-thread-exports"
mindroom threads export --storage-path mindroom_data --room lobby
mindroom threads export --storage-path mindroom_data --watch --interval 300
mindroom threads export --url http://127.0.0.1:9000 --storage-path mindroom_data
```

## journal

Inspect and rebind the event journal; see [Journal binding](https://docs.mindroom.chat/deployment/storage/#journal).

<!-- CODE:START -->
<!-- from mindroom.cli.main import app -->
<!-- from typer.testing import CliRunner -->
<!-- runner = CliRunner() -->
<!-- result = runner.invoke(app, ["journal", "--help"]) -->
<!-- print("```") -->
<!-- print(result.output) -->
<!-- print("```") -->
<!-- CODE:END -->
<!-- OUTPUT:START -->
<!-- ⚠️ This content is auto-generated by `markdown-code-runner`. -->
```

 Usage: root journal [OPTIONS] COMMAND [ARGS]...

 Inspect and rebind the durable event journal.

╭─ Options ──────────────────────────────────────────────────────────────────────────────╮
│ --help  -h        Show this message and exit.                                          │
╰────────────────────────────────────────────────────────────────────────────────────────╯
╭─ Commands ─────────────────────────────────────────────────────────────────────────────╮
│ adopt   Bind this install to the configured event-journal database.                    │
╰────────────────────────────────────────────────────────────────────────────────────────╯


```

<!-- OUTPUT:END -->

### journal adopt

See [journal adopt](https://docs.mindroom.chat/deployment/storage/#journal-adopt) before running it.

<!-- CODE:START -->
<!-- from mindroom.cli.main import app -->
<!-- from typer.testing import CliRunner -->
<!-- runner = CliRunner() -->
<!-- result = runner.invoke(app, ["journal", "adopt", "--help"]) -->
<!-- print("```") -->
<!-- print(result.output) -->
<!-- print("```") -->
<!-- CODE:END -->
<!-- OUTPUT:START -->
<!-- ⚠️ This content is auto-generated by `markdown-code-runner`. -->
```

 Usage: root journal adopt [OPTIONS]

 Bind this install to the configured event-journal database.

 MindRoom refuses to start against a journal it is not bound to, because
 using a different one loses turn deduplication, delivery ownership, and
 recovery ownership without any error. This is how you say the change was
 deliberate.

╭─ Options ──────────────────────────────────────────────────────────────────────────────╮
│ --config        -c      PATH  Use this config file path.                               │
│ --storage-path  -s      PATH  Base directory for persistent MindRoom data.             │
│ --yes           -y            Adopt without confirming, even when another journal is   │
│                               already bound.                                           │
│ --force                       Adopt even though another process still has this         │
│                               install's journal open.                                  │
│ --help          -h            Show this message and exit.                              │
╰────────────────────────────────────────────────────────────────────────────────────────╯


```

<!-- OUTPUT:END -->

```bash
mindroom journal adopt --storage-path mindroom_data
mindroom journal adopt --storage-path mindroom_data --yes
```

## debug-report

Collect everything the backend stored about one conversation into a single JSON document, for example to debug a chat bug report.
The input is the JSON file a MindRoom Chat **Report a bug** message carries, or plain identifiers.
See [Bug Reports](https://docs.mindroom.chat/bug-reports/) for how users send those reports and how administrators receive them.

The report includes the event journal's turn records, admitted events, and outbound deliveries, the conversation's Agno runs of agents, teams, private instances, and system usage, `tracking/tool_calls.jsonl` and its rotations, the LLM request logs, and the `mindroom_*.log` files.
Each source lists the files it read under `paths` and has a `status` of `ok`, `missing` (nothing to read there), or `error` with the message under `error`; a source that cannot be read, such as a locked or corrupt database, does not stop the others.
Successful tool calls and LLM requests are only logged when `debug.log_llm_requests` is enabled.
Nothing is redacted, and the newest matches are kept: up to 100 Agno runs, 1,000 records per JSONL source, and 2,000 log lines of 4,000 characters each, with `dropped` and `truncated` counting what was left out or shortened.

Run it on the MindRoom host with the same `--config` and `--storage-path` the service uses, and hand the bug report and the output to whoever debugs the issue.
When `debug.llm_request_log_dir` is relative, run the command from the service's working directory.
The command never writes the config and reads only its `event_journal` and `debug.llm_request_log_dir` settings, so a config the runtime would reject still locates the data; a PostgreSQL journal URL comes from `event_journal.database_url`, the config directory's `.env`, or the environment, and an unresolved URL reports the journal sources as errors.
`--event` alone does not reach Agno runs or outbound deliveries; pass `--room` and `--thread`, or a bug report file.

<!-- CODE:START -->
<!-- from mindroom.cli.main import app -->
<!-- from typer.testing import CliRunner -->
<!-- runner = CliRunner() -->
<!-- result = runner.invoke(app, ["debug-report", "--help"]) -->
<!-- print("```") -->
<!-- print(result.output) -->
<!-- print("```") -->
<!-- CODE:END -->
<!-- OUTPUT:START -->
<!-- ⚠️ This content is auto-generated by `markdown-code-runner`. -->
```

 Usage: root debug-report [OPTIONS] [REPORT]

 Collect everything the backend stored about a reported conversation, as JSON.

╭─ Arguments ────────────────────────────────────────────────────────────────────────────╮
│   report      [REPORT]  MindRoom Chat bug report JSON, as attached to a Report a bug   │
│                         message.                                                       │
╰────────────────────────────────────────────────────────────────────────────────────────╯
╭─ Options ──────────────────────────────────────────────────────────────────────────────╮
│ --event         -e      TEXT  Matrix event ID; repeatable. Conversation-wide sources   │
│                               (Agno sessions, deliveries) need --room/--thread, or a   │
│                               report file.                                             │
│ --room          -r      TEXT  Matrix room ID.                                          │
│ --thread        -t      TEXT  Thread root event ID.                                    │
│ --config        -c      PATH  Use this config file path.                               │
│ --storage-path  -s      PATH  Base directory for persistent MindRoom data.             │
│ --output        -o      PATH  Write the JSON here instead of stdout.                   │
│ --help          -h            Show this message and exit.                              │
╰────────────────────────────────────────────────────────────────────────────────────────╯


```

<!-- OUTPUT:END -->

```bash
mindroom debug-report bug-report.json --output backend-report.json
mindroom debug-report --room '!room:example.com' --thread '$thread_root' --config ~/.mindroom/config.yaml
```

## trigger

Send signed external trigger requests from cron jobs and watcher scripts; see [External Triggers](https://docs.mindroom.chat/external-triggers/#signed-trigger-setup-flow).

<!-- CODE:START -->
<!-- from mindroom.cli.main import app -->
<!-- from typer.testing import CliRunner -->
<!-- runner = CliRunner() -->
<!-- result = runner.invoke(app, ["trigger", "--help"]) -->
<!-- print("```") -->
<!-- print(result.output) -->
<!-- print("```") -->
<!-- CODE:END -->
<!-- OUTPUT:START -->
<!-- ⚠️ This content is auto-generated by `markdown-code-runner`. -->
```

 Usage: root trigger [OPTIONS] COMMAND [ARGS]...

 Send signed external triggers.

╭─ Options ──────────────────────────────────────────────────────────────────────────────╮
│ --help  -h        Show this message and exit.                                          │
╰────────────────────────────────────────────────────────────────────────────────────────╯
╭─ Commands ─────────────────────────────────────────────────────────────────────────────╮
│ keygen   Generate an Ed25519 trigger signing key.                                      │
│ send     Send a signed external trigger request.                                       │
╰────────────────────────────────────────────────────────────────────────────────────────╯


```

<!-- OUTPUT:END -->

### trigger keygen

Generate an Ed25519 signing key pair for an external trigger.

<!-- CODE:START -->
<!-- from mindroom.cli.main import app -->
<!-- from typer.testing import CliRunner -->
<!-- runner = CliRunner() -->
<!-- result = runner.invoke(app, ["trigger", "keygen", "--help"]) -->
<!-- print("```") -->
<!-- print(result.output) -->
<!-- print("```") -->
<!-- CODE:END -->
<!-- OUTPUT:START -->
<!-- ⚠️ This content is auto-generated by `markdown-code-runner`. -->
```

 Usage: root trigger keygen [OPTIONS]

 Generate an Ed25519 trigger signing key.

╭─ Options ──────────────────────────────────────────────────────────────────────────────╮
│ --private-key-file          PATH  Path where the base64 raw Ed25519 private key should │
│                                   be written.                                          │
│ --help              -h            Show this message and exit.                          │
╰────────────────────────────────────────────────────────────────────────────────────────╯


```

<!-- OUTPUT:END -->

### trigger send

Send one signed trigger request to MindRoom.

<!-- CODE:START -->
<!-- from mindroom.cli.main import app -->
<!-- from typer.testing import CliRunner -->
<!-- runner = CliRunner() -->
<!-- result = runner.invoke(app, ["trigger", "send", "--help"]) -->
<!-- print("```") -->
<!-- print(result.output) -->
<!-- print("```") -->
<!-- CODE:END -->
<!-- OUTPUT:START -->
<!-- ⚠️ This content is auto-generated by `markdown-code-runner`. -->
```

 Usage: root trigger send [OPTIONS] TRIGGER_ID

 Send a signed external trigger request.

╭─ Arguments ────────────────────────────────────────────────────────────────────────────╮
│ *    trigger_id      TEXT  Configured external trigger id. [required]                  │
╰────────────────────────────────────────────────────────────────────────────────────────╯
╭─ Options ──────────────────────────────────────────────────────────────────────────────╮
│ *  --key-file                           FILE   Base64 raw Ed25519 private key file.    │
│                                                [required]                              │
│ *  --kind                               TEXT   Trigger payload kind. [required]        │
│ *  --message                            TEXT   Trigger payload message. [required]     │
│    --event-id                           TEXT   Optional idempotency event id.          │
│    --title                              TEXT   Optional trigger title.                 │
│    --thread-key                         TEXT   Optional key; deliveries sharing it     │
│                                                land in one Matrix thread on new_thread │
│                                                triggers.                               │
│    --data-json                          TEXT   Optional JSON object for trigger data.  │
│    --timeout                            FLOAT  HTTP request timeout in seconds.        │
│                                                [default: 10.0]                         │
│    --verify-tls      --no-verify-tls           Verify TLS certificates.                │
│                                                [default: verify-tls]                   │
│    --url                                TEXT   MindRoom base URL.                      │
│                                                [env var: MINDROOM_URL]                 │
│                                                [default: http://127.0.0.1:8765]        │
│    --key-id                             TEXT   Trigger signing key id.                 │
│                                                [default: default]                      │
│    --help        -h                            Show this message and exit.             │
╰────────────────────────────────────────────────────────────────────────────────────────╯


```

<!-- OUTPUT:END -->

# Support

Use this page to get help with MindRoom and MindRoom clients, including the iOS app, and to look up how hosted subscriptions and account deletion work.

## Contact

- General support and bug reports: [MindRoom GitHub issues](https://github.com/mindroom-ai/mindroom/issues)

Do not post access tokens, private room IDs, personal data, or safety-report evidence in a public issue.

## What to Include in a Support Request

- What you were trying to do
- What happened instead
- Any error message shown
- Screenshots or screen recordings (if available)
- Device model and OS version (for mobile and desktop app issues)
- App version / build number
- Homeserver URL (if relevant)

## Common Issues

### Login / Registration Problems

- Confirm the homeserver URL is correct
- Confirm the homeserver is reachable over HTTPS (for public servers)
- If using SSO, confirm the homeserver advertises the expected identity provider

### Media (Images / Audio) Not Loading

- Check connectivity to the homeserver
- Confirm your account still has access to the room/content

### Account Deactivation

- Use the in-app path: `Settings` -> `Account` -> `Delete / Deactivate Account`
- To delete a hosted MindRoom platform account, see [Account Deletion](#account-deletion).

## Hosted Subscriptions

A hosted instance runs only while its subscription entitles it, and its data is deleted when the subscription stays inactive past the grace period.

| Subscription state | Instance | Platform OpenRouter key | Data |
|--------------------|----------|-------------------------|------|
| `active`, unexpired `trialing`, or `past_due` while Stripe retries a payment | Keeps running | Enabled | Kept |
| `cancelled`, `unpaid`, `incomplete`, `incomplete_expired`, `paused`, expired trial, free tier, or account pending deletion | Stopped | Disabled | Kept until the teardown date |
| Still inactive after the grace period | Uninstalled and marked `deprovisioned` | Deleted | Volumes and instance Secrets deleted |
| Entitled again while stopped | Started, and re-provisioned when the tier changed | Re-enabled with the tier's limit | Kept |
| Entitled again after teardown | Re-provisioned as a fresh instance | New key | Starts empty |
| Entitled on a different tier while running | Re-provisioned with the tier's resources | Same key with the tier's limit | Kept |
| Entitled on a cheaper tier while stopped by the customer | Stays stopped, and is redeployed for its tier once the customer starts it | Limit lowered to the tier's budget | Kept |

A `past_due` subscription keeps running only if it has had a successful payment; one that never paid is treated as `unpaid` and stops.
Customers whose instance is stopped for an inactive subscription see a dashboard banner with the teardown date and a link to billing.
While the subscription stays active, an instance a customer or admin stopped manually is never restarted automatically.
If the subscription becomes inactive, MindRoom holds that instance like any other, and restoring billing starts it again.

To cancel, select `Manage Subscription` on the dashboard's `Billing` page to open the Stripe customer portal, or call `POST /my/subscription/cancel`.
The API cancels at the end of the current billing period by default, so the instance keeps running until then; send `{"cancel_at_period_end": false}` to cancel immediately.
Until the period ends, `Reactivate subscription` on the `Billing` page opens the same portal to keep the subscription.

The grace period defaults to 30 days, is at least 1 day, and is set with `cleanupScheduler.teardownGraceDays` (`INSTANCE_TEARDOWN_GRACE_DAYS`).
The platform operator enables the nightly job that applies this lifecycle, as described in [Subscription Lifecycle](https://docs.mindroom.chat/deployment/saas-platform/#subscription-lifecycle).

Each tier's AI budget is its `included_ai_budget_usd` in `pricing-config.yaml`, and a tier without one gets a zero-dollar limit.
A plan change or resubscription on another tier always gets that tier's budget and resources, never those of a pricier earlier tier.
Plan changes keep the instance's existing OpenRouter key and its accumulated usage.
Downgrading never shrinks an instance's storage volumes, so a downgrade from `pro` keeps its larger volumes.
Each Stripe customer gets a plan's trial only once, so cancelling and checking out again starts a paid subscription.

## Account Deletion

Request deletion of a hosted account from the platform dashboard under `Settings` -> `Delete Account`, or with `POST /my/gdpr/request-deletion` and the JSON body `{"confirmation": true}`.
Without that confirmation, the API only returns a warning and schedules nothing.
The request does the following:

- Every renewing subscription is set to end at the end of its current billing period, and a subscription already set to end within its current period keeps that end.
- Subscriptions with no paid period to finish (`incomplete`, `paused`) are cancelled.
- The account's instances stop at once with their platform OpenRouter keys disabled, and the response says when stopping failed and will be retried automatically.

If Stripe cannot schedule the end of billing, the request fails with `502` and the account is not deleted, so try again.

While the account is pending deletion, its instances stay stopped even when a subscription is still paid.
Provisioning or starting an instance fails with `409` `This account is pending deletion and cannot run instances`.
Opening a checkout or the billing portal, or cancelling or reactivating a subscription, fails with `409` `Cancel the account deletion before changing billing`.

To cancel the deletion within 7 days, sign in and select `Cancel Deletion Request` in `Settings`, or call `POST /my/gdpr/cancel-deletion`.
Signing in alone does not cancel the deletion.
Cancelling restores the account and lets the subscriptions the deletion set to end renew again with the end date the customer had chosen before.
Instances restart once a subscription is entitled, and a subscription whose period ended meanwhile needs a new checkout.
Each instance stopped by the deletion or an inactive subscription gets its full teardown grace period again.
After 7 days, cancelling fails with `409` `This account deletion can no longer be cancelled`.
Cancelling a deletion never lifts a suspension.

An account's instances are not torn down while its deletion can still be cancelled, even when `cleanupScheduler.teardownGraceDays` is shorter than 7 days.
After the 7 days, the nightly cleanup, when the operator enables it, deletes the instances and account data; see [Account Deletion](https://docs.mindroom.chat/deployment/saas-platform/#account-deletion) for what it deletes and retains.

## Abuse / Safety Reports

Use in-app report/block tools first when available.

Do not put moderation evidence or private identifiers in a public GitHub issue.
If the in-app controls fail, open a general issue without sensitive evidence so maintainers can publish an appropriate private contact path.

Useful public context includes:

- the affected feature and client version
- a short general description with identifiers removed
- sanitized error text with tokens, Matrix IDs, room IDs, and message links removed

## Response Times

Support is provided on a best-effort basis.

# Privacy Policy

Last updated: 2026-08-14

This Privacy Policy explains how MindRoom and related MindRoom clients/services handle information.

## Scope

This policy applies to:

- MindRoom software running in local, self-hosted, or hosted installations
- MindRoom services and documentation operated by MindRoom
- MindRoom client applications (including the iOS app)

## Who Can Access Your Information

This policy distinguishes between the MindRoom software, the operator of a MindRoom installation, the MindRoom project maintainers, and external service providers.

- **MindRoom software** means the open-source software running in a local, self-hosted, or hosted installation.
- **Installation operator** means the person or organization that controls that installation, its machine or server, and its storage.
- **MindRoom project maintainers** means the people who develop and publish the open-source MindRoom project.
- **External service providers** include the Matrix homeserver, AI model provider, Google, and any other service that you or the installation operator configure.

For a local or self-hosted installation, the MindRoom project maintainers do not automatically receive or have access to its OAuth tokens, Google data, prompts, agent responses, local sessions, or locally stored files merely because the software is installed or used.

The installation operator and anyone with administrative or filesystem access to the machine or storage may be able to access locally stored credentials and data.
Application-level access is separately controlled by authenticated requester identity, agent authorization, room membership, and the configured credential scope.
Being signed in to the computer does not by itself grant access to MindRoom data, and an authorized Matrix user may interact with a locally running MindRoom installation without being signed in to that computer.

Project maintainers do not gain access to local installation data merely by being maintainers.
When the same people operate a MindRoom-hosted service that you choose, they can process the data available to that hosted component in their separate role as its service operator.
Project maintainers may also receive specific information that you deliberately send in a support request.
For example, a Matrix homeserver operator can access plaintext message content in unencrypted rooms, while end-to-end encrypted rooms protect message bodies from the homeserver but not message metadata.

## Information Needed to Operate the App

To provide messaging features, MindRoom and your selected homeserver process data that is required for delivery, sync, and account functionality:

- account identifiers (for example, Matrix user IDs)
- messages and files you choose to send through your configured homeserver
- room metadata (room names, avatars, membership)
- app configuration and local preferences stored on your device
- diagnostic information you choose to share with support

If you use MindRoom's hosted control plane, it also processes account profile and status data, subscription and payment records, hosted instance records, usage metrics, audit events, and marketing or analytics consent choices.
These records support service operation, billing, security, fraud prevention, compliance, and the preferences you select.
Hosted account and instance data is stored with Supabase, and payment processing is handled by Stripe.

## Matrix and Homeservers

MindRoom is built on Matrix.

- Message content, media, and account data are primarily stored and processed by the Matrix homeserver you choose so chats can work.
- If you use a third-party or self-hosted homeserver, that server's privacy policy also applies.
- This is protocol-level message delivery/storage behavior, not hidden background monitoring.

## Open Source Transparency

MindRoom is open source.

- Source code is publicly available at `https://github.com/mindroom-ai`.
- The app and supporting services can be independently audited by anyone.

## iOS Permissions

The iOS app may request access to:

- **Microphone**: to record voice messages
- **Camera**: to capture photos/videos for chat attachments
- **Photo Library**: to select media to send
- **Photo Library Add Access**: to save media to your device
- **Local Network**: to connect to a local/self-hosted Matrix homeserver on your network

These permissions are only used to provide the corresponding app features.

## How We Use Information

We use information to:

- provide messaging and collaboration features
- deliver media upload/download and rendering features
- support account, authentication, and SSO flows
- respond to support requests
- improve reliability and fix bugs

## Sharing

We do not sell your personal information.

Information may be shared only as needed to:

- operate the service features you request (for example, with your configured Matrix homeserver)
- comply with legal obligations
- investigate abuse or security incidents

## Google API Services

The MindRoom software running in your selected installation accesses Google user data only after you connect a Google integration and grant the requested permissions.
Granting that access does not give the MindRoom project maintainers general access to your Google Account or automatically send them your OAuth tokens or Google data from a local or self-hosted installation.
When a paired local installation uses MindRoom's desktop OAuth client, the provisioning service sends the app client configuration to that installation and Google returns the authorization response directly to its loopback callback.
The local MindRoom process performs the token exchange with Google and stores the resulting tokens; the provisioning service does not receive the Google authorization code, tokens, or Google API data.
Control of the OAuth app registration lets the project maintainers manage or disable the client, but it does not by itself reveal a user's OAuth tokens or Google data to them.

Depending on the integrations you connect, this data can include your Google identity information, Gmail messages and metadata, Drive file metadata and contents, Docs document contents, Calendar data, Sheets spreadsheet values, and Tasks task lists and tasks.

The MindRoom software uses this data only to provide the user-facing agent features that you request or configure, such as searching email, reading a Drive file, editing a document, managing a calendar event, reading and updating a spreadsheet, or creating and completing a task.

Google connections follow the selected agent's saved effective execution scope.
MindRoom uses `private.per` first, then `agents.<name>.worker_scope`, then `defaults.worker_scope`, otherwise no scope:

- With effective scope `user`, the connection is isolated to the authenticated Matrix requester and can be used by that requester's user-scoped agents.
- With effective scope `user_agent`, the connection is isolated to the authenticated Matrix requester and the selected agent.
- With effective scope `shared`, the connection belongs to the selected agent, so any user authorized to invoke that agent can cause it to access the connected Google Account and may receive Google data in the agent's response.
- With no private, per-agent, or inherited scope, the connection is stored at the installation level and is not isolated by requester.

Relevant Google data is sent to the AI model provider that you configure for inference so the agent can complete your request.

The MindRoom project does not use Google user data to train or improve generalized, foundational, or frontier AI models.

OAuth tokens are stored in the selected installation's MindRoom credential store, and Google data returned by a tool may be retained in its session storage and Matrix conversation history.

Your configured Matrix homeserver and AI model provider process data only as needed to provide the features you request and under their respective terms and privacy policies.

The MindRoom project does not sell Google user data, use it for advertising, use it to determine creditworthiness, or transfer it except as needed to provide the features you request, comply with law, or protect security.

You can stop future Google API access by disconnecting the integration in MindRoom or revoking MindRoom from your Google Account permissions, and you can delete locally retained data by deleting the relevant MindRoom sessions, Matrix messages, or local storage.

The MindRoom software's use and transfer of information received from Google APIs adheres to the [Google API Services User Data Policy](https://developers.google.com/terms/api-services-user-data-policy), including the Limited Use requirements.

## Data Retention

Retention depends on the system component:

- data stored on Matrix homeservers is retained according to the homeserver operator's policies
- local app data remains on your device until you remove it or delete the app
- runtime sessions, credentials, workspaces, files, and persistent volumes are retained until the installation operator removes them or applies its own retention policy, except where component-specific cleanup applies
- attachment metadata and eligible managed `incoming_media/` files older than 30 days are pruned opportunistically during new attachment registration
- the hosted control plane schedules hard deletion of soft-deleted application accounts after a 7-day grace period
- hosted non-critical audit logs are scheduled for deletion after 90 days and usage metrics after 365 days; selected security and deletion audit events are excluded from that ordinary cleanup
- support emails and diagnostics may be retained for support and security purposes

Registering a local or workspace file as an attachment retains a copy of its bytes in managed `incoming_media/` storage, subject to the same cleanup.
Attachment cleanup does not delete unmanaged source or workspace files or copies retained by Matrix homeservers.
Active attachment references and filesystem failures can preserve local media beyond 30 days.
Scheduled cleanup describes the repository's configured policy, not proof that a particular deployment has completed every cleanup run.

## Account Deactivation / Deletion

The MindRoom iOS app provides an in-app account deactivation path:

- `Settings` -> `Account` -> `Delete / Deactivate Account`

Actual deletion/deactivation behavior depends on the capabilities and policies of your Matrix homeserver.

Hosted MindRoom service account deletion is a separate control-plane flow with a 7-day grace period and is not triggered by Matrix account deactivation.
Requesting deletion stops the account's hosted instances right away and lets its paid Stripe subscriptions end at the end of their current billing period; cancelling the deletion within the grace period keeps them.
After the grace period, the hard-delete procedure cancels any remaining subscription, uninstalls the account's hosted instances, including their Matrix homeserver data, persistent volumes, and platform-paid AI keys, then targets application-database subscription, instance, audit-log, and subscription-linked usage records, and finally deletes the authentication user, which removes the account record.
Payment and webhook-event rows are kept for accounting with only their account link cleared; they keep Stripe customer and subscription identifiers, and webhook payloads can include the account ID and invoice contact details.
It does not delete Stripe customer or subscription records or copies of Matrix data held by other homeservers; those processors and operators have separate deletion boundaries.

## Security

We use reasonable measures to protect information, but no system is completely secure.

## Children's Privacy

MindRoom is not intended for children under 13 (or the minimum age required in your jurisdiction) without appropriate supervision and authorization.

## Changes to This Policy

We may update this policy from time to time. The "Last updated" date will change when material updates are published.

## Contact

For privacy questions, contact:

- [support@mindroom.chat](mailto:support@mindroom.chat) for private account, privacy, or data requests
- [MindRoom GitHub issues](https://github.com/mindroom-ai/mindroom/issues) for general policy questions; do not post personal data or private account details publicly

# Terms of Service

Last updated: 2026-02-28

These Terms of Service govern your use of MindRoom services and related MindRoom client applications (including the iOS app).

## Acceptance

By using MindRoom, you agree to these Terms.

If you do not agree, do not use the service or applications.

## Description of Service

MindRoom provides AI-agent and Matrix-based collaboration tooling, including client applications that connect to Matrix homeservers.

## Your Responsibilities

You agree to:

- use the service lawfully
- follow applicable platform rules and local laws
- respect other users and communities
- maintain the security of your accounts and devices

## Acceptable Use

You may not use MindRoom to:

- harass, threaten, or abuse others
- distribute illegal content
- violate intellectual property rights
- attempt unauthorized access to systems or accounts
- interfere with service operation or security

We may restrict access for abuse, security risks, or legal compliance.

## Matrix Homeserver Dependency

MindRoom clients connect to Matrix homeservers.

- Availability, retention, moderation, and account policies may be controlled by the homeserver operator.
- If you use a third-party or self-hosted homeserver, separate terms may apply.

## Open Source

MindRoom is open source, and source code for the app and related services is publicly available at [github.com/mindroom-ai](https://github.com/mindroom-ai).

## Content and Moderation

You are responsible for content you send or upload.

MindRoom clients may provide reporting and blocking tools, but moderation outcomes can depend on the homeserver and room administrators.

## No Warranty

MindRoom is provided "as is" and "as available" without warranties of any kind, to the extent permitted by law.

## Limitation of Liability

To the maximum extent permitted by law, MindRoom and its operators are not liable for indirect, incidental, special, consequential, or punitive damages arising from your use of the service.

## Changes

We may update these Terms from time to time. Continued use after changes are published means you accept the updated Terms.

## Contact

For questions about these Terms, contact:

- [MindRoom GitHub issues](https://github.com/mindroom-ai/mindroom/issues)
