Set up a team from nothing: sign in, add people, group them, and put the agent to work — privately and together.
qm-rs is a multiplayer agent harness for work. Most AI agents are built as personal assistants: one person, one context, one history. That model breaks down the moment a team tries to share one — either everybody sees everybody's notes, or each person gets an assistant that knows nothing about the company.
qm-rs takes a different position. Every person and every room gets its own scope: its own memory, files, credentials, permissions, scheduled jobs, and a durable working directory the agent can actually run commands in. People work independently without stepping on each other, and the same agent also works with everyone together in shared groups and channels.
qm-rs is an independent Rust implementation of that idea on local SQLite. It is one binary and one database file, with a server-rendered web UI, connectors for Slack and Telegram, and an HTTP API — no build step and nothing to orchestrate. It learns heavily from QM, the TypeScript project that established this design, borrowing its concepts and several of its specific mechanisms without being a port of it.
The design turns on one idea: every turn goes through a single orchestrator. Whether a message arrives from the web UI, from Slack, from Telegram, or from a scheduled job, it takes the same path — resolve the scope, screen the input, assemble the prompt, drive the model over a small fixed set of tools, and write the result down. Surfaces never reach past the orchestrator, which is what keeps one identity and one policy across all of them.
That is also what makes the security properties hold everywhere at once. A command policy that pauses a recursive delete pauses it in Slack too. A scope boundary that stops the agent quoting your private notes in a group holds no matter which surface asked.
You need a running qm-rs server. See the README for install instructions. In short: cargo run starts it on port 8080 with a mock harness that needs no credentials, and pointing [harness] at any OpenAI-compatible endpoint with tool calling makes it real.
Set [auth].admin_email in config.toml to your own address before you start, or nobody will be able to sign in.
qm has no passwords. You sign in with a link sent to your email address, which works exactly once and expires after fifteen minutes. On a fresh install the administrator is whoever [auth].admin_email names in config.toml; the [email] section is where you point it at your mail provider. Start the server, then follow along.

[auth].admin_email and press Email me a link.
A person in qm is a directory entry: an id, a display name, and an email address. There is no invitation to accept and no password to choose — adding someone means they may now sign in with a link to that address, whenever they like. Each person gets a personal scope that nobody else can read: their own memory, files, credentials and working directory.

[auth].membership_mode in config.toml. allowlist (the default) admits only people listed here or matched by a rule in the config — nobody else can sign in. denylist admits anyone with a valid address unless you deactivate them, which suits a deployment where something else already bounds who can reach the server, such as a VPN or an SSO proxy. Bound it with allowed_domains so only your company's addresses are accepted.
U… id.A group is a set of people who share one scope: one memory, one working directory, one set of files. What the agent learns while working in the group belongs to the group, not to whoever happened to type the message. This is the difference between qm and a personal assistant — the same agent works for individuals and for teams, and keeps the boundary.


group:ops. Its three members can open sessions in it; nobody else can see it at all.group:ops. The chat and the web UI then share one memory and one workspace — a decision made in Slack is visible on the web, and vice versa. Without a binding, a connector derives its own separate scope from the chat id.Now someone actually uses it. Ada signs in with her own link and asks the agent to build something in her personal scope. Behind that scope is a durable working directory — a computer that persists between conversations, where anything the agent installs stays installed.

personal:ada — give the session a title, and click Start.
mkfs are refused outright and cannot be approved. This floor applies in every security posture.Finally, the multiplayer part. Ada brings her work into the group, and Dana — a different person, in a different browser, with her own session — picks it up and asks for a change. Both are talking to the same agent in the same scope, and the group remembers.
group:ops this time, and starts a session there.



The tool surface is small and fixed on purpose, but a deployment almost always needs a verb that reaches into something only it has — an internal API, a registry, a database. Those run as WebAssembly modules: a module is a pure function over bytes, so it gets the model's arguments and returns text, and cannot touch the host's filesystem, network or database except through what you compile into it.
plugins/qm_plugin_sdk. The one installed here is plugins/modules/service_registry — an ops service registry that answers who owns a service, where its runbook is, and who is on call:
use qm_plugin_sdk::{PluginRequest, PluginResponse};
qm_plugin_sdk::handler!(process);
fn process(request: PluginRequest) -> PluginResponse {
let service = request.arg("service").unwrap_or("");
PluginResponse::output(look_up(service))
}cd plugins/modules/service_registry && cargo build --release --target wasm32-wasip1, then copy the resulting .wasm into your plugin directory.config.toml, giving it the name and JSON Schema the model will see:
[plugins]
dir = "plugins/modules"
[[plugins.tools]]
name = "lookup_service"
description = "Look up who owns a service, its runbook, and who is on call."
module = "service_registry.wasm"
parameters = '{"type":"object","properties":{"service":{"type":"string"}},"required":["service"]}'cargo build --release --features wasm — and restart it. Without that feature the module is reported as inert rather than silently ignored.
service_registry.wasm is loaded and offering lookup_service to every scope. A missing file would show as MISSING here, and a build without the wasm feature would show every module as inert.
lookup_service and answered from the registry. The call and its result are ordinary transcript entries — a plugin tool is not a second class of thing.execute always means execute. Modules can also answer two other hooks: turn.before, to rewrite a turn or route it to a different model, and screen, to run your own security classifier instead of the built-in one.