Skip to content

Repository files navigation

qm-rs

A multiplayer agent harness for work. On the web, in Slack, and in Telegram. One binary, one database file, no build step.

Set up a team from nothing: sign in, add people, create a group, and put the agent to work — privately and together. Screenshots and worked examples.


This is not a port of QM. It is an independent Rust implementation that learns heavily from QM — a multiplayer agent harness in TypeScript. qm-rs borrows QM's core concepts and, in several places, its specific implementation ideas; it does not reproduce its code, its architecture in full, or its behaviour exactly. See Relationship to QM for what was borrowed, what was changed on purpose, and what is simply absent.

Stack: Rust (2021) · axum + tokio · Tera server-rendered templates · rusqlite (bundled SQLite) behind an r2d2 pool · compile-time embedded versioned migrations · serde/serde_json/toml · reqwest · tracing · WasmEdge for plugins (optional feature).

What it is

Most agents are personal assistants: one person, one context, one history. That breaks down as soon as a team shares one — either everybody sees everybody's notes, or each person gets an assistant that knows nothing about the company.

qm-rs takes the position QM takes. Every person and every room gets its own scope: its own memory, files, keychain view, 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.

                       ┌──────────────────────────────┐
   web UI (Tera) ─────▶│                              │
   Slack       ───────▶│        Orchestrator          │──▶ SQLite
   Telegram    ───────▶│  resolve → screen → harness  │    sessions · memory
   cron        ───────▶│        → tools → persist     │    skills · crons · acl
   HTTP API    ───────▶│                              │    sessions · api keys
                       └───────────────┬──────────────┘
                                       │
                              per-scope sandbox
                          (durable dir, policy-gated exec)

Every turn — typed, scheduled, or arriving from Slack or Telegram — runs through one orchestrator. Surfaces never reach past it, which is what keeps one identity and one policy across all of them.

Tutorial

The getting started guide walks through setting up a team from nothing — sign in as the administrator, add two people, put them in a group, then watch them work with the agent privately and together.

It is generated by performing it: scripts/tutorial.sh drives a real browser through every step against a real model, and the screenshots and agent replies in it are the actual result. If a step stops working, the guide fails to build, so it cannot drift from the application.

Quick start

cargo run

That's it. With no config file the server boots on http://127.0.0.1:8080 with the mock harness: deterministic, in-process, no credentials, no network. Open the dashboard and start a session.

The mock harness reads directives out of the message text, so you can drive the real tool surface without a model:

!exec echo hello           run a shell command in the scope's sandbox
!write notes.md hello      write a file
!read notes.md             read it back
!remember Ada likes tea    record a fact in this scope's memory
!recall tea                search memory
!exec rm -rf build         → pauses for approval (the command policy)

To use a real model, point it at any OpenAI-compatible endpoint with tool calling:

# config.toml
[harness]
kind = "openai"
endpoint = "https://your-gateway.example.com/v1"
model = "openai/gpt-5.4"
api_key = "gw-..."          # or leave empty and export QM_HARNESS_API_KEY

Every credential works the same way: set it in config.toml, or leave the field empty and put it in the env var named beside it. The file is checked first, and an empty string means "not set", so the field can stay visible as documentation without shadowing the environment. config.toml is gitignored.

cargo run --release

config.example.toml documents every knob in place.

Adding people

The administrator is whoever [auth].admin_email names. Everyone else is added at runtime from Admin → People, with no config edit and no restart: a person is a directory entry with an email address, and adding one means they may now sign in with a link to it.

Two membership modes, chosen with [auth].membership_mode:

Mode Who may sign in
allowlist (default) only people added here, or matched by allowed_emails / allowed_domains
denylist anyone with a valid address, unless deactivated — upstream QM's model, where the workspace is the perimeter

Under denylist, bound it with allowed_domains so only your company's addresses are accepted.

Deactivating is the offboarding verb in both modes: it refuses the next sign-in and immediately invalidates every session and API key that person holds.

Groups

A group is a set of people who share one scope — one memory, one workspace, one transcript. Create one under Admin → Groups. Groups are keyed by their participants, so the same set of people always resolves to the same group, whichever surface the conversation arrives on.

If you run a chat connector, the same page binds a real Telegram group or Slack channel to a group scope, so the chat and the web UI share one memory. Without a binding, a connector derives its own separate scope from the chat id.

Onboarding

A person's first conversation is onboarded by the agent itself, not by a form. While their memory notebook carries no completion marker, the turn is told to introduce itself and walk them through setup; the agent records Onboarding: completed v1 on <date> in that notebook when it is done. Memory is the source of truth, so there is no second table to keep in sync and the person can read and edit their own state. Publish a skill named onboarding and the agent will follow that instead of improvising.

Signing in

Set who may sign in, then open the app:

[org]
admin = "ada"

[auth]
admin_email = "ada@example.com"
public_url = "http://127.0.0.1:8080"

[email]
mode = "console"           # the link goes to the server log

Enter the address, and the sign-in link appears in the log:

WARN qm_rs::auth::email: sign-in link (console mode; treat this as a password):
     http://127.0.0.1:8080/auth/callback?token=...

Console mode is not a placeholder — for a single-operator install, reading your own log is a perfectly good way to sign in to your own server.

To send real email, use Resend: get a key from resend.com/api-keys and verify a sending domain first, because an unverified sender is rejected by the provider.

[email]
mode = "resend"
api_key = "re_..."                  # or leave empty and export QM_EMAIL_API_KEY
from_address = "qm@your-domain.com" # must be on a domain you verified
from_name = "Acme QM"

public_url under [auth] is what sign-in links point back at, so set it to something the recipient can actually reach.

Nobody can sign in until you say who may. With no admin_email, allowed_emails or allowed_domains, every address is refused and the server warns at boot. "Anyone with an email address" is never the default.

Core concepts

Scopes

A scope is the unit that owns memory, files, skills, keychain entries, crons and permissions. Its id is <kind>:<ref>:

Scope Example Who reads it
personal personal:ada Ada alone
channel channel:eng everyone in the channel
group group:g1 everyone in the group
org org:acme every internal principal

A turn resolves to a layer stack: the org scope mounted read-only beneath the turn's own scope, which is writable. A personal turn writes to the person; a channel turn writes to the channel — so what the agent learns in a channel belongs to the channel, not to whoever happened to speak.

The tool surface

Small and fixed, as upstream's is:

Tool What it does
execute run a shell command in the scope's durable sandbox
read / write / list files in the workspace
memory capture, query or read this scope's notebook
history search earlier conversations in reachable scopes
cron schedule work to run later
skills list or read the available skills
share grant another scope access to a file
finish_silently end the turn without replying

Plugins can add more (see below). A plugin may not shadow a built-in.

Authentication

People sign in with an emailed magic link — one use, short expiry, no password to leak. Programs use bearer API keys minted from /account.

Sessions, login links and API keys are all high-entropy random strings shown once and stored only as a SHA-256 hash, so read access to the database does not hand anyone a live credential. Deactivating a principal invalidates their sessions and keys immediately.

Every page and API handler takes an authenticated principal as an argument, so a handler cannot forget to check — it will not compile without one. The only routes outside that are the sign-in flow, /api/health, and /slack/events, which authenticates by request signature instead.

# Mint a key at /account, then:
curl -X POST localhost:8080/api/turn \
  -H "Authorization: Bearer qmk_..." \
  -H "content-type: application/json" \
  -d '{"text":"what changed in the deploy?"}'

A key acts as its owner, with their scopes and permissions. Keys cannot mint further keys — that requires a signed-in browser, so one leaked key does not become permanent access.

Security

An org picks one posture; a scope may only tighten it.

Posture Behaviour
strict every tool call pauses for a human
auto (default) external content and tool output are screened before the model sees them
dangerous no screening, no pauses

The predeclared command policy applies in every posture, dangerous included. Recursive deletes, force pushes and destructive SQL ask for approval; mkfs and fork bombs are denied outright. Rules match against a normalized form of the command, so quoting, escaping and nesting do not get past them:

rm -rf /tmp/x          → approval
rm '-rf' /tmp/x        → approval
rm \-rf /tmp/x         → approval
sh -c 'rm -rf /tmp/x'  → approval
psql -c 'DROP TABLE users'  → approval
echo 'notes about mkfs'     → allowed  (quoted prose is not a command)

Approvals are durable rows, not process state, so a pause survives a restart. Approving with scope session or always records a standing grant so that class of command stops asking.

Memory

One Markdown notebook per scope, recalled at the start of every turn. Captures dedupe and carry a date. Facts arriving from untrusted provenance are rewritten so they cannot forge the notebook's own grammar — a leading (2020-01-01) becomes prose, and a trailing (said in #ops) becomes an explicit [claimed source: ...]. Every write is a revision, and the editor compare-and-swaps on the revision it loaded, so a concurrent edit is reported rather than silently overwritten.

Skills

Scope-owned instruction bundles with YAML-ish frontmatter. Signed on write and verified on read: a skill whose stored rows were changed outside the app is hidden from every turn rather than executed. Editing a published skill returns it to draft. A nearer scope's skill shadows a shared one of the same name.

Slack

[slack]
enabled = true
allowed_channels = ["C01234567"]

[slack.principals]
"U01234567" = "ada"
export QM_SLACK_BOT_TOKEN=xoxb-...    # OAuth & Permissions
export QM_SLACK_APP_TOKEN=xapp-...    # app-level token, connections:write
cargo run --release

Socket Mode by default: an outbound WebSocket, so no public URL and no inbound port. Scopes the bot needs: app_mentions:read, channels:history, chat:write, im:history, users:read.

  • A DM → the sender's personal scope.
  • A channel → channel:slack-<channel_id>, and by default the bot only answers when @-mentioned.
  • Each Slack thread gets its own session, so two conversations in one channel do not interleave.
  • An unmapped Slack user becomes a guest principal slack:<user_id>.

For a deployment that already terminates HTTPS, set mode = "events" and a signing_secret; Slack then POSTs to /slack/events. Every request is signature-verified — HMAC over v0:<timestamp>:<body>, with a five-minute window so a captured request cannot be replayed — before anything in the body is read. Events are deduplicated by id, so Slack's retries never run a turn twice.

Telegram

[telegram]
enabled = true
allowed_chat_ids = [123456789]

[telegram.principals]
"123456789" = "ada"
export QM_TELEGRAM_BOT_TOKEN=123456:ABC-DEF...
cargo run --release

Get a token from @BotFather. The connector long-polls getUpdates — no webhook, no public URL, no inbound port.

  • A private chat → the sender's personal scope.
  • A group → channel:tg-<chat_id>, and by default the bot only answers when @-mentioned.
  • An unmapped Telegram user becomes a guest principal telegram:<user_id>.

A bot is addressable by anyone who knows its handle, so leave allowed_chat_ids empty only for a bot nobody else can find.

Plugins

QM's plugins are two different things, and qm-rs treats them differently:

  • Surfaces (Slack, web UI, admin, portal upstream) are I/O-driven daemons holding sockets and timers. Here the web UI and Telegram connector are native in-process Rust.
  • Deployment extension points — organization-specific tools, the security screener, turn middleware — are pure functions over bytes. Those run as WasmEdge modules, using the same ABI as cloud_ai_gateway, so modules and authoring patterns carry across.
cargo build --release --features wasm

wasmedge-sdk/standalone downloads the runtime at build time but does not bake an rpath into the binary, so the dynamic loader needs to be told where it landed:

export DYLD_FALLBACK_LIBRARY_PATH="$HOME/.wasmedge/lib"   # macOS
export LD_LIBRARY_PATH="$HOME/.wasmedge/lib"              # Linux

--features wasm-static links it in instead, for a single self-contained binary. scripts/tutorial.sh resolves the path itself, and is a working reference for the whole flow.

Write a module against plugins/qm_plugin_sdk:

use qm_plugin_sdk::{PluginRequest, PluginResponse};

qm_plugin_sdk::handler!(process);

fn process(req: PluginRequest) -> PluginResponse {
    match req.hook.as_str() {
        "screen" if req.content().contains("ignore your instructions") =>
            PluginResponse::quarantine("prompt injection"),
        "screen" => PluginResponse::allow(),
        "turn.before" => PluginResponse::pass().route("openai/gpt-5.4-mini"),
        _ => PluginResponse::failure("unsupported hook"),
    }
}
cd plugins/modules/example_guard
cargo build --release --target wasm32-wasip1
cp target/wasm32-wasip1/release/example_guard.wasm ../

Two worked examples ship in plugins/modules/: service_registry adds a custom agent tool (and is the one the tutorial installs), and example_guard answers the screen and turn.before hooks.

[plugins]
dir = "plugins/modules"
screener = "example_guard.wasm"
turn_middleware = ["example_guard.wasm"]

[[plugins.tools]]
name = "lookup_order"
description = "Look up an order by id"
module = "orders.wasm"
parameters = '{"type":"object","properties":{"order_id":{"type":"string"}}}'

Three hooks: tool:<name> (a custom agent tool, selectable per scope), turn.before (rewrite the text, route the model, extend the prompt), and screen (the security screener). Each call gets a fresh store and instance, so one scope's call cannot observe another's. A screener that fails, or returns anything other than auto, fails closed.

Without --features wasm the binary still builds and runs; configured modules are reported as inert on /admin rather than silently ignored.

Database migrations

Migrations are sql/migrations/NNNN_<what>.sql, embedded at compile time, applied in order inside their own transactions, and tracked in schema_migrations. They run automatically on every boot, and /admin shows applied versus registered so drift is visible.

To change the schema:

  1. add sql/migrations/NNNN_<what>.sql;
  2. register it in src/db.rs::MIGRATIONS.

Never edit an applied migration. cargo test enforces that the registry stays ordered and unique.

Deployment needs the binary plus templates/ — the sql/ folder is the canonical, reviewable history, not a runtime dependency.

HTTP API

# Run a turn
curl -X POST localhost:8080/api/turn -H 'content-type: application/json' \
  -d '{"actor":"ada","text":"what changed in the deploy?","thread_ref":"t1"}'

# Resolve an approval
curl -X POST localhost:8080/api/turn -H 'content-type: application/json' \
  -d '{"actor":"ada","thread_ref":"t1",
       "approval":{"request_id":"...","approved":true,"scope":"session"}}'

curl localhost:8080/api/sessions/<id>   # full transcript
curl localhost:8080/api/health
curl -N localhost:8080/api/events       # SSE, live turn progress

Development

cargo fmt --all -- --check
cargo clippy --all-targets -- -D warnings
cargo test
bash tests/smoke_test.sh     # boots the real server, walks the whole pipeline

All four must pass. The smoke test needs no network and no credentials: it runs the mock harness against a temp database and exercises sign-in, tool dispatch, the command policy, sandbox confinement, approvals, memory, scope isolation, crons, Slack signature verification, every rendered page, and a restart.

Live end-to-end tests

Two suites run against a real model. Their specs are adapted from the ones QM runs as its own e2e — the agent loop in process (pi-harness.e2e.test.ts), the same behaviour over HTTP (http.e2e.test.ts), plus the scenarios qm-rs has (execute-turn, cron-create) — so both projects ask the agent loop the same questions.

cp .env.e2e.example .env.e2e   # endpoint, key, model
bash scripts/e2e.sh            # 14 live tests, in process and over HTTP
bash scripts/e2e_report.sh     # browser journey → an HTML report

.env.e2e is gitignored and the key is never printed. Without it, cargo test skips these entirely and never reaches the network.

scripts/e2e.sh asserts those specs: generation, execute against a real sandbox, write-then-read, in-session and cross-session memory, the session log, the resolved system prompt actually reaching the model, a denied command never running, a policy hit pausing for a human, scope isolation, a published skill being followed, and screener behaviour under injection.

scripts/tutorial.sh performs the getting-started tutorial and regenerates docs/ from the run — the guide published at second-state.github.io/qm-rs. It only replaces the committed guide when every chapter passes.

scripts/e2e_report.sh drives a real Chrome through 15 workflows in the web UI, capturing a captioned screenshot per step, and writes a self-contained report to e2e-reports/<timestamp>/report.html:

e2e-reports/20260801T181246Z/
├── report.html        # every workflow, pass/fail, screenshots + captions
├── report.json        # the same, machine-readable
├── 01.png … 32.png    # one per step
└── server.log

Every agent reply in that report is a genuine turn — no fixtures, no fake harness. Reports are gitignored; they contain live model output.

KNOWLEDGE.md has the design rationale and the deliberate limitations.

Relationship to QM

QM is a multiplayer agent harness in TypeScript — roughly 107k lines across a core, four plugin packages and a CLI. qm-rs is a separate program written in Rust. It is not a translation, a fork, or a compatible reimplementation: no code was carried across, the two do not share a database or an API, and behaviour differs in places both large and small.

What qm-rs does take from QM is the thinking. Reading QM is what settled most of the design questions below, and several of its specific mechanisms were good enough to adopt more or less directly.

Concepts borrowed

The shape of the system is QM's:

  • Scopes as the unit that owns memory, files, skills, credentials, permissions and scheduled work — with personal, group, channel and org kinds, and a layer stack that composes them.
  • One orchestrator every surface flows through, so identity and policy stay the same whether a turn arrives from the web, a chat connector, or a cron.
  • A small fixed tool surface with execute at its centre, running in the scope's own durable computer.
  • A security posture an org sets and a narrower scope may only tighten, with a predeclared command policy that applies in every posture.
  • Memory as a per-scope notebook the agent reads at the start of a turn and writes back to.
  • Skills as scope-owned, signed, shareable instruction bundles.
  • Plugins as extension points, not surfaces.

Implementation ideas adopted

These are places where QM had solved something specific and qm-rs does it the same way rather than reinventing it:

Idea Where
Normalizing a command before matching policy rules, so quoting and nesting cannot smuggle a flag past one policy::command::scannable_command
The deny-is-final composition rule, and a scope that may only add rules policy::command::compose_policy
Screening that fails closed, with unreachable distinguished from clean policy::security
The memory notebook's line grammar — dated bullets, dedupe by normalized text, tail-capped recall memory
Declawing untrusted facts so they cannot forge the notebook's own grammar memory::fold_capture
Onboarding as a conversation tracked by a marker line in the person's own memory onboarding
Groups keyed by their sorted participant set, so the same people always resolve to the same group store::directory::participant_key
Claiming a scheduled instant to make a cron fire exactly once store::crons::claim_fire
Ack-first-then-work for Slack, made safe by an event-id claim connectors::slack
The security-screener system prompt policy::security

The live e2e suite is also ported from QM's own e2e specs (pi-harness.e2e.test.ts, http.e2e.test.ts), so the two are at least asking the same questions of the agent loop.

Deliberate departures

Where qm-rs disagrees with QM rather than merely omitting something:

  • Membership. QM's identity service treats anyone in the Slack workspace as internal unless deactivated — a deny-list, because the workspace is the perimeter. qm-rs is web-first with no workspace to inherit, so allowlist is the default; denylist is offered for deployments that have a perimeter elsewhere.
  • The directory is curated, not synced. QM replaces the whole directory from Slack. qm-rs has an admin UI instead, and no sync.
  • Command normalization is stricter. QM drops quoted arguments entirely, which keeps echo 'notes about mkfs' inert but also hides psql -c 'DROP TABLE users'. qm-rs scans quoted arguments to known interpreters and SQL clients as code, while quoted prose elsewhere stays inert.
  • Granted file handles are de-collided. Two grants whose basenames match would both mount at shared/<name> and the second would silently shadow the first; qm-rs renames it.
  • Persistence. Postgres and a distributed queue become one SQLite file.

Not present

The Vite/Lit single-page app · the vendor harness SDKs (Pi, OpenCode, Codex, Claude Code) — the Harness trait is the seam where those would go, and an OpenAI-compatible implementation stands in · cloud sandboxes (Fly machines, AWS microVMs), so isolation here is path confinement rather than kernel-level · the deployment directory and qm CLI · web-app publishing · OAuth connectors and the credential broker · pluggable external identity providers · multi-instance leader election.

QM is MIT-licensed, as is this.

License

MIT.

About

A multiplayer agent harness for work — Rust core on local SQLite, with a server-rendered web UI, Slack and Telegram connectors, and WasmEdge plugins.

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages